test(conformance): fill row_zero_host_binding (unmapped fail-closed + #h override-reject)

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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-06-27 09:23:34 -04:00
co-authored by Tyler Longwell
parent 1d7db983ab
commit f7ec53b06d
@@ -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::<serde_json::Value>(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 \