relay/auth: NIP-98 u-URL host is per-tenant, not config-global

Row 44 obligation closed: NIP-98 u-tag URL host must match req.community.
Previously expected_url was built from state.config.relay_url (one static
string per deployment), which under multi-tenant both (a) admitted events
signed for community A's host at community B's connection, and (b) rejected
every legitimate request whose community host wasn't the single configured
one.

Adds nip98_expected_url(config_relay_url, tenant, path): scheme from config
(preserves ws/wss dev-vs-prod), host from tenant.host() (the same host
row-zero bound from the request Host header). Swaps the three bridge call
sites (submit_event, query_events, count_events). Removes the orphaned
canonical_url helper.

Tests: 4 new in api::bridge::tests covering helper unit (both directions
of host substitution + scheme mapping) and verify_bridge_auth integration
(cross-host rejection + matching-host acceptance). Mutate-bite verified:
reverting the helper internals to config-global behavior turns all 4 new
tests RED with the exact diagnostics they were designed to surface.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-06-27 01:17:12 -04:00
co-authored by Tyler Longwell
parent 3fd16cc1f7
commit be9d26e55a
+172 -11
View File
@@ -118,14 +118,30 @@ async fn check_nip98_replay_with_guard(
}
}
/// Reconstruct the canonical URL for NIP-98 verification from the relay config.
fn canonical_url(relay_url: &str, path: &str) -> String {
let base = relay_url
.trim()
.trim_end_matches('/')
.replace("wss://", "https://")
.replace("ws://", "http://");
format!("{base}{path}")
/// Construct the NIP-98 `u`-tag expected URL for a request bound to `tenant`.
///
/// Conformance row 44 obligation: "NIP-98 `u` URL host must match
/// `req.community`." Host comes from the resolved [`TenantContext`] — the
/// same host the row-zero seam already bound from the request `Host` header —
/// and the scheme comes from the deployment's configured relay URL so
/// `ws`/`wss` deployments map to `http`/`https` consistently with how the
/// client signs the URL it is actually hitting.
///
/// Critically, this does NOT use `config_relay_url`'s host. `config.relay_url`
/// is one static string per deployment; under multi-tenant a relay serves many
/// hosts, only one of which would match. Using it as the URL match key would
/// (a) accept a NIP-98 event signed for community A's host when the request
/// arrives at community B's host (host-binding side door — verify_nip98 would
/// pass and the relay would proceed against the wrong tenant's auth context),
/// and (b) reject every legitimate request whose community host isn't the
/// single configured one. Substituting `tenant.host()` closes both directions.
fn nip98_expected_url(config_relay_url: &str, tenant: &TenantContext, path: &str) -> String {
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
"https"
} else {
"http"
};
format!("{scheme}://{}{path}", tenant.host())
}
/// Extract a channel UUID from a single filter's `#h` tag.
@@ -206,7 +222,7 @@ pub async fn submit_event(
)
})?;
let url = canonical_url(&state.config.relay_url, "/events");
let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events");
let (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
@@ -295,7 +311,7 @@ pub async fn query_events(
)
})?;
let url = canonical_url(&state.config.relay_url, "/query");
let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query");
let (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
@@ -570,7 +586,7 @@ pub async fn count_events(
)
})?;
let url = canonical_url(&state.config.relay_url, "/count");
let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count");
let (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
@@ -1244,6 +1260,151 @@ mod tests {
);
}
/// Build a signed NIP-98 event JSON string for `url` + `method`, mirroring
/// `buzz_auth::nip98::tests::make_nip98_event` so the bridge tests don't
/// reach into buzz-auth's test scope.
fn build_nip98_event_json(keys: &Keys, url: &str, method: &str) -> String {
let tags = vec![
Tag::parse(["u", url]).expect("u tag"),
Tag::parse(["method", method]).expect("method tag"),
];
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)
.expect("sign NIP-98 event");
serde_json::to_string(&event).expect("serialize")
}
/// Build a `HeaderMap` with the NIP-98 event base64-encoded in
/// `Authorization: Nostr <base64>`, matching the production bridge auth
/// header shape.
fn nip98_auth_headers(event_json: &str) -> axum::http::HeaderMap {
use base64::engine::general_purpose::STANDARD as BASE64;
let mut headers = axum::http::HeaderMap::new();
let value = format!("Nostr {}", BASE64.encode(event_json.as_bytes()));
headers.insert(
axum::http::header::AUTHORIZATION,
value.parse().expect("valid header value"),
);
headers
}
/// Row 44 obligation: a NIP-98 event signed against community A's host
/// MUST be rejected at the bridge when the request resolves to community
/// B's host. The conformance text in `docs/multi-tenant-conformance.md`
/// states: "NIP-98 `u` URL host must match `req.community`". Before this
/// gap closed, `expected_url` was derived from `state.config.relay_url`
/// (one static string per deployment), so any request to *any* host on a
/// multi-tenant deployment would verify against community A's URL — both
/// admitting cross-host forgeries (event signed for A presented at B) and
/// rejecting every legitimate request whose community host wasn't the
/// single configured one.
///
/// This test bites if `nip98_expected_url` is reverted to use
/// `config.relay_url`'s host (the original `canonical_url` behavior).
#[test]
fn verify_bridge_auth_rejects_nip98_event_signed_for_wrong_communitys_host() {
let keys = Keys::generate();
// Client signs an event for community A's host, then presents it at a
// request whose `Host` header resolved to community B.
let signed_url = "https://host-a.example/events";
let event_json = build_nip98_event_json(&keys, signed_url, "POST");
let headers = nip98_auth_headers(&event_json);
let config_relay_url = "wss://host-a.example"; // doesn't matter — only used for scheme.
let tenant_b = fresh_tenant("host-b.example");
let expected_url = nip98_expected_url(config_relay_url, &tenant_b, "/events");
let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect_err(
"cross-host NIP-98 event MUST be rejected — row 44: `u` URL host \
must match req.community",
);
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"cross-host rejection must be a 401, not silently admitted"
);
let msg = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
msg.contains("URL mismatch"),
"rejection must carry the URL-mismatch signal so callers can \
distinguish it from other auth failures; got body = {body:?}"
);
}
/// Positive control for the cross-host test: a NIP-98 event signed for
/// host A MUST be accepted at a request whose tenant resolved to host A.
/// Without this, the cross-host test could be passing vacuously (e.g. if
/// `nip98_expected_url` always produced a URL no event could match).
#[test]
fn verify_bridge_auth_accepts_nip98_event_signed_for_matching_host() {
let keys = Keys::generate();
let signed_url = "https://host-a.example/events";
let event_json = build_nip98_event_json(&keys, signed_url, "POST");
let headers = nip98_auth_headers(&event_json);
// Configured relay URL deliberately differs in host from the request's
// tenant host — proving the helper uses `tenant.host()`, not the config.
let config_relay_url = "wss://other-config-host.example";
let tenant_a = fresh_tenant("host-a.example");
let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events");
let (pubkey, _event_id_bytes) =
verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect("matching-host NIP-98 event must verify");
assert_eq!(
pubkey,
keys.public_key(),
"returned pubkey must be the signer's"
);
}
/// `nip98_expected_url` derives host from `tenant`, not from
/// `config_relay_url`. Pin both directions: changing the tenant's host
/// changes the output; changing the config's host does NOT.
#[test]
fn nip98_expected_url_uses_tenant_host_not_config_host() {
let tenant_a = fresh_tenant("host-a.example");
let tenant_b = fresh_tenant("host-b.example");
let url_a = nip98_expected_url("wss://config-host.example", &tenant_a, "/events");
let url_b = nip98_expected_url("wss://config-host.example", &tenant_b, "/events");
assert_eq!(url_a, "https://host-a.example/events");
assert_eq!(url_b, "https://host-b.example/events");
// Same tenant, two different config hosts → output is identical.
// (If config-host ever leaked into the URL, this assertion would bite.)
let url_a_alt_config =
nip98_expected_url("wss://different-config.example", &tenant_a, "/events");
assert_eq!(
url_a, url_a_alt_config,
"config-relay-url's host MUST NOT influence the NIP-98 expected URL — \
only its scheme contributes"
);
}
/// `nip98_expected_url` derives scheme from `config_relay_url`'s prefix:
/// `wss://` → `https`, everything else → `http`. Deployments that run
/// `ws://` in dev/test still need a NIP-98 URL the client can sign against.
#[test]
fn nip98_expected_url_derives_scheme_from_config() {
let tenant = fresh_tenant("host-a.example");
assert_eq!(
nip98_expected_url("wss://config.example", &tenant, "/events"),
"https://host-a.example/events",
"wss:// production config → https:// URL"
);
assert_eq!(
nip98_expected_url("ws://config.example", &tenant, "/events"),
"http://host-a.example/events",
"ws:// dev config → http:// URL"
);
}
/// Build a kind:30174 engram envelope authored by `agent`, tagged with `owner`.
fn engram_envelope(agent: &Keys, owner_hex: &str) -> buzz_core::StoredEvent {
let d_tag = Tag::custom(