diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4c3479c2..a54adf9b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(wc:*)", - "Bash(tree:*)" + "Bash(tree:*)", + "mcp__ide__getDiagnostics" ], "deny": [], "ask": [] diff --git a/backend/src/auth.rs b/backend/src/auth.rs index ece95ac4..4592f6ac 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,6 +1,3 @@ - - - use axum::{ extract::FromRequestParts, http::{ diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index de28285f..e5cec2c9 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -18,22 +18,39 @@ struct LoginAttempt { blocked_until: Option, } -fn hash_login_identifier(username: &str) -> String { - static SALT: OnceLock = OnceLock::new(); +static LOGIN_ATTEMPT_SALT: OnceLock = OnceLock::new(); - let salt = SALT.get_or_init(|| { - env::var("LOGIN_ATTEMPT_SALT").unwrap_or_else(|_| { - tracing::error!( - "LOGIN_ATTEMPT_SALT environment variable is missing. Set a high-entropy value to enable secure brute-force protection." - ); - panic!( - "LOGIN_ATTEMPT_SALT must be set to a random, high-entropy string. See .env.example for guidance." - ); - }) - }); +pub fn init_login_attempt_salt() -> Result<(), String> { + let raw = env::var("LOGIN_ATTEMPT_SALT") + .map_err(|_| "LOGIN_ATTEMPT_SALT environment variable not set".to_string())?; + let trimmed = raw.trim(); + + if trimmed.len() < 32 { + return Err("LOGIN_ATTEMPT_SALT must be at least 32 characters long".to_string()); + } + + let unique_chars = trimmed.chars().collect::>().len(); + if unique_chars < 10 { + return Err("LOGIN_ATTEMPT_SALT must contain at least 10 unique characters".to_string()); + } + LOGIN_ATTEMPT_SALT + .set(trimmed.to_string()) + .map_err(|_| "LOGIN_ATTEMPT_SALT already initialized".to_string())?; + + Ok(()) +} + +fn login_attempt_salt() -> &'static str { + LOGIN_ATTEMPT_SALT + .get() + .expect("LOGIN_ATTEMPT_SALT not initialized. Call init_login_attempt_salt() first.") + .as_str() +} + +fn hash_login_identifier(username: &str) -> String { let mut hasher = Sha256::new(); - hasher.update(salt.as_bytes()); + hasher.update(login_attempt_salt().as_bytes()); hasher.update(username.trim().to_ascii_lowercase().as_bytes()); format!("{:x}", hasher.finalize()) } diff --git a/backend/src/handlers/site_pages.rs b/backend/src/handlers/site_pages.rs index 9b762a07..3074397e 100644 --- a/backend/src/handlers/site_pages.rs +++ b/backend/src/handlers/site_pages.rs @@ -286,8 +286,9 @@ fn map_page( ) })?; + let sanitized_slug = slug.trim().to_lowercase(); let sanitized_title = match title.trim() { - "" => slug.clone(), + "" => sanitized_slug.clone(), value => value.to_string(), }; @@ -304,7 +305,7 @@ fn map_page( Ok(SitePageResponse { id, - slug, + slug: sanitized_slug, title: sanitized_title, description: sanitized_description, nav_label: sanitized_nav_label, @@ -480,14 +481,18 @@ pub async fn get_navigation( let mut items = Vec::with_capacity(pages.len()); for page in pages { + let normalized_slug = page.slug.trim().to_lowercase(); + if normalized_slug.is_empty() { + continue; + } items.push(NavigationItemResponse { id: page.id, - slug: page.slug, + slug: normalized_slug.clone(), label: page .nav_label .clone() .filter(|label| !label.trim().is_empty()) - .unwrap_or(page.title.clone()), + .unwrap_or_else(|| page.title.trim().to_string()), order_index: page.order_index, }); } @@ -557,7 +562,17 @@ pub async fn list_published_page_slugs( .await .map_err(|err| map_sqlx_error(err, "Navigation"))?; - let slugs = pages.into_iter().map(|page| page.slug).collect(); + let slugs = pages + .into_iter() + .filter_map(|page| { + let normalized = page.slug.trim().to_lowercase(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } + }) + .collect(); Ok(Json(slugs)) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 4994c852..ebf65992 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -223,6 +223,9 @@ async fn main() { csrf::init_csrf_secret().expect("Failed to initialize CSRF secret"); tracing::info!("CSRF secret initialized successfully"); + handlers::auth::init_login_attempt_salt().expect("Failed to initialize login attempt salt"); + tracing::info!("Login attempt salt initialized successfully"); + let pool = db::create_pool() .await .expect("Failed to create database pool"); @@ -260,7 +263,11 @@ async fn main() { Method::OPTIONS, Method::HEAD, ])) - .allow_headers(AllowHeaders::list(vec![AUTHORIZATION, CONTENT_TYPE])) + .allow_headers(AllowHeaders::list(vec![ + AUTHORIZATION, + CONTENT_TYPE, + HeaderName::from_static(csrf::csrf_header_name()), + ])) .allow_credentials(true); tracing::info!(origins = ?allowed_origins, "Configured CORS origins"); @@ -340,10 +347,10 @@ async fn main() { delete(handlers::comments::delete_comment), ) - .route_layer(middleware::from_extractor::()) - .route_layer(middleware::from_fn(auth::auth_middleware)) + .route_layer(middleware::from_extractor::()) + .layer(RequestBodyLimitLayer::new(ADMIN_BODY_LIMIT)) .layer(GovernorLayer::new(admin_rate_limit_config.clone())); diff --git a/src/api/client.js b/src/api/client.js index 7cef8c0c..b40a3466 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -62,11 +62,17 @@ class ApiClient { setToken(token) { if (token && typeof token !== 'string') { console.warn('Attempted to set invalid JWT token; ignoring') + this.token = null + return } - this.token = null + this.token = typeof token === 'string' && token.trim() !== '' ? token.trim() : null } getHeaders() { - return {} + const headers = {} + if (this.token) { + headers.Authorization = `Bearer ${this.token}` + } + return headers } async request(endpoint, options = {}) { const { diff --git a/src/components/Comments.jsx b/src/components/Comments.jsx index b90971eb..3cbff929 100644 --- a/src/components/Comments.jsx +++ b/src/components/Comments.jsx @@ -1,21 +1,40 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { MessageSquare, Send, Trash2 } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { api } from '../api/client'; import PropTypes from 'prop-types'; +const VALID_TUTORIAL_ID = /^[a-zA-Z0-9_-]+$/; + const Comments = ({ tutorialId }) => { const [comments, setComments] = useState([]); const [newComment, setNewComment] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [loadingComments, setLoadingComments] = useState(false); + const [loadError, setLoadError] = useState(null); const { isAuthenticated, user } = useAuth(); const isAdmin = Boolean(user && user.role === 'admin'); - useEffect(() => { - loadComments(); + const normalizedTutorialId = useMemo(() => { + if (typeof tutorialId !== 'string') { + return null; + } + const trimmed = tutorialId.trim(); + if (!trimmed || trimmed.length > 100 || !VALID_TUTORIAL_ID.test(trimmed)) { + return null; + } + return trimmed; }, [tutorialId]); - const loadComments = async () => { + + const loadComments = useCallback(async () => { + if (!normalizedTutorialId) { + setComments([]); + setLoadError(new Error('Kommentare für diese Ressource sind deaktiviert.')); + return; + } + setLoadingComments(true); + setLoadError(null); try { // Fetch comments from the API using the tutorial ID - const data = await api.listTutorialComments(tutorialId); + const data = await api.listTutorialComments(normalizedTutorialId); // Ensure we always have an array, even if API returns unexpected data setComments(Array.isArray(data) ? data : []); } catch (error) { @@ -23,18 +42,24 @@ const Comments = ({ tutorialId }) => { console.error('Failed to load comments:', error); // Set empty array to maintain consistent state setComments([]); + setLoadError(error); } - }; + setLoadingComments(false); + }, [normalizedTutorialId]); + + useEffect(() => { + loadComments(); + }, [loadComments]); const handleSubmit = async (e) => { // Prevent default form submission behavior e.preventDefault(); // Validate form data and user permissions - if (!newComment.trim() || !isAuthenticated || !isAdmin) return; + if (!canManageComments || !newComment.trim()) return; // Set loading state to prevent duplicate submissions setIsLoading(true); try { // Submit comment to API - await api.createComment(tutorialId, newComment); + await api.createComment(normalizedTutorialId, newComment); // Clear form for next comment setNewComment(''); // Refresh comments list to show new comment @@ -49,9 +74,9 @@ const Comments = ({ tutorialId }) => { }; const handleDelete = async (commentId) => { // Double-check admin permissions - if (!isAdmin) return; + if (!canManageComments) return; // Show confirmation dialog to prevent accidental deletion - if (!confirm('Kommentar wirklich löschen?')) return; + if (typeof window !== 'undefined' && !window.confirm('Kommentar wirklich löschen?')) return; try { // Delete comment from API await api.deleteComment(commentId); @@ -62,6 +87,8 @@ const Comments = ({ tutorialId }) => { console.error('Failed to delete comment:', error); } }; + const canManageComments = isAdmin && normalizedTutorialId; + return (
{} @@ -70,7 +97,7 @@ const Comments = ({ tutorialId }) => { Kommentare ({comments.length}) {} - {isAdmin && ( + {canManageComments && (