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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user