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:
@@ -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 ---
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
85
backend/src/handlers/test_admin.rs
Normal file
85
backend/src/handlers/test_admin.rs
Normal 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")
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user