fix(auth): stop one guest on the venue NAT from locking everyone else out

Every guest at the venue shares one public IP, so an IP-keyed limiter throttles the whole
party as a single client. Three separate limits got that wrong, and the host — whose only
credential is a 4-digit PIN — was the one who could not absorb it.

/recover carried a cross-name failure budget checked BEFORE the account lookup, so it
refused a CORRECT PIN. Thirty POSTs with invented names spent the shared budget for fifteen
minutes and ~2 requests/minute sustained it indefinitely, denying PIN recovery to everyone
including a host locked out of their own event. The budget is now carried as a flag: a
correct PIN authenticates regardless, while wrong ones answer 429 instead of 401. Guessing
stays bounded where it always really was — the per-(IP,name) ceiling and the per-account
3-strike lockout, neither of which an attacker on any IP can evade.

join_ip went from 60/min to 300. A 100-guest wedding does not trickle in; it arrives when
the QR code goes up, all from one address, and guests 61-100 were turned away on the one
screen with no auto-retry. This limit only bounds raw volume — the per-name bucket is the
anti-spam control and BCRYPT_PERMITS is the CPU bound — so it can sit well above the peak.

Download tickets are now bound to ONE archive via `TicketKind::Download(ExportKind)`. Both
download routes share an authenticator, so a bare ticket opened either; combined with the
resume budget that made a single mint worth 40 transfers of a multi-GB keepsake while the
per-day limiter, charged only at mint, never moved. `kind` is consequently required at
/export/ticket; every shipped client already sends it.

The per-session ticket cap is now per-kind. A 6-hour download ticket is always the oldest
entry for its session, so ordinary SSE churn evicted it first — and /export opens its own
SSE connection on the session that just minted it. A couple of wifi flaps mid-transfer
killed the ticket, 401'd the resume, and cost the guest another of three daily downloads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:43:29 +02:00
parent 963f6449a1
commit ec7c7f18ca
5 changed files with 906 additions and 83 deletions

View File

