mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
3fd16cc1f7
commit
be9d26e55a
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user