diff --git a/.env.example b/.env.example index b204567..614e288 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,11 @@ DOMAIN=my-event.example.com APP_PORT=3000 # ── Database ────────────────────────────────────────────────────────────────── -DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap +# Set a strong password and keep it in sync between DATABASE_URL and +# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24 +DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap POSTGRES_USER=eventsnap -POSTGRES_PASSWORD=secret +POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password POSTGRES_DB=eventsnap # ── Authentication ──────────────────────────────────────────────────────────── diff --git a/Caddyfile b/Caddyfile index 129bb28..d560ae4 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,26 +1,38 @@ {$DOMAIN} { - encode zstd gzip + encode zstd gzip - # SvelteKit frontend — static assets with long-lived cache (content-hashed filenames) - @hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$ - header @hashed_assets Cache-Control "public, max-age=31536000, immutable" + # Site-wide security headers (defense-in-depth). HSTS is free since Caddy + # already terminates TLS. nosniff also covers all of /media/*. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + } - # Media previews and thumbnails - @previews path /media/previews/* /media/thumbnails/* - header @previews Cache-Control "public, max-age=3600" + # SvelteKit frontend — static assets with long-lived cache (content-hashed filenames) + @hashed_assets path_regexp hashed /_app/immutable/.*\.[a-f0-9]{8,}\.(js|css|woff2)$ + header @hashed_assets Cache-Control "public, max-age=31536000, immutable" - # Original media files (private — only host can download) - @originals path /media/originals/* - header @originals Cache-Control "private, max-age=86400" + # Media previews and thumbnails + @previews path /media/previews/* /media/thumbnails/* + header @previews Cache-Control "public, max-age=3600" - # API — never cache - @api path /api/* - header @api Cache-Control "no-store" + # Original media files (private — only host can download). Force download + # rather than inline rendering as defense-in-depth against any future + # content-type confusion (previews/thumbnails are re-encoded and stay inline). + @originals path /media/originals/* + header @originals Cache-Control "private, max-age=86400" + header @originals Content-Disposition "attachment" - # Route API and media requests to the Rust backend - reverse_proxy /api/* app:3000 - reverse_proxy /media/* app:3000 + # API — never cache + @api path /api/* + header @api Cache-Control "no-store" - # Everything else goes to SvelteKit frontend - reverse_proxy frontend:3001 + # Route API and media requests to the Rust backend + reverse_proxy /api/* app:3000 + reverse_proxy /media/* app:3000 + + # Everything else goes to SvelteKit frontend + reverse_proxy frontend:3001 } diff --git a/README.md b/README.md index f5af35c..91f5cea 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ eventsnap/ │ ├── svelte.config.js │ └── Dockerfile ├── docker-compose.yml +├── docker-compose.dev.yml # opt-in dev overlay (publishes Postgres on the host) ├── Caddyfile └── .env.example ``` @@ -107,6 +108,11 @@ docker compose up -d Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds. +> **Production note:** `docker compose up -d` does **not** expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly: +> ```bash +> docker compose -f docker-compose.yml -f docker-compose.dev.yml up +> ``` + ### Generate required secrets ```bash diff --git a/backend/Dockerfile b/backend/Dockerfile index 3953c2f..3dc6159 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -20,8 +20,16 @@ FROM alpine:3.21 RUN apk add --no-cache ca-certificates ffmpeg +# Run as a non-root user. Pre-create and chown the media mount path so the fresh +# named volume inherits the non-root ownership (Docker seeds an empty named volume +# from the image directory, preserving its uid/gid) and uploads can be written. +RUN addgroup -S app && adduser -S app -G app + WORKDIR /app COPY --from=builder /app/target/release/eventsnap-backend ./ +RUN mkdir -p /media && chown -R app:app /app /media +USER app + EXPOSE 3000 CMD ["./eventsnap-backend"] diff --git a/backend/src/handlers/social.rs b/backend/src/handlers/social.rs index 4c96e7b..29c1375 100644 --- a/backend/src/handlers/social.rs +++ b/backend/src/handlers/social.rs @@ -9,6 +9,7 @@ use crate::auth::middleware::AuthUser; use crate::error::AppError; use crate::models::comment::{Comment, CommentDto}; use crate::models::hashtag::{self, Hashtag}; +use crate::models::upload::Upload; use crate::state::AppState; pub async fn toggle_like( @@ -24,6 +25,12 @@ pub async fn toggle_like( return Err(AppError::Forbidden("Du bist gesperrt.".into())); } + // Event-scope: the upload must belong to the caller's event (404 otherwise), + // matching the host handlers' find_by_id_and_event pattern. + Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) + .await? + .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; + // Try to insert; if conflict, delete (toggle) let result = sqlx::query( "INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2) @@ -64,10 +71,15 @@ const COMMENT_PAGE_SIZE: i64 = 50; pub async fn list_comments( State(state): State, - _auth: AuthUser, + auth: AuthUser, Path(upload_id): Path, Query(q): Query, ) -> Result>, AppError> { + // Event-scope: only list comments for an upload in the caller's event. + Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) + .await? + .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; + let comments = Comment::list_for_upload(&state.pool, upload_id, q.before, COMMENT_PAGE_SIZE).await?; Ok(Json(comments)) @@ -91,6 +103,11 @@ pub async fn add_comment( return Err(AppError::Forbidden("Du bist gesperrt.".into())); } + // Event-scope: only comment on an upload that belongs to the caller's event. + Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) + .await? + .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; + let text = body.body.trim(); let text_chars = text.chars().count(); if text_chars == 0 || text_chars > 500 { @@ -145,6 +162,11 @@ pub async fn delete_comment( return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into())); } - Comment::soft_delete(&state.pool, comment_id).await?; + // Event-scope: soft_delete_in_event only matches comments whose upload is in + // the caller's event, so a cross-event comment_id resolves to a 404 here. + let deleted = Comment::soft_delete_in_event(&state.pool, comment_id, auth.event_id).await?; + if !deleted { + return Err(AppError::NotFound("Kommentar nicht gefunden.".into())); + } Ok(StatusCode::NO_CONTENT) } diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index a0fed5d..b04031e 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -16,6 +16,23 @@ use crate::state::AppState; const MAX_CAPTION_LENGTH: usize = 2000; +/// Allowlist of accepted media types, keyed by the MIME that `infer` derives from +/// the file's magic bytes. The detected MIME (not the client-declared one) is what +/// we trust, store, and hand to the compression pipeline — so a text-based payload +/// (SVG/HTML/JS) can never be stored or served on-origin. Each entry maps to the +/// server-controlled file extension we persist the original under. +const ALLOWED_MEDIA: &[(&str, &str)] = &[ + ("image/jpeg", "jpg"), + ("image/png", "png"), + ("image/webp", "webp"), + ("image/gif", "gif"), + ("image/heic", "heic"), + ("image/heif", "heif"), + ("video/mp4", "mp4"), + ("video/quicktime", "mov"), + ("video/webm", "webm"), +]; + pub async fn upload( State(state): State, auth: AuthUser, @@ -62,8 +79,6 @@ pub async fn upload( let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await; let mut file_data: Option> = None; - let mut file_name: Option = None; - let mut content_type: Option = None; let mut caption: Option = None; let mut hashtags_csv: Option = None; @@ -71,8 +86,9 @@ pub async fn upload( let name = field.name().unwrap_or_default().to_string(); match name.as_str() { "file" => { - file_name = field.file_name().map(|s| s.to_string()); - content_type = field.content_type().map(|s| s.to_string()); + // Note: the client-declared filename and Content-Type are intentionally + // ignored — the stored MIME and extension are derived from the file's + // magic bytes below, so a mislabelled payload can't influence them. file_data = Some( field.bytes().await .map_err(|e| AppError::BadRequest(format!("Datei konnte nicht gelesen werden: {e}")))? @@ -96,7 +112,6 @@ pub async fn upload( } let data = file_data.ok_or_else(|| AppError::BadRequest("Keine Datei hochgeladen.".into()))?; - let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); let size = data.len() as i64; // Validate caption length. Counted in chars (code points) to match the @@ -111,22 +126,23 @@ pub async fn upload( } } - // Validate file MIME type using magic bytes - let detected_mime = infer::get(&data); - if let Some(detected) = detected_mime { - let detected_type = detected.mime_type(); - // Ensure detected type is compatible with declared MIME type - let declared_category = mime.split('/').next().unwrap_or(""); - let detected_category = detected_type.split('/').next().unwrap_or(""); - - // Only reject if categories don't match (e.g., image vs video) - if declared_category != "application" && declared_category != detected_category { - return Err(AppError::BadRequest(format!( - "Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}", - mime, detected_type - ))); - } - } + // Determine the file type from its magic bytes and require it to be on the + // allowlist. `infer` returns None for text-based payloads (SVG/HTML/JS), so + // those are rejected outright — closing the stored-XSS vector. Both the MIME + // we persist and the on-disk extension come from the detected type, never from + // client-supplied values. + let kind = infer::get(&data) + .ok_or_else(|| AppError::BadRequest("Dateityp nicht erkannt oder nicht unterstützt.".into()))?; + let (mime, ext) = ALLOWED_MEDIA + .iter() + .find(|(allowed, _)| *allowed == kind.mime_type()) + .map(|(m, e)| ((*m).to_string(), *e)) + .ok_or_else(|| { + AppError::BadRequest(format!( + "Dateityp wird nicht unterstützt: {}.", + kind.mime_type() + )) + })?; // Validate file size let max_bytes = if mime.starts_with("video/") { @@ -159,12 +175,6 @@ pub async fn upload( } } - // Determine file extension - let ext = file_name - .as_deref() - .and_then(|n| n.rsplit('.').next()) - .unwrap_or(if mime.starts_with("video/") { "mp4" } else { "jpg" }); - let upload_id = Uuid::new_v4(); let event_slug = &state.config.event_slug; let relative_path = format!("originals/{event_slug}/{upload_id}.{ext}"); @@ -257,7 +267,7 @@ pub async fn edit_upload( Path(upload_id): Path, Json(body): Json, ) -> Result { - let upload = Upload::find_by_id(&state.pool, upload_id) + let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) .await? .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; @@ -285,7 +295,7 @@ pub async fn delete_upload( auth: AuthUser, Path(upload_id): Path, ) -> Result { - let upload = Upload::find_by_id(&state.pool, upload_id) + let upload = Upload::find_by_id_and_event(&state.pool, upload_id, auth.event_id) .await? .ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?; @@ -293,7 +303,7 @@ pub async fn delete_upload( return Err(AppError::Forbidden("Nur eigene Uploads löschen.".into())); } - Upload::soft_delete(&state.pool, upload_id).await?; + Upload::soft_delete_in_event(&state.pool, upload_id, auth.event_id).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 5808c28..1dc9af1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -18,6 +18,10 @@ mod state; use config::AppConfig; use state::AppState; +/// Hard HTTP body cap for the upload endpoint (576 MiB). Backstop against +/// memory-exhaustion; precise per-class size limits are enforced in the handler. +const MAX_UPLOAD_BYTES: usize = 576 * 1024 * 1024; + #[tokio::main] async fn main() -> Result<()> { dotenvy::dotenv().ok(); @@ -57,9 +61,13 @@ async fn main() -> Result<()> { .route("/api/v1/recover", post(auth::handlers::recover)) .route("/api/v1/admin/login", post(auth::handlers::admin_login)) .route("/api/v1/session", delete(auth::handlers::logout)) - // Upload — body limit disabled; size validation is done inside the handler + // Upload — HTTP-level body cap as an OOM backstop. The handler still enforces + // the precise per-class limits from DB config (max_image/video_size_mb); this + // layer just stops a multi-GB body from being buffered into memory before that + // check runs. Sized generously above the default 500 MB video limit + multipart + // overhead — if an admin raises max_video_size_mb above this, bump MAX_UPLOAD_BYTES. .route("/api/v1/upload", post(handlers::upload::upload) - .route_layer(DefaultBodyLimit::disable())) + .route_layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES))) .route( "/api/v1/upload/{id}", patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload), diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..48b4937 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,8 @@ +# Dev-only overlay. NOT loaded automatically (unlike docker-compose.override.yml). +# Opt in explicitly for local development when you need host access to Postgres: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up +# Never use this overlay in production — it publishes the database port on the host. +services: + db: + ports: + - "5432:5432" diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index b15b9e4..0000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,4 +0,0 @@ -services: - db: - ports: - - "5432:5432" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5223746..1ec10ad 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -16,6 +16,10 @@ COPY --from=builder /app/build ./build COPY --from=builder /app/package.json ./ RUN npm install --omit=dev +# Run as the image's built-in non-root `node` user. +RUN chown -R node:node /app +USER node + EXPOSE 3001 ENV PORT=3001 HOST=0.0.0.0 CMD ["node", "build"]