diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..d28fb9922b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -621,20 +621,16 @@ pub async fn submit_event( ) -> Result, (StatusCode, Json)> { // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped write, identical to the WS door in `router.rs`. - // Unmapped host or lookup failure fails closed with a generic 404 — never a - // default tenant, never echoing the host. + // Unmapped host or lookup failure fails closed with a generic body — never + // a default tenant, never echoing the host. The status still separates + // permanent (unmapped → 404) from transient (lookup failure → 503). let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let tenant = crate::tenant::bind_community(&state.db, raw_host) .await - .map_err(|_| { - api_error( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - })?; + .map_err(|err| api_error(err.http_status(), err.public_message()))?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); let (pubkey, event_id_bytes) = verify_bridge_auth( @@ -888,21 +884,16 @@ pub async fn query_events( ) -> Result, (StatusCode, Json)> { // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped read, identical to the WS door in `router.rs`. - // An unmapped host or lookup failure fails closed with a generic 404 — never - // a default tenant, never echoing the host (so an unauthenticated caller - // cannot probe which communities exist on this deployment). + // An unmapped host or lookup failure fails closed with a generic body — + // never a default tenant, never echoing the host. The status separates + // permanent (unmapped → 404) from transient (lookup failure → 503). let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let tenant = crate::tenant::bind_community(&state.db, raw_host) .await - .map_err(|_| { - api_error( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - })?; + .map_err(|err| api_error(err.http_status(), err.public_message()))?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); let (pubkey, event_id_bytes) = verify_bridge_auth( @@ -1340,12 +1331,7 @@ pub async fn count_events( .unwrap_or(""); let tenant = crate::tenant::bind_community(&state.db, raw_host) .await - .map_err(|_| { - api_error( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - })?; + .map_err(|err| api_error(err.http_status(), err.public_message()))?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); let (pubkey, event_id_bytes) = verify_bridge_auth( @@ -1811,16 +1797,18 @@ pub async fn workflow_webhook( // any tenant-scoped lookup or write. The host — not the workflow row — // determines the tenant: a request for community A's host may only reach // community A's workflows, even when the same workflow UUID also exists in - // community B. Unmapped host, lookup failure, and a workflow that does not - // exist in *this* community all fail closed with the same generic 404, so a - // caller cannot probe which hosts or workflow ids exist on other tenants. + // community B. An unmapped host or a workflow that does not exist in *this* + // community fail closed with the same generic 404, so a caller cannot probe + // which hosts or workflow ids exist on other tenants. A *lookup* failure + // (transient backend unavailability) surfaces as 503 instead, so clients + // retry rather than treating the relay as permanently gone. let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); let tenant = crate::tenant::bind_community(&state.db, raw_host) .await - .map_err(|_| not_found("workflow not found"))?; + .map_err(|err| api_error(err.http_status(), err.public_message()))?; let community_id = tenant.community(); let workflow = state diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c..701cec1970 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -78,12 +78,8 @@ pub async fn ws_audio_handler( .unwrap_or(""); let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { Ok(ctx) => ctx, - Err(_) => { - return ( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - .into_response(); + Err(err) => { + return (err.http_status(), err.public_message()).into_response(); } }; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..f8fb6327c0 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -299,15 +299,13 @@ async fn nip11_or_ws_handler( // `icon` simply absent), so the doc cannot leak which hosts are mapped. let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { Ok(ctx) => ctx, - Err(_) => { - // Generic rejection: do not distinguish "unmapped" from "lookup - // error", and never echo the host, so an unauthenticated caller - // cannot probe which communities exist on this deployment. - return ( - StatusCode::NOT_FOUND, - "relay: no community is configured for this host", - ) - .into_response(); + Err(err) => { + // Generic rejection: never echo the host, never reveal the + // underlying error. The status code still distinguishes permanent + // (unmapped host → 404, clients stop retrying) from transient + // (lookup failure → 503, clients retry) — collapsing both to 404 + // made DB hiccups look like the relay had vanished (#5030). + return (err.http_status(), err.public_message()).into_response(); } }; diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index 88b75f7d6e..360a4c4103 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -50,14 +50,52 @@ pub trait HostResolver: Send + Sync { pub enum BindError { /// The host did not map to any community on this deployment. Callers MUST /// reject the request with a *generic* error — never echo the host back or - /// distinguish "unmapped" from other failures, so an unauthenticated - /// caller cannot probe which hosts exist. + /// leak the underlying failure, so an unauthenticated caller cannot probe + /// which hosts exist from the response body. This is a **permanent** + /// condition: clients treat 404 as terminal and correctly stop retrying. UnmappedHost, /// The resolution lookup itself failed (e.g. database error). Treated as /// fail-closed: the request is rejected, never admitted to a default tenant. + /// Unlike [`BindError::UnmappedHost`] this is **transient** — the host may + /// be mapped, but the backend was temporarily unavailable. See + /// [`BindError::http_status`] for why callers surface it as 503 rather + /// than 404. Lookup(E), } +impl BindError { + /// HTTP status for a failed bind: 404 for a genuinely unmapped host + /// (permanent — clients treat 404 as terminal and stop retrying), 503 for + /// a lookup failure (transient — clients retry 5xx). + /// + /// #5030 measured the cost of collapsing both to 404: a relay-side DB + /// hiccup during a reconnect made an otherwise healthy relay look + /// permanently gone to clients (buzz-acp classifies `WebSocket(Http): 404` + /// as terminal), so a 22% reconnect-failure rate turned into sessions + /// going dark after the retry budget was exhausted. + /// + /// The response BODY stays byte-identical for both variants (see + /// [`BindError::public_message`]) — the anti-probe property is preserved + /// in the steady state. The only new signal is the status code, and it is + /// observable only while the backend lookup is actually failing: an + /// attacker probing during a DB outage can tell mapped hosts (503) from + /// unmapped ones (404). That narrow, outage-window leak is the accepted + /// tradeoff for correct transient semantics; the alternative made healthy + /// relays indistinguishable from decommissioned ones to every client. + pub fn http_status(&self) -> axum::http::StatusCode { + match self { + BindError::UnmappedHost => axum::http::StatusCode::NOT_FOUND, + BindError::Lookup(_) => axum::http::StatusCode::SERVICE_UNAVAILABLE, + } + } + + /// Shared, host-free rejection body — identical for both variants so the + /// message alone never reveals mapping state or the underlying error. + pub fn public_message(&self) -> &'static str { + "relay: no community is configured for this host" + } +} + /// Bind a raw connection host to a [`TenantContext`], failing closed. /// /// This is the single row-zero entry point. It normalizes the host with the @@ -330,4 +368,24 @@ mod tests { assert!(matches!(err, BindError::UnmappedHost)); } } + + #[test] + fn unmapped_host_maps_to_permanent_404_and_lookup_to_transient_503() { + // The body stays identical across variants (anti-probe property), but + // the status code separates permanent from transient so clients retry + // DB hiccups instead of abandoning the relay (#5030). + let unmapped = BindError::<&str>::UnmappedHost; + let lookup = BindError::<&str>::Lookup("db down"); + + assert_eq!(unmapped.http_status(), axum::http::StatusCode::NOT_FOUND); + assert_eq!( + lookup.http_status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(unmapped.public_message(), lookup.public_message()); + assert!( + !unmapped.public_message().contains("db down"), + "the underlying error must never leak into the rejection body" + ); + } }