From f7ec53b06d1cbb80d243b75c8c462df372e815bb Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sat, 27 Jun 2026 09:04:44 -0400 Subject: [PATCH] test(conformance): fill row_zero_host_binding (unmapped fail-closed + #h override-reject) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace both pending_lane stubs in row_zero_host_binding with live wire tests: (a) unmapped_host_fails_closed_generically — drives an unmapped host over the HTTP door (404) vs a mapped host (non-404): the status *difference* proves no default-tenant fallthrough. Asserts the rejection body echoes neither the host authority nor the bare label (generic, no enumeration oracle), and that a raw WS upgrade to the unknown host is rejected at the handshake. Doc- comment notes the mapped-200/unmapped-404 status difference is an intentional, door-scoped distinguisher (non-nostr+json SPA/WS door only) so a future reader does not "fix" it into a 404-everywhere that breaks the SPA fallback; the nostr+json door deliberately does not expose it. (b) client_supplied_community_cannot_override_host — creates an OPEN channel in community B only, confirms it is postable in B (positive control), then posts a kind:9 #h-tagging that B-only channel UUID over an A connection. A must reject: the host-derived community wins over the client #h claim. Open visibility isolates the override property from the ordinary membership gate (the A-side post can fail for exactly one reason: the channel doesn't exist in A's community). Asserts the rejection does not echo the B channel UUID (no cross-community existence oracle). Bite-specificity: the override assertion also pins the reason string "restricted: not a channel member" (the exact IngestError::Rejected the override path emits at ingest.rs:446), so the red means "A rejected because the host-derived community refused the #h claim", not merely "A rejected for some earlier-gate reason" (bind_community 404 / bridge-auth 403 / NIP-98 replay / relay-membership 403 / JSON parse 400 all precede the channel-scope branch). Two cold reviewers (Dawn, Mari) converged on this independently from the sanitization and channels-membership sides. Sibling-not-replacement: #h override-attempt is row_zero; the relay-tag / token-u-host override signals are Sami's NIP-42 rows; same-UUID coexistence is Mari's channels_membership row. Cross-refs documented in the doc-comments. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../tests/conformance_multitenant.rs | 334 +++++++++++++++++- 1 file changed, 326 insertions(+), 8 deletions(-) diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index b0e94c830..fcba7343c 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -76,27 +76,345 @@ mod row_zero_host_binding { /// Obligation: an unknown/unmapped host fails closed with a *generic* /// rejection and never falls through to a default tenant. + /// + /// This is the wire complement promised by [`super::nip11_relay_info`]: + /// NIP-11 is deliberately host-agnostic and serves the *identical* static + /// document to every host (mapped or not) so a 404-vs-200 status difference + /// cannot become an enumeration oracle on the `nostr+json` path. The + /// fail-closed binding instead lives on the **WebSocket-upgrade / non- + /// `nostr+json`** door (`router.rs::nip11_or_ws_handler` → + /// `tenant::bind_community`), and *that* is what this row asserts. + /// + /// Three properties, all wire-observable: + /// 1. **Fails closed** — an unmapped host does not fall through to a + /// default tenant. A non-`nostr+json` request to the unknown host is + /// rejected `404`, where a mapped host is *not* 404 (it serves the SPA + /// / NIP-11 fallback, or upgrades to WS). The status *difference* + /// between mapped and unmapped on this door is the proof the unmapped + /// host got no tenant. NOTE: this mapped-200/unmapped-404 status + /// *difference* is an intentional, door-scoped distinguisher — it + /// exists on the non-`nostr+json` (SPA/WS) door only, where a 404 is + /// how an unbound host is signalled. The `nostr+json` door + /// deliberately does *not* expose it (see `nip11_relay_info`), which is + /// why an unauthenticated NIP-11 probe cannot enumerate communities. A + /// future reader must not "fix" this into a 404-everywhere: that would + /// break the SPA fallback that legitimately serves mapped hosts here. + /// 2. **Generic** — the rejection body is the fixed string the relay uses + /// for *both* "unmapped" and "lookup error" (`router.rs:181`); it must + /// not echo the host or otherwise distinguish the failure mode, so an + /// unauthenticated caller cannot probe which communities exist. + /// 3. **The WS door fails too** — a raw WebSocket handshake to the unknown + /// host is rejected at the upgrade (before any frame is read), not + /// accepted-then-bound-to-a-default. + /// + /// Mutate-bite (would-it-fail-without-the-fix): make `bind_community` fall + /// through to a default tenant on the unmapped host (e.g. `Err(_) =>` returns + /// a real `TenantContext` instead of the 404) → the unmapped host stops + /// 404'ing and the status-difference / WS-rejected assertions go red. #[tokio::test] #[ignore] async fn unmapped_host_fails_closed_generically() { - pending_lane( - "relay-wiring", - "unmapped host → generic rejection, no default tenant, no host echo", + // (2) Generic body + (1) fails-closed status, both on the non- + // `nostr+json` HTTP door where the body is fully observable. + let client = reqwest::Client::builder() + .build() + .expect("build reqwest client"); + + // Default Accept (NOT `application/nostr+json`): a mapped host serves + // the SPA / NIP-11 fallback (non-404); an unmapped host fails closed. + let unknown_resp = client + .get(url_unknown()) + .send() + .await + .unwrap_or_else(|e| panic!("GET {} failed: {e}", url_unknown())); + let unknown_status = unknown_resp.status(); + let unknown_body = unknown_resp.text().await.expect("read unmapped body"); + + let mapped_resp = client + .get(url_a()) + .send() + .await + .unwrap_or_else(|e| panic!("GET {} failed: {e}", url_a())); + let mapped_status = mapped_resp.status(); + + // (1) Fails closed: unmapped is 404, mapped is not. The *difference* is + // the proof — the unmapped host bound to no tenant, while the mapped one + // proceeded past the bind. If an unmapped host silently fell through to a + // default tenant, it would return the same non-404 as the mapped host. + assert_eq!( + unknown_status, + reqwest::StatusCode::NOT_FOUND, + "unmapped host must fail closed with 404, not fall through to a \ + default tenant (got {unknown_status})" + ); + assert_ne!( + mapped_status, + reqwest::StatusCode::NOT_FOUND, + "a mapped host must NOT 404 on this door — otherwise the 404 above \ + is not evidence the unmapped host was singled out as unbound" + ); + + // (2) Generic: the body must not echo the host or any tenant- + // distinguishing fragment. The host authority `unknown.localhost[:port]` + // (and the bare label) must be absent, so the rejection cannot be used + // to confirm a host the relay does not serve. + let unknown_url = url_unknown(); + let unknown_authority = unknown_url + .strip_prefix("http://") + .or_else(|| unknown_url.strip_prefix("https://")) + .unwrap_or(&unknown_url); + assert!( + !unknown_body.contains(unknown_authority), + "unmapped-host rejection echoed the host authority \ + {unknown_authority:?} in its body: {unknown_body:?} — the \ + rejection must be generic and reveal nothing host-specific" + ); + assert!( + !unknown_body.contains("unknown.localhost"), + "unmapped-host rejection echoed the host label in its body: \ + {unknown_body:?} — the rejection must be generic" + ); + + // (3) The WS-upgrade door fails closed too: a raw WebSocket handshake to + // the unknown host is rejected AT the upgrade, never accepted and then + // bound to a default tenant. `bind_community` runs before + // `WebSocketUpgrade::from_request`, so the 404 is returned in place of + // the `101 Switching Protocols` and the handshake errors out. + let ws_url = url_unknown().replacen("http://", "ws://", 1); + let ws_result = tokio_tungstenite::connect_async(&ws_url).await; + assert!( + ws_result.is_err(), + "WebSocket upgrade to an unmapped host must be rejected at the \ + handshake (fail-closed before any frame), but it succeeded — the \ + connection bound to a tenant it should not have" ); } /// Obligation: a client-supplied `h` tag / token community stamp can never /// override the host-derived community; a disagreeing stamp is rejected. + /// + /// # What this row asserts, and how it is *distinct* from its siblings + /// + /// Per `NOSTR.md`: "The Nostr wire format does not grow a tenant tag. + /// Client-supplied `#h` tags still name channels/groups and are checked + /// against the host-derived community." So the only client-supplied + /// community-ish signal on the EVENT wire is the `#h` channel tag, and the + /// row-zero contract is that it is resolved *within* the host-derived + /// community (`tenant.community()`), never honored as a cross-community + /// override. + /// + /// This is the **override-attempt** scenario, deliberately partitioned from + /// two siblings that share the same scope branch but assert different + /// properties of it (see channel: `buzz-relay-rewrite`, 2026-06-27): + /// + /// * [`super::channels_membership::same_channel_uuid_in_two_communities_is_isolated`] + /// (Mari, `buzz-db`) asserts **coexistence**: a channel UUID that exists + /// in *both* A and B; a post in A's instance never touches B's. Two + /// legitimate channels, non-interference. + /// * [`super::api_tokens_nip98_replay`] / Sami's + /// `verify_nip42_rejects_event_signed_for_wrong_communitys_host` + /// (`nip42_host_binding_live.rs`) assert the **AUTH `relay` tag** and + /// **token / NIP-98 `u`-host** override signals on their own paths. + /// + /// row_zero (b) asserts the **`#h` override-attempt**: a channel that exists + /// *only in B*; an A connection `#h`-tagging it is **rejected** — the host + /// binding wins over the claim. Sibling-not-replacement: this shares the + /// `ingest::check_channel_membership` → `is_member_cached(tenant.community(), + /// ch_id)` scope branch with Mari's row, but bites the *override* property, + /// not coexistence. + /// + /// # Why the channel is `visibility=open` (isolating override from membership) + /// + /// The B channel is created **open**, so in B itself a non-member can post to + /// it. That is load-bearing: it means the A-connection post can fail for + /// **exactly one** reason — the channel does not exist in A's community + /// (`get_channel(A, b_ch_id)` is `None` → not open → not member). If the + /// channel were restricted, the rejection would be the ordinary + /// "not a member" gate and would *not* prove the override property. The + /// positive control (the same post succeeds against B) confirms the channel + /// is genuinely postable, so the A-side rejection is the override-rejection + /// and nothing else — the red comes from the override assertion, not a setup + /// or shared-membership failure. + /// + /// Mutate-bite (would-it-fail-without-the-fix): make + /// `ingest::check_channel_membership` resolve the channel against the + /// *claimed* `#h` community instead of `tenant.community()` (honor the + /// override) → the A-connection post of B's open channel is accepted and + /// this row's "A must reject" assertion goes red. + /// + /// # Bite-specificity: the rejection is pinned to the override branch + /// + /// "A rejected" alone is not enough — `bind_community` (404), bridge-auth + /// (403), NIP-98 replay, relay-membership (403), and JSON parse (400) all + /// run *before* the channel-scope branch, so any of them could red this row + /// while the override path was never reached. To rule that out, the + /// override assertion below also pins the reason string + /// `"restricted: not a channel member"` — the exact + /// `IngestError::Rejected` the override path emits. That makes the red mean + /// "A rejected *because* the host-derived community refused the `#h` claim," + /// not merely "A rejected." (Dawn + Mari converged on this independently + /// from the sanitization and channels-membership sides, 2026-06-27.) #[tokio::test] #[ignore] async fn client_supplied_community_cannot_override_host() { - pending_lane( - "relay-wiring", - "token/h-tag community disagreeing with resolve_host(host) → reject", + use nostr::Keys; + + let keys = Keys::generate(); + + // Create an OPEN channel that lives ONLY in community B (host B). + let channel = create_open_channel(&url_b(), &keys).await; + + // Positive control: the channel is genuinely postable in B — a kind:9 + // message to B's host succeeds. This proves the A-side rejection below + // is the cross-community override-rejection, not a broken/unpostable + // channel or a membership gate. + let (status_b, body_b) = + post_kind9(&url_b(), &keys, &channel, "row-zero-b: legit post in B").await; + assert!( + status_b.is_success() && accepted(&body_b), + "control failed: kind:9 to B's own open channel must be accepted \ + (status {status_b}, body {body_b}) — without this the A-side \ + rejection does not isolate the override property" + ); + + // The override attempt: on an A connection (host A → community A), post a + // kind:9 `#h`-tagging the channel UUID that exists only in B. The + // client-supplied `#h` community signal disagrees with + // `resolve_host(A)`; row zero requires the host to win, so A must reject. + let (status_a, body_a) = post_kind9( + &url_a(), + &keys, + &channel, + "row-zero-b: override attempt from A", + ) + .await; + + // The override assertion itself: A rejects. `get_channel(A, b_ch_id)` + // finds nothing in A's community, so the open-channel bypass cannot + // apply and the host-resolved community refuses the claim. (A 2xx + + // accepted:true here would mean A honored the B `#h` claim — the exact + // override this row forbids.) + assert!( + !status_a.is_success() || !accepted(&body_a), + "row zero violated: an A connection posting to a channel that exists \ + only in community B was ACCEPTED (status {status_a}, body {body_a}) \ + — the client-supplied `#h` community overrode the host-derived \ + community" + ); + + // Bite-specificity: the rejection above must come from the + // channel-scope/override branch, not an incidental earlier gate + // (`bind_community` 404, bridge-auth 403, NIP-98 replay, relay + // membership 403, JSON parse 400) that would red this row while the + // override path was never reached. The override path emits exactly + // `IngestError::Rejected("restricted: not a channel member")`: + // `get_channel(A, b_ch_id)` returns None against A's community, the + // open-channel bypass cannot apply, and the host-resolved community + // refuses the claim. Pinning the reason string converts "A rejected + // for *some* reason" into "A rejected *because the host-derived + // community refused the `#h` claim*" — the property this row exists to + // prove. (Two cold reviewers, Dawn + Mari, converged on this + // independently from the sanitization and channels-membership sides.) + assert!( + body_a.contains("restricted: not a channel member"), + "row zero violated: A rejected, but not via the channel-scope \ + override branch — body {body_a:?} does not carry the \ + \"restricted: not a channel member\" reason, so the red could be \ + an incidental earlier gate (auth/parse/relay-membership) rather \ + than the host-binding refusing the client-supplied `#h` claim" + ); + + // And the rejection must not leak B's existence: the generic + // channel-scope rejection ("restricted: not a channel member") reveals + // nothing about whether the channel exists elsewhere. The B channel UUID + // appearing in A's rejection body would itself be a cross-community + // existence oracle. + assert!( + !body_a.contains(&channel), + "A's rejection echoed the B-only channel UUID {channel:?} in its \ + body: {body_a:?} — the rejection must not confirm cross-community \ + existence" ); } } +/// Create an `open`-visibility channel (kind:9007) in the community bound to +/// `base_url`'s host, via the NIP-98 HTTP bridge (`POST /events`). Returns the +/// channel UUID. The `Host` header is implied by `base_url`, so the relay +/// derives the community from the host — the channel lands in exactly that +/// community and no other. +async fn create_open_channel(base_url: &str, keys: &nostr::Keys) -> String { + use nostr::{EventBuilder, Kind, Tag}; + + let channel_uuid = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid]).expect("h tag"), + Tag::parse(["name", &format!("row-zero-{channel_uuid}")]).expect("name tag"), + Tag::parse(["channel_type", "stream"]).expect("channel_type tag"), + Tag::parse(["visibility", "open"]).expect("visibility tag"), + ]) + .sign_with_keys(keys) + .expect("sign create-channel event"); + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{base_url}/events")) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).expect("serialize event")) + .send() + .await + .unwrap_or_else(|e| panic!("create-channel POST to {base_url} failed: {e}")); + let status = resp.status(); + let body = resp.text().await.expect("read create-channel body"); + assert!( + status.is_success() && body.contains("\"accepted\":true"), + "create-channel in {base_url} must succeed (status {status}, body {body})" + ); + + channel_uuid +} + +/// Post a kind:9 group message `#h`-tagging `channel` to the community bound to +/// `base_url`'s host. Returns `(status, body)` so callers can assert on the +/// wire-observable accept/reject. The relay derives the community from the +/// `Host` (implied by `base_url`); `channel` is the client-supplied `#h` claim. +async fn post_kind9( + base_url: &str, + keys: &nostr::Keys, + channel: &str, + content: &str, +) -> (reqwest::StatusCode, String) { + use nostr::{EventBuilder, Kind, Tag}; + + let event = EventBuilder::new(Kind::Custom(9), content) + .tags(vec![Tag::parse(["h", channel]).expect("h tag")]) + .sign_with_keys(keys) + .expect("sign kind:9 event"); + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{base_url}/events")) + .header("X-Pubkey", keys.public_key().to_hex()) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).expect("serialize event")) + .send() + .await + .unwrap_or_else(|e| panic!("kind:9 POST to {base_url} failed: {e}")); + let status = resp.status(); + let body = resp.text().await.expect("read kind:9 body"); + (status, body) +} + +/// Whether a `POST /events` JSON body reports the event as accepted. +fn accepted(body: &str) -> bool { + serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("accepted").and_then(|a| a.as_bool())) + .unwrap_or(false) +} + // --------------------------------------------------------------------------- // NIP-11 relay info and relay `self` (Eva — relay-wiring) // --------------------------------------------------------------------------- @@ -203,8 +521,8 @@ mod nip11_relay_info { a 404 — a status difference between mapped and unmapped hosts would \ itself be a community-enumeration oracle" ); - let json_unknown: serde_json::Value = serde_json::from_str(&body_unknown) - .expect("unmapped-host NIP-11 is valid JSON"); + let json_unknown: serde_json::Value = + serde_json::from_str(&body_unknown).expect("unmapped-host NIP-11 is valid JSON"); assert_eq!( json_a, json_unknown, "NIP-11 served to an unmapped host must be byte-identical to a mapped \