Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 15 additions & 27 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -621,20 +621,16 @@ pub async fn submit_event(
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// 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(
Expand Down Expand Up @@ -888,21 +884,16 @@ pub async fn query_events(
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// 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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions crates/buzz-relay/src/audio/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
};

Expand Down
16 changes: 7 additions & 9 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
};

Expand Down
62 changes: 60 additions & 2 deletions crates/buzz-relay/src/tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,52 @@ pub trait HostResolver: Send + Sync {
pub enum BindError<E> {
/// 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<E> BindError<E> {
/// 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
Expand Down Expand Up @@ -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"
);
}
}