From 81995d7ed82a07d4c1196944c8e4a851c375e32f Mon Sep 17 00:00:00 2001 From: obbax Date: Sun, 9 Aug 2026 18:40:27 +0200 Subject: [PATCH] feat(relay): support serving a community on host aliases via BUZZ_HOST_ALIASES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A community resolves from exactly one `communities.host`, and tenant binding rejects every other Host before NIP-98/42 runs. A deployment legitimately reachable on two addresses (e.g. a public CDN host plus an internal tailnet host) therefore cannot authenticate on the second address — the signed NIP-98 `u` tag / NIP-42 `relay` tag never matches (issues #4952, #4953). Add an opt-in `BUZZ_HOST_ALIASES` env var ("alias=canonical", comma-separated). An arriving request whose Host is a configured alias binds to the community of its canonical host, while `TenantContext` keeps the *arrival* host — so the existing `nip98_expected_url` / `nip42_expected_relay_url` helpers validate against exactly the URL the client signed, with no change to the verification logic and no wildcard or prefix matching anywhere. Guarantees: - Default (unset/empty) yields an empty map and the exact pre-existing code path — no behavior change for current deployments. - A real `communities.host` always wins; the alias map is consulted only on a resolver miss, then the canonical is resolved through the same fail-closed path, so an alias can never shadow a real community host. - Exact matching only, on the normalized host; startup validation rejects malformed pairs, duplicate aliases, self-aliases, alias chains, and any host that fails the existing `communities.host` grammar. - Unmapped alias and unmapped host return the identical generic rejection. Signed-off-by: obbax --- .env.example | 6 + crates/buzz-relay/src/api/admin/mod.rs | 10 +- crates/buzz-relay/src/api/bridge.rs | 82 ++++++- crates/buzz-relay/src/api/git/transport.rs | 2 +- crates/buzz-relay/src/api/invites.rs | 2 +- crates/buzz-relay/src/api/media.rs | 4 +- crates/buzz-relay/src/api/nip05.rs | 2 +- crates/buzz-relay/src/audio/handler.rs | 8 +- crates/buzz-relay/src/config.rs | 208 ++++++++++++++++++ .../src/handlers/community_provisioning.rs | 6 +- crates/buzz-relay/src/main.rs | 1 + crates/buzz-relay/src/nip11.rs | 4 +- crates/buzz-relay/src/router.rs | 8 +- crates/buzz-relay/src/tenant.rs | 182 +++++++++++++-- 14 files changed, 492 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index 0f7bbba6f13..6df78733e80 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,12 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 +# Optional alias→canonical host map for a deployment legitimately reachable on +# more than one address (e.g. a public CDN hostname plus an internal tailnet +# name for the same community) — comma-separated alias=canonical pairs, both +# sides already normalized (lowercase, no default port, no trailing dot). +# Unset/blank keeps today's exact single-host-per-community behavior. +# BUZZ_HOST_ALIASES=internal.tailnet.example=chat.example.com # Stable relay signing key. Set this in dev if you want REST-created forum posts # to keep resolving to the original author across relay restarts. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 21f30065f0a..8b4fd131d0b 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -210,9 +210,13 @@ async fn feedback_attachment( // Resolve the tenant from server-owned feedback provenance, then assert the // resolved row still agrees with the feedback FK. Client input never names // a community, host, object key, extension, or upstream URL. - let tenant = crate::tenant::bind_community(&state.db, &feedback.community_host) - .await - .map_err(|_| ApiError::not_found())?; + let tenant = crate::tenant::bind_community( + &state.db, + &feedback.community_host, + &state.config.host_aliases, + ) + .await + .map_err(|_| ApiError::not_found())?; if tenant.community().as_uuid() != &feedback.community_id { tracing::warn!( feedback_id = %feedback.id, diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453fd..d5cfdfff25d 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -627,7 +627,7 @@ pub async fn submit_event( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| { api_error( @@ -895,7 +895,7 @@ pub async fn query_events( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| { api_error( @@ -1338,7 +1338,7 @@ pub async fn count_events( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| { api_error( @@ -1818,7 +1818,7 @@ pub async fn workflow_webhook( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| not_found("workflow not found"))?; let community_id = tenant.community(); @@ -2073,7 +2073,7 @@ async fn authorize_moderation_read( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| { api_error( @@ -2560,6 +2560,78 @@ mod tests { ); } + /// T12 (`BUZZ_HOST_ALIASES` row-zero composition): once `bind_community` + /// has bound a request through an alias, `tenant.host()` IS the alias + /// (see `crate::tenant::bind_community`'s doc — the context always + /// carries the arrival host). A NIP-98 event the client signed against + /// that alias address must verify. This proves the alias design composes + /// with NIP-98 verification with ZERO changes needed to + /// `nip98_expected_url` or `verify_bridge_auth`: both already read + /// `tenant.host()`, which is the alias regardless of whether resolution + /// went through `communities.host` directly or through `host_aliases`. + #[test] + fn verify_bridge_auth_accepts_nip98_event_signed_for_alias_bound_tenant() { + let keys = Keys::generate(); + // Client reaches the relay on the alias address and signs against it. + let signed_url = "https://internal.tailnet.example/events"; + let event_json = build_nip98_event_json(&keys, signed_url, "POST"); + let headers = nip98_auth_headers(&event_json); + + // Stands in for what `bind_community` returns when a request arrives + // on the alias and resolves through `host_aliases` to the canonical + // community: `TenantContext::resolved` carries the ARRIVAL host, not + // the canonical `chat.example.com`. + let config_relay_url = "wss://chat.example.com"; // scheme source only. + let alias_bound_tenant = fresh_tenant("internal.tailnet.example"); + let expected_url = nip98_expected_url(config_relay_url, &alias_bound_tenant, "/events"); + + let (pubkey, _event_id_bytes) = + verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true).expect( + "NIP-98 event signed for the alias must verify against an alias-bound tenant", + ); + assert_eq!( + pubkey, + keys.public_key(), + "returned pubkey must be the signer's" + ); + } + + /// T13: the mirror negative case. An event signed for the CANONICAL host + /// must be REJECTED at a request bound through the alias — the same + /// exact-match property as the plain cross-host test above, proving the + /// alias path does not loosen `nip98_expected_url` into accepting either + /// address for one tenant. Row 44 ("the `u` URL host must match + /// req.community") holds regardless of which resolution path bound the + /// tenant. + #[test] + fn verify_bridge_auth_rejects_nip98_event_signed_for_canonical_when_tenant_bound_via_alias() { + let keys = Keys::generate(); + // Client signs for the CANONICAL host — the alias-unaware address. + let signed_url = "https://chat.example.com/events"; + let event_json = build_nip98_event_json(&keys, signed_url, "POST"); + let headers = nip98_auth_headers(&event_json); + + let config_relay_url = "wss://chat.example.com"; + let alias_bound_tenant = fresh_tenant("internal.tailnet.example"); + let expected_url = nip98_expected_url(config_relay_url, &alias_bound_tenant, "/events"); + + let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) + .expect_err( + "an event signed for the canonical host must be rejected on an \ + alias-bound tenant — exact-match binding must hold regardless of \ + resolution path", + ); + assert_eq!(status, StatusCode::UNAUTHORIZED); + 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; got body = {body:?}" + ); + } + /// Mirror of the query-reconstruction `authorize_moderation_read` performs /// before calling [`nip98_expected_url`], so the tests below pin the exact /// seam without a DB harness. Kept in lockstep with the production match arm. diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 53e3f59463c..f94562e9b24 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -128,7 +128,7 @@ impl axum::extract::FromRequestParts> for GitAuth { .get(header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| (StatusCode::NOT_FOUND, "repository not found").into_response())?; let expected_url = git_expected_url( diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171ccad..9f19b5c0236 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -237,7 +237,7 @@ async fn authenticate( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| { api_error( diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index a2f3640bde5..a9f068b4549 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -162,7 +162,7 @@ impl FromRequestParts> for AuthenticatedUpload { .get(header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| MediaError::NotFound)?; @@ -481,7 +481,7 @@ async fn bind_media_read_tenant( .get(header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - crate::tenant::bind_community(&state.db, raw_host) + crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .map_err(|_| MediaError::NotFound) } diff --git a/crates/buzz-relay/src/api/nip05.rs b/crates/buzz-relay/src/api/nip05.rs index 2424f3b0e52..84fefba19c6 100644 --- a/crates/buzz-relay/src/api/nip05.rs +++ b/crates/buzz-relay/src/api/nip05.rs @@ -36,7 +36,7 @@ pub async fn nostr_nip05( .unwrap_or(""); let json = match ( params.name, - crate::tenant::bind_community(&state.db, raw_host).await, + crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases).await, ) { (Some(n), Ok(tenant)) => { let name = n.to_lowercase(); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c7..86126a295df 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -76,7 +76,13 @@ pub async fn ws_audio_handler( .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { + let tenant = match crate::tenant::bind_community( + &state.db, + raw_host, + &state.config.host_aliases, + ) + .await + { Ok(ctx) => ctx, Err(_) => { return ( diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3d..2e4b95ae945 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -1,5 +1,6 @@ //! Relay configuration from environment variables. +use std::collections::HashMap; use std::net::SocketAddr; use std::time::Duration; @@ -103,6 +104,23 @@ pub struct Config { pub relay_url: String, /// Public WebSocket URL of the dedicated device-pairing relay, when configured. pub pairing_relay_url: Option, + + /// Alias→canonical host map for deployments legitimately reachable on + /// more than one address (`BUZZ_HOST_ALIASES`), e.g. a public CDN + /// hostname plus an internal tailnet name for the same community + /// (upstream #4952/#4953). + /// + /// Opt-in and additive only: an unset or blank var yields an empty map, + /// which is byte-identical to today's single-host-per-community + /// behavior — [`crate::tenant::bind_community`] only ever consults this + /// map *after* an exact `communities.host` lookup misses, so an alias + /// can never shadow a real community host (DB always wins). Both sides + /// of every pair must already be in the same normalized, canonical + /// authority shape `communities.host` requires (see + /// [`crate::handlers::community_provisioning::validate_host`], reused + /// here rather than duplicated) — no wildcards, no prefix/suffix + /// matching, no case folding beyond that existing normalization. + pub host_aliases: HashMap, /// Maximum number of concurrent WebSocket connections. pub max_connections: usize, /// Maximum number of concurrently executing message handlers. @@ -367,6 +385,94 @@ fn parse_operator_api_origin(raw: &str) -> Result { Ok(raw.trim_end_matches('/').to_string()) } +/// Parse `BUZZ_HOST_ALIASES` into an alias→canonical host map. +/// +/// Format: comma-separated `alias=canonical` pairs, e.g. +/// `internal.tailnet.example=chat.example.com`. An unset or blank/whitespace +/// var is a deliberate no-op — returns an empty map, so a deployment that +/// never sets this var keeps today's exact single-host behavior (see +/// [`crate::tenant::bind_community`]'s DB-then-alias resolution order). +/// +/// Validation is a hard startup error, not a warning — a typo here must not +/// silently disable (or worse, misconfigure) host binding: +/// - each entry must be exactly `alias=canonical` (exactly one `=`); +/// - both `alias` and `canonical` must pass the same normalized host grammar +/// `communities.host` requires — reuses +/// [`crate::handlers::community_provisioning::validate_host`] rather than +/// duplicating that grammar, so e.g. uppercase, a default port, or a +/// scheme prefix are rejected here exactly as they would be for a +/// community host; +/// - an alias may not equal its own canonical (a no-op entry that would only +/// ever mask the DB-first check with an identical lookup); +/// - aliases must be unique (a duplicate key would make resolution +/// last-write-wins depending on iteration order); +/// - alias chains are forbidden — a `canonical` value may never also appear +/// as another pair's `alias` key. `bind_community` resolves an alias +/// exactly one hop against the DB, so a chain would silently fail to +/// resolve instead of erroring here at startup where it's cheap to catch. +fn parse_host_aliases(raw: &str) -> Result, ConfigError> { + let raw = raw.trim(); + if raw.is_empty() { + return Ok(HashMap::new()); + } + + let mut aliases = HashMap::new(); + for entry in raw.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + // Tolerate a trailing/stray comma, matching BUZZ_CORS_ORIGINS and + // RELAY_OPERATOR_PUBKEYS' handling of empty split segments. + continue; + } + + if entry.matches('=').count() != 1 { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_HOST_ALIASES entry must be alias=canonical (exactly one '='): {entry:?}" + ))); + } + let (alias, canonical) = entry + .split_once('=') + .expect("exactly one '=' checked above"); + let alias = alias.trim(); + let canonical = canonical.trim(); + + crate::handlers::community_provisioning::validate_host(alias).map_err(|e| { + ConfigError::InvalidValue(format!("BUZZ_HOST_ALIASES alias {alias:?} is invalid: {e}")) + })?; + crate::handlers::community_provisioning::validate_host(canonical).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_HOST_ALIASES canonical {canonical:?} is invalid: {e}" + )) + })?; + + if alias == canonical { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_HOST_ALIASES alias {alias:?} cannot be its own canonical host" + ))); + } + + if aliases + .insert(alias.to_string(), canonical.to_string()) + .is_some() + { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_HOST_ALIASES has a duplicate alias: {alias:?}" + ))); + } + } + + for canonical in aliases.values() { + if aliases.contains_key(canonical) { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_HOST_ALIASES has a chained alias: {canonical:?} is both a canonical host \ + and an alias key — chains are not resolved, only one hop" + ))); + } + } + + Ok(aliases) +} + const DEFAULT_PUSH_GATEWAY_DELIVERY_URL: &str = "https://push.buzz.xyz/v1/deliveries/apns"; fn parse_push_gateway_delivery_url(raw: &str) -> Result { @@ -553,6 +659,9 @@ impl Config { }) .transpose()?; + let host_aliases = + parse_host_aliases(&std::env::var("BUZZ_HOST_ALIASES").unwrap_or_default())?; + let max_connections = std::env::var("BUZZ_MAX_CONNECTIONS") .ok() .and_then(|v| v.parse().ok()) @@ -998,6 +1107,7 @@ impl Config { db_read_pool_size, relay_url, pairing_relay_url, + host_aliases, max_connections, max_concurrent_handlers, send_buffer_size, @@ -1136,6 +1246,10 @@ mod tests { config.relay_operator_pubkeys.is_empty(), "relay_operator_pubkeys should default empty (provisioning disabled)" ); + assert!( + config.host_aliases.is_empty(), + "host_aliases should default empty (no alias binding)" + ); assert!( !config.allow_nip_oa_auth, "allow_nip_oa_auth should default to false" @@ -1576,6 +1690,100 @@ mod tests { )); } + #[test] + fn host_aliases_parses_one_and_two_pairs() { + let one = parse_host_aliases("internal.tailnet.example=chat.example.com") + .expect("single pair should parse"); + assert_eq!(one.len(), 1); + assert_eq!( + one.get("internal.tailnet.example"), + Some(&"chat.example.com".to_string()) + ); + + let two = parse_host_aliases( + "internal.tailnet.example=chat.example.com,vpn.example=chat.example.com", + ) + .expect("two pairs should parse"); + assert_eq!(two.len(), 2); + assert_eq!( + two.get("internal.tailnet.example"), + Some(&"chat.example.com".to_string()) + ); + assert_eq!( + two.get("vpn.example"), + Some(&"chat.example.com".to_string()) + ); + } + + #[test] + fn host_aliases_rejects_malformed_pair() { + for entry in [ + "no-equals-sign", + "a=b=c", + "=missing-alias.example", + "missing-canonical.example=", + ] { + assert!( + parse_host_aliases(entry).is_err(), + "expected error for {entry:?}" + ); + } + } + + #[test] + fn host_aliases_rejects_duplicate_alias() { + let result = parse_host_aliases( + "internal.tailnet.example=chat.example.com,internal.tailnet.example=other.example.com", + ); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("duplicate alias") + )); + } + + #[test] + fn host_aliases_rejects_chained_alias() { + // b.example is both pair 1's canonical and pair 2's alias key — a + // chain bind_community never follows (it resolves one hop only). + let result = parse_host_aliases("a.example=b.example,b.example=c.example"); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("chained alias") + )); + } + + #[test] + fn host_aliases_rejects_alias_equal_to_its_own_canonical() { + let result = parse_host_aliases("chat.example.com=chat.example.com"); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref msg)) if msg.contains("cannot be its own canonical") + )); + } + + #[test] + fn host_aliases_rejects_invalid_host_grammar() { + for entry in [ + "Uppercase.example=chat.example.com", // uppercase alias + "chat.example.com=Uppercase.example", // uppercase canonical + "internal.example:443=chat.example.com", // non-canonical default port + "wss://internal.example=chat.example.com", // scheme prefix + ] { + assert!( + parse_host_aliases(entry).is_err(), + "expected error for {entry:?}" + ); + } + } + + #[test] + fn host_aliases_unset_or_blank_is_empty_map() { + assert!(parse_host_aliases("").expect("empty is valid").is_empty()); + assert!(parse_host_aliases(" ") + .expect("whitespace-only is valid") + .is_empty()); + } + #[test] fn push_gateway_defaults_to_buzz_and_can_be_disabled() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8bea0..87345b2daee 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -80,7 +80,11 @@ pub(crate) fn validate_pubkey_hex(value: &str) -> Option { /// no-op on it): lowercase, no default port, no trailing dot. Requiring the /// caller to send the normalized form keeps the stored `communities.host` /// value byte-identical to what request-time host resolution will look up. -fn validate_host(host: &str) -> Result<(), String> { +/// +/// `pub(crate)` so `crate::config`'s `BUZZ_HOST_ALIASES` parser can validate +/// both sides of an alias pair against this exact same grammar instead of +/// duplicating it. +pub(crate) fn validate_host(host: &str) -> Result<(), String> { if host.is_empty() { return Err("host is empty".to_string()); } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 34dc2dfcf80..d3924ff1aee 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -583,6 +583,7 @@ async fn main() -> anyhow::Result<()> { let tenant = match buzz_relay::tenant::bind_deployment_community( &reconcile_state.db, &reconcile_state.config.relay_url, + &reconcile_state.config.host_aliases, ) .await { diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7baa..5e18681ebbc 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -248,7 +248,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st state.config.pairing_relay_url.as_deref(), ); let tenant_host = if state.config.push_gateway_delivery_url.is_some() { - crate::tenant::bind_community(&state.db, raw_host) + crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .ok() .map(|tenant| tenant.host().to_owned()) @@ -280,7 +280,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st /// `None` (no `icon` field): NIP-11 is intentionally served to unmapped hosts /// too, and an icon lookup failure must not break that. async fn workspace_icon_for_host(state: &crate::state::AppState, raw_host: &str) -> Option { - let tenant = crate::tenant::bind_community(&state.db, raw_host) + let tenant = crate::tenant::bind_community(&state.db, raw_host, &state.config.host_aliases) .await .ok()?; state diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe34..d1111d502a8 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -297,7 +297,13 @@ async fn nip11_or_ws_handler( // tenant. NIP-11 above is served before binding and stays fail-open: an // unmapped host still gets the document (with host-scoped fields like // `icon` simply absent), so the doc cannot leak which hosts are mapped. - let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { + let tenant = match crate::tenant::bind_community( + &state.db, + raw_host, + &state.config.host_aliases, + ) + .await + { Ok(ctx) => ctx, Err(_) => { // Generic rejection: do not distinguish "unmapped" from "lookup diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index 88b75f7d6ee..f4d3237013b 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -14,6 +14,8 @@ //! (`Db::resolve_host`); the relay depends on the trait, not the query, so the //! binding is testable without a database. +use std::collections::HashMap; + use buzz_core::tenant::{normalize_host, CommunityId, TenantContext}; /// Resolves a normalized connection host to its community, or `None` when the @@ -65,12 +67,40 @@ pub enum BindError { /// error) returns a [`BindError`] the caller turns into a generic rejection. /// There is deliberately no path that yields a default or fallback community. /// -/// The returned [`TenantContext`] carries the *normalized* host, so downstream -/// NIP-05 / audit labelling and the NIP-98 `u`-host check all see the same -/// canonical form the community was resolved from. +/// ## Resolution order — DB always wins +/// +/// `host_aliases` (deployment config, `BUZZ_HOST_ALIASES`) lets one community +/// legitimately answer on more than one host — e.g. a public CDN hostname +/// plus an internal tailnet name (upstream #4952/#4953). Resolution order: +/// +/// 1. Exact `communities.host` match for the normalized arrival host. A hit +/// here binds immediately and `host_aliases` is never even consulted. +/// 2. Only on a miss (`Ok(None)`) is the arrival host looked up as an alias +/// key; its configured canonical is resolved through the *same* +/// resolver, and a hit there binds the community. +/// +/// Because step 1 always runs first and returns immediately on a hit, an +/// alias entry can **never** shadow a real `communities.host` row — the DB +/// is authoritative regardless of what `host_aliases` claims about that same +/// host string. +/// +/// ## What the returned context carries +/// +/// The returned [`TenantContext`] always carries the *arrival* host — the +/// normalized form of `raw_host`, which is the alias itself when resolution +/// went through `host_aliases`, not the community's canonical +/// `communities.host`. This is deliberate: [`crate::api::bridge::nip98_expected_url`] +/// and `nip42_expected_relay_url` build the URL a client's signature must +/// match from `tenant.host()`, and a client reaching the relay on an alias +/// signs its NIP-98/NIP-42 event against *that* alias address — never the +/// canonical host, which it may not even know about. Binding the arrival +/// host means those checks (and NIP-05 / audit labelling) keep matching +/// whatever address the request actually came in on, with zero changes +/// needed in the auth verification code itself. pub async fn bind_community( resolver: &R, raw_host: &str, + host_aliases: &HashMap, ) -> Result> { let host = normalize_host(raw_host); // Inv_RowZero (host-binding seam): an empty raw_host carries no community @@ -86,7 +116,14 @@ pub async fn bind_community( } match resolver.resolve_host(&host).await { Ok(Some(community)) => Ok(TenantContext::resolved(community, host)), - Ok(None) => Err(BindError::UnmappedHost), + Ok(None) => match host_aliases.get(&host) { + Some(canonical) => match resolver.resolve_host(canonical).await { + Ok(Some(community)) => Ok(TenantContext::resolved(community, host)), + Ok(None) => Err(BindError::UnmappedHost), + Err(e) => Err(BindError::Lookup(e)), + }, + None => Err(BindError::UnmappedHost), + }, Err(e) => Err(BindError::Lookup(e)), } } @@ -101,11 +138,24 @@ pub async fn bind_community( /// [`bind_community`] path. This is deliberately NOT a default/fallback /// community: an unmapped `relay_url` host returns the same [`BindError`] as /// any other unmapped host. +/// +/// Takes the same `host_aliases` map as [`bind_community`] and forwards it +/// unchanged. In practice this is a no-op for every existing deployment: +/// `relay_url`'s host is the deployment's own canonical host, so the exact +/// `communities.host` lookup in step 1 always hits before `host_aliases` +/// would ever be consulted. The parameter exists so both entry points share +/// one signature rather than one silently ignoring alias config. pub async fn bind_deployment_community( resolver: &R, relay_url: &str, + host_aliases: &HashMap, ) -> Result> { - bind_community(resolver, &buzz_core::tenant::relay_url_authority(relay_url)).await + bind_community( + resolver, + &buzz_core::tenant::relay_url_authority(relay_url), + host_aliases, + ) + .await } /// Extract the relay URL authority in the same normalized shape as request @@ -180,10 +230,19 @@ mod tests { MapResolver { map, fail: false } } + /// The empty alias map — every pre-existing test uses this to prove + /// `BUZZ_HOST_ALIASES` unset/empty reproduces exact prior behavior + /// (T1: hard requirement 1). + fn no_aliases() -> HashMap { + HashMap::new() + } + #[tokio::test] async fn maps_known_host_to_its_community() { let r = resolver_with("relay.example", 1); - let ctx = bind_community(&r, "relay.example").await.expect("bound"); + let ctx = bind_community(&r, "relay.example", &no_aliases()) + .await + .expect("bound"); assert_eq!(ctx.community().as_uuid(), &Uuid::from_u128(1)); assert_eq!(ctx.host(), "relay.example"); } @@ -194,7 +253,7 @@ mod tests { // all bind to the same community (they cannot split a tenant). let r = resolver_with("relay.example", 7); for variant in ["RELAY.EXAMPLE", "relay.example.", "relay.example:443"] { - let ctx = bind_community(&r, variant) + let ctx = bind_community(&r, variant, &no_aliases()) .await .unwrap_or_else(|_| panic!("variant {variant:?} should bind")); assert_eq!( @@ -206,17 +265,104 @@ mod tests { } } + /// T2 (BUZZ_HOST_ALIASES): an unmapped host with a configured alias binds + /// to the canonical's community, but the returned context carries the + /// ARRIVAL host (the alias) — never the canonical — so NIP-98/NIP-42 + /// verification matches whatever the client actually signed. + #[tokio::test] + async fn alias_binds_to_canonicals_community_but_context_keeps_arrival_host() { + let r = resolver_with("chat.example.com", 3); + let mut aliases = HashMap::new(); + aliases.insert( + "internal.tailnet.example".to_string(), + "chat.example.com".to_string(), + ); + + let ctx = bind_community(&r, "internal.tailnet.example", &aliases) + .await + .expect("alias should bind through its canonical's community"); + assert_eq!(ctx.community().as_uuid(), &Uuid::from_u128(3)); + assert_eq!( + ctx.host(), + "internal.tailnet.example", + "context must carry the arrival (alias) host, not the canonical" + ); + } + + /// T3: a host that is neither a `communities.host` row nor an alias key + /// still fails closed with the generic `UnmappedHost`, identical to the + /// no-aliases-configured case — a non-empty alias map must not change + /// the rejection for hosts it says nothing about. + #[tokio::test] + async fn unmapped_host_with_nonempty_alias_map_still_fails_closed() { + let r = resolver_with("chat.example.com", 4); + let mut aliases = HashMap::new(); + aliases.insert( + "internal.tailnet.example".to_string(), + "chat.example.com".to_string(), + ); + + let err = bind_community(&r, "evil.example", &aliases) + .await + .unwrap_err(); + assert!(matches!(err, BindError::UnmappedHost)); + } + + /// T4 (hard requirement 3, "DB always wins"): an alias entry that names + /// the SAME host as a real `communities.host` row must never shadow it — + /// the DB row's own community binds, not wherever the alias points. + #[tokio::test] + async fn alias_can_never_shadow_a_real_community_host() { + let r = resolver_with("shadowed.example", 5); + let mut aliases = HashMap::new(); + // Misconfigured/adversarial alias claiming "shadowed.example" is an + // alias for a different community entirely. + aliases.insert( + "shadowed.example".to_string(), + "somewhere-else.example".to_string(), + ); + + let ctx = bind_community(&r, "shadowed.example", &aliases) + .await + .expect("the real communities.host row must win"); + assert_eq!( + ctx.community().as_uuid(), + &Uuid::from_u128(5), + "DB row's own community must bind, not the alias target" + ); + assert_eq!(ctx.host(), "shadowed.example"); + } + + /// T5: an alias whose configured canonical has no `communities.host` row + /// fails closed with the same generic `UnmappedHost` — a dangling alias + /// must not surface a distinct error an unauthenticated caller could use + /// to probe deployment config. + #[tokio::test] + async fn alias_whose_canonical_is_missing_from_db_fails_closed() { + let r = resolver_with("chat.example.com", 6); + let mut aliases = HashMap::new(); + aliases.insert( + "internal.tailnet.example".to_string(), + "nowhere.example".to_string(), + ); + + let err = bind_community(&r, "internal.tailnet.example", &aliases) + .await + .unwrap_err(); + assert!(matches!(err, BindError::UnmappedHost)); + } + #[tokio::test] async fn deployment_url_keeps_nondefault_port_for_lookup() { let r = resolver_with("localhost:3000", 42); - let ctx = bind_deployment_community(&r, "ws://localhost:3000") + let ctx = bind_deployment_community(&r, "ws://localhost:3000", &no_aliases()) .await .expect("deployment host should bind with non-default port"); assert_eq!(ctx.community().as_uuid(), &Uuid::from_u128(42)); assert_eq!(ctx.host(), "localhost:3000"); let wrong = resolver_with("localhost", 42); - let err = bind_deployment_community(&wrong, "ws://localhost:3000") + let err = bind_deployment_community(&wrong, "ws://localhost:3000", &no_aliases()) .await .unwrap_err(); assert!(matches!(err, BindError::UnmappedHost)); @@ -226,7 +372,7 @@ mod tests { async fn deployment_url_normalizes_default_ports() { let r = resolver_with("relay.example", 9); for url in ["ws://relay.example:80", "wss://relay.example:443"] { - let ctx = bind_deployment_community(&r, url) + let ctx = bind_deployment_community(&r, url, &no_aliases()) .await .unwrap_or_else(|_| panic!("url {url:?} should bind")); assert_eq!(ctx.community().as_uuid(), &Uuid::from_u128(9)); @@ -243,7 +389,9 @@ mod tests { #[tokio::test] async fn unmapped_host_fails_closed() { let r = resolver_with("relay.example", 1); - let err = bind_community(&r, "evil.example").await.unwrap_err(); + let err = bind_community(&r, "evil.example", &no_aliases()) + .await + .unwrap_err(); assert!(matches!(err, BindError::UnmappedHost)); } @@ -253,7 +401,9 @@ mod tests { map: HashMap::new(), fail: true, }; - let err = bind_community(&r, "relay.example").await.unwrap_err(); + let err = bind_community(&r, "relay.example", &no_aliases()) + .await + .unwrap_err(); assert!(matches!(err, BindError::Lookup("db down"))); } @@ -287,7 +437,7 @@ mod tests { // A request with a missing or unreadable Host header reaches // `bind_community` with raw_host = "" (router.rs:169-172). The // fence must reject — the request never supplied a host. - let err = bind_community(&r, "").await.expect_err( + let err = bind_community(&r, "", &no_aliases()).await.expect_err( "Inv_RowZero: an empty raw_host carries no community evidence; \ bind_community must fail closed regardless of the host map", ); @@ -308,7 +458,7 @@ mod tests { async fn whitespace_only_raw_host_fails_closed_even_if_db_has_empty_host_row() { let r = resolver_with("", 0xdeadbeef); - let err = bind_community(&r, " ").await.expect_err( + let err = bind_community(&r, " ", &no_aliases()).await.expect_err( "Inv_RowZero: whitespace-only raw_host normalizes to empty \ (see buzz-core::tenant::normalize_host) and carries no \ community evidence", @@ -326,7 +476,9 @@ mod tests { #[tokio::test] async fn non_empty_unmapped_host_still_fails_closed_after_fix() { let r = resolver_with("", 0xdeadbeef); - let err = bind_community(&r, "evil.example").await.unwrap_err(); + let err = bind_community(&r, "evil.example", &no_aliases()) + .await + .unwrap_err(); assert!(matches!(err, BindError::UnmappedHost)); } }