@@ -105,7 +105,7 @@ pub struct PatchConfigRequest(pub HashMap<String, String>);
pub async fn patch_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
RequireAdmin(auth): RequireAdmin,
Json(body): Json<HashMap<String, String>>,
) -> Result<StatusCode, AppError> {
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
@@ -289,6 +289,23 @@ pub async fn patch_config(
// the TTL is only a backstop and must not be relied on for correctness.
state.config_cache.invalidate();
// Config changes were logged NOWHERE. They are the actions most likely to be blamed the
// morning after ("why did uploads stop?") and the hardest to reconstruct, because the value
// that caused the problem has since been changed back. Record the keys and their new values;
// these are operational settings, not credentials, so the payload is safe to keep.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"patch_config",
None,
None,
serde_json::to_value(&body).ok(),
)
.await;
// Notify all clients that a publicly-readable config value changed so their stores
// (e.g. the privacy note in My Account) refresh without a manual reload.
if privacy_note_changed || theme_changed {
@@ -351,8 +368,12 @@ pub struct DownloadQuery {
/// single-use, 30s-TTL store as the SSE stream.
#[derive(serde::Deserialize)]
pub struct ExportTicketQuery {
/// Which archive the ticket is for — `zip` or `html`. Optional so an older client that
/// doesn't send it keeps working; it simply skips the pre-check it doesn't know to ask for.
/// Which archive the ticket is for — `zip` or `html`.
///
/// REQUIRED. It used to be optional "so an older client keeps working", but the ticket is now
/// bound to the archive it was minted for (see `TicketKind::Download`), and a ticket with no
/// archive would either have to be valid for both — the abuse this closes — or be issued for a
/// guess that 401s at the other endpoint. Every shipped client sends it.
#[serde(default)]
pub kind: Option<String>,
}
@@ -391,15 +412,26 @@ pub async fn export_ticket(
// `fetch` that the existing `toastError` path already renders. This is NOT the HEAD probe
// ruled out elsewhere: it reads the same indexed row the download will read and touches no
// ticket, so it cannot consume anything.
if let Some(kind) = q.kind.as_deref() {
let export_type = match kind {
"zip" => "zip",
"html" => "html",
other => {
return Err(AppError::BadRequest(format!(
"Unbekannter Export-Typ: {other}"
)));
}
let export_kind = match q.kind.as_deref() {
Some("zip") => crate::services::sse_tickets::ExportKind::Zip,
Some("html") => crate::services::sse_tickets::ExportKind::Html,
Some(other) => {
return Err(AppError::BadRequest(format!(
"Unbekannter Export-Typ: {other}"
)));
}
None => {
return Err(AppError::BadRequest(
"Es fehlt die Angabe, welches Archiv geladen werden soll. Bitte lade die Seite \
neu und versuche es erneut."
.into(),
));
}
};
{
let export_type = match export_kind {
crate::services::sse_tickets::ExportKind::Zip => "zip",
crate::services::sse_tickets::ExportKind::Html => "html",
};
let msg = if export_type == "zip" {
"Der ZIP-Export ist noch nicht verfügbar."
@@ -419,7 +451,7 @@ pub async fn export_ticket(
// 503 + Retry-After, matching how `sse::issue_ticket` answers the identical condition.
let ticket = state
.sse_tickets
.issue(auth.token_hash, TicketKind::Download)
.issue(auth.token_hash, TicketKind::Download(export_kind))
.ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
@@ -432,10 +464,18 @@ pub async fn export_ticket(
/// Validate a download ticket (single-use) and confirm its session still exists.
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
/// id to key the export rate limit per-user (see `enforce_export_rate`).
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
async fn authenticate_download_ticket(
state: &AppState,
ticket: &str,
want: crate::services::sse_tickets::ExportKind,
) -> Result<Uuid, AppError> {
// Non-consuming: a keepsake download must survive being resumed with `Range`, and a
// single-use ticket meant the resume 401'd and cost the guest another of their three daily
// downloads. `redeem_download` bounds it by DOWNLOAD_TTL instead, and the session check
// below still runs on every request.
let token_hash = state
.sse_tickets
.consume(ticket, TicketKind::Download)
.redeem_download(ticket, want)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
let session = crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
.await
@@ -446,15 +486,29 @@ async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<
pub async fn download_zip(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
// Ticket validation only — the rate limit was charged at mint time, where a 429 is visible
// to the page. Charging it again here would cost every download two slots.
authenticate_download_ticket(&state, &q.ticket).await?;
authenticate_download_ticket(
&state,
&q.ticket,
crate::services::sse_tickets::ExportKind::Zip,
)
.await?;
let path =
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
serve_file(path, "Gallery.zip", "application/zip").await
serve_file(
path,
"Gallery.zip",
"application/zip",
headers
.get(axum::http::header::RANGE)
.and_then(|v| v.to_str().ok()),
)
.await
}
/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
@@ -500,45 +554,94 @@ async fn resolve_export_file(
pub async fn download_html(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
// See `download_zip`: the limit is charged at ticket mint, where the client can see it.
authenticate_download_ticket(&state, &q.ticket).await?;
authenticate_download_ticket(
&state,
&q.ticket,
crate::services::sse_tickets::ExportKind::Html,
)
.await?;
let path =
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
serve_file(path, "Memories.zip", "application/zip").await
serve_file(
path,
"Memories.zip",
"application/zip",
headers
.get(axum::http::header::RANGE)
.and_then(|v| v.to_str().ok()),
)
.await
}
/// Stream a keepsake archive, honouring `Range`.
///
/// Range support is not a nicety here. The keepsake is the emotional payoff of the product and can
/// be ~1.4 GB; without `Accept-Ranges` a download that dies at 90% over hotel wifi restarts at byte
/// zero. Worse, the 3/day limit is charged when the download TICKET is minted and ZIP+HTML already
/// costs 2 — so one dropped connection locked a guest out of their own wedding photos for ~24h.
///
/// Reuses `upload::parse_range`, which already implements exactly the forms a client sends and is
/// unit-tested there. The media routes have always done this correctly; this route was the outlier.
async fn serve_file(
path: std::path::PathBuf,
filename: &str,
content_type: &str,
range_header: Option<&str>,
) -> Result<axum::response::Response, AppError> {
use crate::handlers::upload::{RangeSpec, parse_range};
use axum::body::Body;
use axum::http::{Response, StatusCode, header};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&path)
let mut file = tokio::fs::File::open(&path)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
let len = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
.map_err(|e| AppError::Internal(e.into()))?
.len();
let disposition = format!("attachment; filename=\"{filename}\"");
let base = |status: StatusCode| {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition.clone())
// Advertised on EVERY response, including the 200. A client only knows it may resume
// if the first (unranged) response says so.
.header(header::ACCEPT_RANGES, "bytes")
};
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))?;
match parse_range(range_header, len) {
RangeSpec::Full => base(StatusCode::OK)
.header(header::CONTENT_LENGTH, len)
.body(Body::from_stream(ReaderStream::new(file)))
.map_err(|e| AppError::Internal(e.into())),
Ok(response)
RangeSpec::Partial { start, end } => {
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|e| AppError::Internal(e.into()))?;
let span = end - start + 1;
base(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_LENGTH, span)
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
.body(Body::from_stream(ReaderStream::new(file.take(span))))
.map_err(|e| AppError::Internal(e.into()))
}
RangeSpec::Unsatisfiable => base(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{len}"))
.body(Body::empty())
.map_err(|e| AppError::Internal(e.into())),
}
}
/// Also expose export status to all authenticated users (guests need it for the export page)

View File

@@ -53,12 +53,15 @@ pub async fn issue_ticket(
));
}
let ticket = state.sse_tickets.issue(auth.token_hash, TicketKind::Sse).ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Live-Updates folgen in Kürze.".into(),
Some(30),
)
})?;
let ticket = state
.sse_tickets
.issue(auth.token_hash, TicketKind::Sse)
.ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Live-Updates folgen in Kürze.".into(),
Some(30),
)
})?;
let server_time = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
@@ -68,6 +71,57 @@ pub async fn issue_ticket(
}))
}
/// Live SSE streams one session may hold OPEN at once.
///
/// The ticket store's `MAX_TICKETS_PER_SESSION` bounds UNCONSUMED tickets, not open streams — so it
/// never bounded this at all: mint a ticket, redeem it (freeing the slot), repeat. At the 60/min
/// ticket ceiling one guest could accumulate 60 new live streams per minute indefinitely, each
/// holding a broadcast receiver, a tokio task and a 60-second DB revalidation ticker.
///
/// 6 rather than 2: a guest legitimately has the feed in one tab, the diashow on a laptop, and both
/// may briefly double during a reconnect before the old socket's `Drop` lands. Well above real use,
/// far below anything that hurts.
const MAX_OPEN_STREAMS_PER_SESSION: usize = 6;
/// Open stream count per session token hash.
type OpenStreams = std::collections::HashMap<String, usize>;
static OPEN_STREAMS: std::sync::LazyLock<std::sync::Mutex<OpenStreams>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(OpenStreams::new()));
/// Decrements the open-stream count for its session when the stream is dropped.
///
/// A `Drop` guard is the only thing that works here: a client vanishing off wifi never runs any
/// cleanup path we write, but dropping the response future is exactly what happens.
struct StreamSlot(String);
impl Drop for StreamSlot {
fn drop(&mut self) {
if let Ok(mut map) = OPEN_STREAMS.lock()
&& let Some(n) = map.get_mut(&self.0)
{
*n = n.saturating_sub(1);
if *n == 0 {
map.remove(&self.0);
}
}
}
}
/// Claim one of this session's stream slots, or `None` when it is already at the cap.
fn claim_stream_slot(token_hash: &str) -> Option<StreamSlot> {
let mut map = match OPEN_STREAMS.lock() {
Ok(m) => m,
// Never let a poisoned lock take live updates down for the whole venue.
Err(e) => e.into_inner(),
};
let n = map.entry(token_hash.to_string()).or_insert(0);
if *n >= MAX_OPEN_STREAMS_PER_SESSION {
return None;
}
*n += 1;
Some(StreamSlot(token_hash.to_string()))
}
/// SSE stream endpoint. Authenticates via a single-use ticket (see
/// [`issue_ticket`]) — never the raw JWT.
pub async fn stream(
@@ -88,6 +142,17 @@ pub async fn stream(
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
// Bound how many streams this session holds open — see MAX_OPEN_STREAMS_PER_SESSION. Refuse
// rather than evict: closing somebody's live feed to make room for their own reconnect loop
// reads exactly like the flakiness it would be trying to fix.
let slot = claim_stream_slot(&token_hash).ok_or_else(|| {
tracing::warn!("session at its open-SSE-stream cap; refusing another");
AppError::TooManyRequests(
"Zu viele offene Verbindungen. Bitte schließe andere Tabs.".into(),
Some(10),
)
})?;
let rx = state.sse_tx.subscribe();
let events = BroadcastStream::new(rx).filter_map(|msg| match msg {
Ok(sse_event) => Some(Ok(Event::default()
@@ -113,6 +178,10 @@ pub async fn stream(
let pool = state.pool.clone();
let session_hash = token_hash.clone();
let session_gone = async move {
// Owns the slot guard, and this future is owned by the returned stream — so the slot is
// released exactly when the stream is dropped, including when the client simply walks out
// of range and no cleanup code of ours ever runs.
let _slot = slot;
let mut ticker = tokio::time::interval(Duration::from_secs(60));
ticker.tick().await; // consume the immediate first tick
loop {