fix(backend): JWT jti, NUL-byte guard, dev-only truncate endpoint

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-16 19:01:34 +02:00
parent e6efffafe5
commit 05f76514a2
7 changed files with 126 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
# --- Build stage --- # --- 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 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 src ./src
COPY static ./static COPY static ./static
COPY migrations ./migrations
RUN touch src/main.rs && cargo build --release RUN touch src/main.rs && cargo build --release
# --- Runtime stage --- # --- Runtime stage ---

View File

@@ -54,6 +54,13 @@ pub async fn join(
"Name muss zwischen 1 und 50 Zeichen lang sein.".into(), "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( let event = Event::find_or_create(
&state.pool, &state.pool,

View File

@@ -13,6 +13,13 @@ pub struct Claims {
pub role: UserRole, pub role: UserRole,
pub exp: i64, pub exp: i64,
pub iat: 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( pub fn create_token(
@@ -29,6 +36,7 @@ pub fn create_token(
role, role,
iat: now.timestamp(), iat: now.timestamp(),
exp: (now + Duration::days(expiry_days)).timestamp(), exp: (now + Duration::days(expiry_days)).timestamp(),
jti: Uuid::new_v4(),
}; };
jsonwebtoken::encode( jsonwebtoken::encode(
&Header::default(), &Header::default(),

View File

@@ -4,4 +4,5 @@ pub mod host;
pub mod me; pub mod me;
pub mod social; pub mod social;
pub mod sse; pub mod sse;
pub mod test_admin;
pub mod upload; pub mod upload;

View File

@@ -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<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<StatusCode, AppError> {
// 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")
}

View File

@@ -107,6 +107,23 @@ async fn main() -> Result<()> {
) )
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs)); .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 // Serve media files from disk
let media_service = ServeDir::new(&config.media_path); let media_service = ServeDir::new(&config.media_path);

View File

@@ -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 /// Drop keys whose windows are empty after expiring old timestamps. Called from a
/// background task (see [`crate::services::maintenance`]) so that long-lived /// background task (see [`crate::services::maintenance`]) so that long-lived
/// processes don't accumulate one HashMap entry per IP that ever connected. /// processes don't accumulate one HashMap entry per IP that ever connected.