From 809ff9faf7a999e2a8feec7b15623a0dbc3a9670 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sat, 27 Jun 2026 08:15:57 -0400 Subject: [PATCH] =?UTF-8?q?test(conformance):=20nip11=5Frelay=5Finfo=20?= =?UTF-8?q?=E2=80=94=20NIP-11=20is=20host-agnostic,=20not=20a=20community-?= =?UTF-8?q?enumeration=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real (non-pending_lane) conformance row in conformance_multitenant.rs; the reference pattern remaining rows copy. Asserts the wire-observable complement to the compile-time static-input fence (_RELAY_INFO_BUILD_STATIC_INPUT_FENCE): the NIP-11 relay-info document served for host A, host B, and an *unmapped* host are all byte-identical. Identical docs are the proof that the unauthenticated relay-info endpoint carries no host-derived field and therefore cannot be used to probe which communities are configured on a deployment. Corrects an initial design error caught by running the row against a live two-host relay: the unmapped-host case must return 200 with the same static doc, NOT 404 — a 200-vs-404 status difference between mapped and unmapped hosts would itself be the enumeration oracle. Fail-closed host binding lives on the WS-upgrade / non-nostr+json path (router.rs::nip11_or_ws_handler) and is the obligation of row_zero_host_binding, not this row. Adds url_unknown() helper (RELAY_URL_UNKNOWN) alongside url_a/url_b. Verified by hand against a live two-host relay (a/b.localhost:3100, shared PG/Redis): green -> mutate (leak request Host into the served description) -> red on the A==B assertion with a real wire diff, not a compile break -> restore -> green. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../tests/conformance_multitenant.rs | 113 +++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index 9f53dedce..9f40f9d29 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -51,6 +51,15 @@ fn url_b() -> String { std::env::var("RELAY_URL_B").unwrap_or_else(|_| "http://b.localhost:3000".to_string()) } +/// An unmapped/unknown host on the same relay process. No community row maps to +/// it, so the relay must fail closed (404) rather than fall through to a default +/// tenant. `*.localhost` resolves to 127.0.0.1, so this addresses the same relay +/// as `url_a`/`url_b` but presents a `Host` no community is bound to. +fn url_unknown() -> String { + std::env::var("RELAY_URL_UNKNOWN") + .unwrap_or_else(|_| "http://unknown.localhost:3000".to_string()) +} + /// Marker for a conformance obligation whose lane has not yet landed on the /// integration branch. Centralizes the "not yet wired" panic so the harvest of /// remaining work is one grep: `rg pending_lane conformance_multitenant.rs`. @@ -94,15 +103,113 @@ mod row_zero_host_binding { mod nip11_relay_info { use super::*; + /// Fetch the NIP-11 relay information document from `base_url`'s root with + /// `Accept: application/nostr+json`. Returns `(status, body)`; `body` is the + /// raw response text (parsed by callers as needed). + /// + /// The `Host` header is implied by `base_url` — `a.localhost`/`b.localhost` + /// both resolve to 127.0.0.1, so reqwest addresses the same relay process + /// and the relay derives the community from the host. That host-derivation + /// is exactly what this row exercises; nothing here is caller-supplied. + async fn fetch_nip11(base_url: &str) -> (reqwest::StatusCode, String) { + let client = reqwest::Client::builder() + .build() + .expect("build reqwest client"); + let resp = client + .get(base_url) + .header(reqwest::header::ACCEPT, "application/nostr+json") + .send() + .await + .unwrap_or_else(|e| panic!("NIP-11 GET {base_url} failed: {e}")); + let status = resp.status(); + let body = resp.text().await.expect("read NIP-11 body"); + (status, body) + } + /// Obligation: unauthenticated NIP-11 reads must not become an enumeration /// oracle for other communities; `RelayInfo::build` takes only static + /// host-scoped inputs (the static-input lint backs this at compile/CI time). + /// + /// This is the *black-box* complement to that compile-time fence + /// (`crates/buzz-relay/src/nip11.rs::_RELAY_INFO_BUILD_STATIC_INPUT_FENCE`): + /// the fence proves `RelayInfo::build` *cannot* take an unscoped DB/search + /// input; this test proves the *observable wire behavior* — that the served + /// document carries nothing that distinguishes one community from another. + /// + /// Because `RelayInfo::build` is genuinely static-input today, host A's and + /// host B's NIP-11 bodies are byte-identical, and that identity *is* the + /// proof: no field varies by community, so an unauthenticated reader cannot + /// use the document to probe whether (or how) community B is configured. + /// The moment a per-community value leaks into the doc, the two bodies + /// diverge and this assertion fails — that is the mutate-bite this row + /// guards (seed a community-distinguishing field into the served doc → the + /// A≡B assertion goes red). #[tokio::test] #[ignore] async fn nip11_is_not_a_cross_community_enumeration_oracle() { - pending_lane( - "relay-wiring", - "NIP-11 from host A reveals nothing about community B's existence/config", + let (status_a, body_a) = fetch_nip11(&url_a()).await; + let (status_b, body_b) = fetch_nip11(&url_b()).await; + + assert_eq!( + status_a, + reqwest::StatusCode::OK, + "host A must serve its NIP-11 document" + ); + assert_eq!( + status_b, + reqwest::StatusCode::OK, + "host B must serve its NIP-11 document" + ); + + // Both bodies must be valid NIP-11 JSON — a relay-info object, not an + // error page or a host echo. + let json_a: serde_json::Value = + serde_json::from_str(&body_a).expect("host A NIP-11 is valid JSON"); + let json_b: serde_json::Value = + serde_json::from_str(&body_b).expect("host B NIP-11 is valid JSON"); + assert!( + json_a.get("supported_nips").is_some(), + "host A NIP-11 must be a relay-info document (has supported_nips)" + ); + assert!( + json_b.get("supported_nips").is_some(), + "host B NIP-11 must be a relay-info document (has supported_nips)" + ); + + // The enumeration-oracle obligation: no field of the served document + // varies by community. Identical bodies are the proof that the doc + // cannot be used to distinguish or probe another tenant. + assert_eq!( + json_a, json_b, + "NIP-11 from host A and host B must be identical: any community-\ + distinguishing field would make the unauthenticated relay-info \ + document an enumeration oracle for other tenants" + ); + + // An *unmapped* host must get the SAME document too — not a 404. NIP-11 + // is intentionally host-agnostic (served from static facts BEFORE host + // binding; see `router.rs::nip11_or_ws_handler`). If an unknown host + // 404'd here while a mapped host returned 200, that status difference + // would itself be the enumeration oracle — a caller could probe which + // hosts are configured by watching for 404-vs-200. Serving the identical + // static doc to every host, mapped or not, is precisely what denies that + // oracle. (Fail-closed host binding lives on the WS-upgrade / non- + // `nostr+json` path and is asserted by `row_zero_host_binding`.) + let (status_unknown, body_unknown) = fetch_nip11(&url_unknown()).await; + assert_eq!( + status_unknown, + reqwest::StatusCode::OK, + "an unmapped host must still receive the static NIP-11 document, not \ + 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"); + assert_eq!( + json_a, json_unknown, + "NIP-11 served to an unmapped host must be byte-identical to a mapped \ + host's document: the relay-info doc carries no host-derived field, \ + so it cannot reveal whether a given host is configured" ); } }