From 05f76514a29ac0db6cba4089cd6ca32a9697d8ab Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 16 May 2026 19:01:34 +0200 Subject: [PATCH 1/3] fix(backend): JWT jti, NUL-byte guard, dev-only truncate endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced while running the new E2E suite, plus a small test hook: - jwt.rs: add a per-token `jti: Uuid` claim. Without it, two `create_token` calls in the same wall-clock second for the same (sub, role, event_id) produced identical JWT bytes — and identical sha256(token) hashes — which then collided on `session.token_hash UNIQUE` with a 500. Manifests in real use when an admin clicks "Anmelden" twice fast. - auth/handlers.rs: reject display names containing 0x00. Postgres rejects NUL in TEXT columns with `invalid byte sequence for encoding "UTF8"` and the request leaks back as a 500. Now returns 400 with a clean message. - handlers/test_admin.rs + main.rs: new POST /api/v1/admin/__truncate route, compiled in always but only **registered** when EVENTSNAP_TEST_MODE=1 is set on startup. Truncates every event-scoped table, reseeds config from migration defaults, wipes media on disk, and clears the in-memory rate limiter. RequireAdmin-gated so it's not anonymous even in test mode. In production builds (no env var) the route returns 404 — verified by the startup log message. - services/rate_limiter.rs: add `clear()` so the truncate handler can wipe the in-memory window map between tests. - Dockerfile: bump rust:1.87 → rust:1.88 (current dep tree needs it) and COPY ./migrations into the build context so the `sqlx::migrate!()` macro can resolve at compile time. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Dockerfile | 3 +- backend/src/auth/handlers.rs | 7 +++ backend/src/auth/jwt.rs | 8 +++ backend/src/handlers/mod.rs | 1 + backend/src/handlers/test_admin.rs | 85 ++++++++++++++++++++++++++++ backend/src/main.rs | 17 ++++++ backend/src/services/rate_limiter.rs | 6 ++ 7 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 backend/src/handlers/test_admin.rs diff --git a/backend/Dockerfile b/backend/Dockerfile index 39e4159..3953c2f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # --- Build stage --- -FROM rust:1.87-alpine AS builder +FROM rust:1.88-alpine AS builder RUN apk add --no-cache musl-dev pkgconfig openssl-dev @@ -12,6 +12,7 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \ COPY src ./src COPY static ./static +COPY migrations ./migrations RUN touch src/main.rs && cargo build --release # --- Runtime stage --- diff --git a/backend/src/auth/handlers.rs b/backend/src/auth/handlers.rs index 1912a82..b8c6749 100644 --- a/backend/src/auth/handlers.rs +++ b/backend/src/auth/handlers.rs @@ -54,6 +54,13 @@ pub async fn join( "Name muss zwischen 1 und 50 Zeichen lang sein.".into(), )); } + // Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers + // see a clean 400 instead of an internal error. + if display_name.contains('\0') { + return Err(AppError::BadRequest( + "Name enthält ungültige Zeichen.".into(), + )); + } let event = Event::find_or_create( &state.pool, diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs index 0af1ac9..99cf235 100644 --- a/backend/src/auth/jwt.rs +++ b/backend/src/auth/jwt.rs @@ -13,6 +13,13 @@ pub struct Claims { pub role: UserRole, pub exp: i64, pub iat: i64, + /// Random per-token identifier. Without it, two `create_token` calls in the + /// same wall-clock second for the same (sub, role, event) produce identical + /// JWT bytes — and identical sha256(token) hashes — which then collide on + /// the `session.token_hash` UNIQUE constraint. The jti is ignored by the + /// verifier but breaks the collision. + #[serde(default)] + pub jti: Uuid, } pub fn create_token( @@ -29,6 +36,7 @@ pub fn create_token( role, iat: now.timestamp(), exp: (now + Duration::days(expiry_days)).timestamp(), + jti: Uuid::new_v4(), }; jsonwebtoken::encode( &Header::default(), diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 652fab2..fbdf47b 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -4,4 +4,5 @@ pub mod host; pub mod me; pub mod social; pub mod sse; +pub mod test_admin; pub mod upload; diff --git a/backend/src/handlers/test_admin.rs b/backend/src/handlers/test_admin.rs new file mode 100644 index 0000000..f3d0cc4 --- /dev/null +++ b/backend/src/handlers/test_admin.rs @@ -0,0 +1,85 @@ +//! Test-only admin routes. **Compiled in always, but only registered when +//! `EVENTSNAP_TEST_MODE=1` is set in the environment.** The route returns a hard +//! 404 in production builds because [`crate::main`] skips registering the handler. +//! +//! These exist to give the Playwright E2E suite a quick "reset everything" +//! escape hatch without forcing tests to maintain raw SQL fixtures or spin up a +//! fresh database container per test. + +use axum::extract::State; +use axum::http::StatusCode; + +use crate::auth::middleware::RequireAdmin; +use crate::error::AppError; +use crate::state::AppState; + +/// Truncates every event-scoped table, wipes media on disk, and reseeds the +/// `config` table from migration defaults. Requires an admin JWT — even with +/// `EVENTSNAP_TEST_MODE=1` it cannot be hit anonymously. +pub async fn truncate_all( + State(state): State, + RequireAdmin(_auth): RequireAdmin, +) -> Result { + // Truncate in dependency order doesn't matter with CASCADE, but listing the + // tables explicitly makes the blast radius obvious in code review. + sqlx::query( + r#"TRUNCATE + comment_hashtag, + upload_hashtag, + hashtag, + "like", + comment, + export_job, + upload, + session, + "user", + event, + config + RESTART IDENTITY CASCADE"#, + ) + .execute(&state.pool) + .await?; + + // Reseed config — mirrors migrations 005 and 009. Kept in sync by hand + // because pulling SQL out of the migration files at runtime is fragile. + sqlx::query( + r#"INSERT INTO config (key, value) VALUES + ('max_image_size_mb', '20'), + ('max_video_size_mb', '500'), + ('upload_rate_per_hour', '10'), + ('feed_rate_per_min', '60'), + ('export_rate_per_day', '3'), + ('quota_tolerance', '0.75'), + ('estimated_guest_count', '100'), + ('compression_concurrency', '2'), + ('rate_limits_enabled', 'false'), + ('upload_rate_enabled', 'false'), + ('feed_rate_enabled', 'false'), + ('export_rate_enabled', 'false'), + ('join_rate_enabled', 'false'), + ('quota_enabled', 'false'), + ('storage_quota_enabled', 'false'), + ('upload_count_quota_enabled', 'false'), + ('privacy_note', '') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"#, + ) + .execute(&state.pool) + .await?; + + // Wipe media directory. Best-effort: if it doesn't exist, that's fine. + let _ = tokio::fs::remove_dir_all(&state.config.media_path).await; + let _ = tokio::fs::create_dir_all(&state.config.media_path).await; + + // The rate limiter holds an in-memory HashMap; clear it so a previous test's + // counters don't leak into the next one. + state.rate_limiter.clear(); + + Ok(StatusCode::NO_CONTENT) +} + +/// Returns whether the truncate endpoint is enabled. Used by the e2e harness +/// during global-setup to fail loud if the test backend was started without +/// `EVENTSNAP_TEST_MODE=1`. +pub fn is_test_mode() -> bool { + std::env::var("EVENTSNAP_TEST_MODE").as_deref() == Ok("1") +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 970bddd..6053027 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -107,6 +107,23 @@ async fn main() -> Result<()> { ) .route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs)); + // Test-only route: a hard reset for the Playwright E2E harness. The handler + // is compiled in always, but the route is only attached when + // `EVENTSNAP_TEST_MODE=1`. In production the call returns 404 — the route + // simply isn't there. + let api = if handlers::test_admin::is_test_mode() { + tracing::warn!( + "EVENTSNAP_TEST_MODE=1 — registering /api/v1/admin/__truncate. \ + DO NOT enable this in production." + ); + api.route( + "/api/v1/admin/__truncate", + post(handlers::test_admin::truncate_all), + ) + } else { + api + }; + // Serve media files from disk let media_service = ServeDir::new(&config.media_path); diff --git a/backend/src/services/rate_limiter.rs b/backend/src/services/rate_limiter.rs index d7593a5..733640a 100644 --- a/backend/src/services/rate_limiter.rs +++ b/backend/src/services/rate_limiter.rs @@ -42,6 +42,12 @@ impl RateLimiter { } } + /// Wipe every tracked window. Used by the test-mode truncate route so a previous + /// test's accumulated counters don't bleed into the next test's rate-limit checks. + pub fn clear(&self) { + self.windows.lock().unwrap().clear(); + } + /// Drop keys whose windows are empty after expiring old timestamps. Called from a /// background task (see [`crate::services::maintenance`]) so that long-lived /// processes don't accumulate one HashMap entry per IP that ever connected. From 1cdab21514b7f2e5b12a4380da8c9ad0c340d41a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 16 May 2026 19:01:54 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(frontend):=20a11y=20backdrop,=20?= =?UTF-8?q?=E2=89=A544px=20PIN=20button,=20test-ids=20on=20auth=20&=20uplo?= =?UTF-8?q?ad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - account/+page.svelte: remove `aria-hidden="true"` from the leave-confirm and data-mode-warning bottom-sheet backdrops. The attribute cascaded into the dialog children, making the inner Abmelden/Aktivieren/Abbrechen buttons unreachable in the accessibility tree (and to Playwright's `getByRole`). Discovered while writing the E2E suite; the visual layout is unchanged. - join/+page.svelte: bump the PIN-copy button from `py-1` (28px tall) to `min-h-11 min-w-11 py-2` so it clears the ≥44px touch-target floor on mobile. Touch-target audit revealed the gap. - data-testid attributes on stable interactive elements (join name input, join submit, PIN modal + copy + continue, recovery PIN + submit + try- different-name, admin login password + submit + error, recover name + PIN + submit + error, upload header submit + sticky submit + caption textarea). Targeted at ~20 spots where semantic locators were ambiguous (e.g. two "Hochladen" buttons on /upload, German strings that may iterate). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/routes/account/+page.svelte | 4 ++-- frontend/src/routes/admin/login/+page.svelte | 4 +++- frontend/src/routes/join/+page.svelte | 19 +++++++++++++------ frontend/src/routes/recover/+page.svelte | 5 ++++- frontend/src/routes/upload/+page.svelte | 3 +++ 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index bbdce47..ec04814 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -384,7 +384,7 @@ {#if dataModeWarningOpen} -
(dataModeWarningOpen = false)} aria-hidden="true"> +
(dataModeWarningOpen = false)}>
e.stopPropagation()} @@ -418,7 +418,7 @@ {#if leaveConfirmOpen} -
(leaveConfirmOpen = false)} aria-hidden="true"> +
(leaveConfirmOpen = false)}>
e.stopPropagation()} diff --git a/frontend/src/routes/admin/login/+page.svelte b/frontend/src/routes/admin/login/+page.svelte index 38add3e..79cb1b7 100644 --- a/frontend/src/routes/admin/login/+page.svelte +++ b/frontend/src/routes/admin/login/+page.svelte @@ -45,16 +45,18 @@ bind:value={password} placeholder="Passwort" autocomplete="current-password" + data-testid="admin-password-input" class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500" /> {#if error} -

{error}

+

{error}

{/if}
{#if showPinModal} -
+

Dein Wiederherstellungs-PIN

@@ -178,10 +183,11 @@

- {pin} + {pin} @@ -189,6 +195,7 @@