From 9b0bd814ce1de08911e320f7b2d0d954b087f697 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Fri, 26 Jun 2026 23:26:30 -0400 Subject: [PATCH] fix(relay): fail closed on empty/whitespace host in bind_community MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-binding seam (Inv_RowZero) must derive a community only from a request's actual Host. An empty raw_host carries no community evidence, yet bind_community passed it straight to the resolver. The schema does not forbid an `host = ''` row in communities (0001_initial_schema.sql: `host VARCHAR(255) NOT NULL`, unique index on `lower(host)`), so a misconfigured/empty-host row plus a request with a missing, unreadable, or whitespace-only Host header would silently bind to that community. Guard before the resolver lookup: if normalize_host(raw_host) is empty, return BindError::UnmappedHost. Reuses the existing variant so the rejection is byte-identical to any other unmapped host — an unauthenticated caller cannot probe whether an empty-host row exists. Red-team finding (Attack 2, MEDIUM defense-in-depth) by Sami; her three proof tests (empty, whitespace-only, plus a non-empty negative control) are included un-ignored. Verified mutate->red->restore: removing the guard reds both empty-host gates with the literal fence collapse (TenantContext{community: X, host: ""}). Full cargo test -p buzz-relay --lib: 381 passed, 0 failed. Co-authored-by: Sami Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-relay/src/tenant.rs | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index 39e1f9242f..ed150a9511 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -73,6 +73,17 @@ pub async fn bind_community( raw_host: &str, ) -> Result> { let host = normalize_host(raw_host); + // Inv_RowZero (host-binding seam): an empty raw_host carries no community + // evidence — there is no `connection.host` to resolve, so no community can + // be derived from it. Fail closed BEFORE the resolver lookup. The schema + // does not forbid an `host = ''` row in `communities`, so without this + // guard a request with a missing/whitespace-only Host header would silently + // bind to a misconfigured empty-host community. Reuse `UnmappedHost` (not a + // distinct variant) so the rejection is byte-identical to any other unmapped + // host — an unauthenticated caller cannot probe for an empty-host row. + if host.is_empty() { + return Err(BindError::UnmappedHost); + } match resolver.resolve_host(&host).await { Ok(Some(community)) => Ok(TenantContext::resolved(community, host)), Ok(None) => Err(BindError::UnmappedHost), @@ -253,4 +264,79 @@ mod tests { let err = bind_community(&r, "relay.example").await.unwrap_err(); assert!(matches!(err, BindError::Lookup("db down"))); } + + mod redteam_attack2 { + use super::*; + + /// RED gate. Configures a resolver with an `""→CommunityId` mapping + /// (the schema permits it; no CHECK against empty host exists), then + /// asks `bind_community` to bind an empty raw_host as a request with + /// a missing/invalid Host header would. Today this returns + /// `Ok(TenantContext{community=X})` — the fence collapses to the + /// misconfigured row. The fix: short-circuit in `bind_community` so + /// that `normalize_host(raw_host).is_empty()` returns + /// `Err(BindError::UnmappedHost)` before any resolver lookup. + /// + /// Generic-rejection note: we reuse `UnmappedHost` (not a new + /// `EmptyHost` variant) so the door's response is byte-identical to + /// any other unmapped host — an unauthenticated caller cannot probe + /// whether the deployment has an empty-host row. + /// + /// Delete this `#[ignore]` when the fix lands; verified RED with + /// `cargo test -p buzz-relay --include-ignored + /// tenant::tests::redteam_attack2::empty_raw_host_fails_closed_even_if_db_has_empty_host_row` + + #[tokio::test] + async fn empty_raw_host_fails_closed_even_if_db_has_empty_host_row() { + // Simulate operator misconfig / buggy migration: an empty-host row + // exists in `communities`. The schema does not forbid this. + let r = resolver_with("", 0xdeadbeef); + + // A request with a missing or unreadable Host header reaches + // `bind_community` with raw_host = "" (router.rs:169-172). The + // fence must reject — the request never supplied a host. + let err = bind_community(&r, "").await.expect_err( + "Inv_RowZero: an empty raw_host carries no community evidence; \ + bind_community must fail closed regardless of the host map", + ); + assert!( + matches!(err, BindError::UnmappedHost), + "fence must produce a generic UnmappedHost (no info leak about \ + whether an empty-host row exists); got {err:?}", + ); + } + + /// RED gate. Same property, whitespace-only host: `normalize_host` + /// trims to empty (`buzz-core::tenant::normalize_host_empty_stays_empty`), + /// so this is the same fence collapse via a different raw input. + /// + /// Delete `#[ignore]` when the fix lands. + + #[tokio::test] + async fn whitespace_only_raw_host_fails_closed_even_if_db_has_empty_host_row() { + let r = resolver_with("", 0xdeadbeef); + + let err = bind_community(&r, " ").await.expect_err( + "Inv_RowZero: whitespace-only raw_host normalizes to empty \ + (see buzz-core::tenant::normalize_host) and carries no \ + community evidence", + ); + assert!( + matches!(err, BindError::UnmappedHost), + "fence must produce a generic UnmappedHost; got {err:?}", + ); + } + + /// Negative control: a *non-empty* unmapped host must still fail + /// closed (this already passes — included so the redteam_attack2 + /// module documents both shapes of the fence's intended behavior and + /// catches a fix that accidentally over-narrows to only-empty). + #[tokio::test] + async fn non_empty_unmapped_host_still_fails_closed_after_fix() { + let r = resolver_with("", 0xdeadbeef); + let err = bind_community(&r, "evil.example").await.unwrap_err(); + assert!(matches!(err, BindError::UnmappedHost)); + } + } + }