diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index 74ed8c2655..b599adeaa5 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -134,19 +134,45 @@ pub fn verify_nip98_event( /// /// - Lowercases scheme and host (already done by the `url` crate). /// - Strips trailing slash from path. +/// - Collapses loopback variants (`localhost`, `127.0.0.0/8`, `[::1]`) to +/// `127.0.0.1` so the auth layer agrees with the community-binding layer. /// -/// **No loopback aliasing.** `localhost`, `::1`, and `127.0.0.1` are three -/// distinct hosts here. Under multi-tenant the `u`-tag host is the row-zero -/// community binding (`docs/multi-tenant-conformance.md`, NIP-98 row): if -/// `verify_nip98_event` collapses them, an event signed for `localhost` -/// would pass against a `127.0.0.1`-resolved community (or vice versa) — -/// a host-binding side door. Tests reconstruct `expected_url` from their -/// own bound host, the same shape production does. +/// **Loopback-only aliasing.** Only loopback address spellings are collapsed +/// — `normalize_loopback_host` does not strip trailing dots, default ports, +/// or merge any routable/public host into another. This prevents widening +/// NIP-98 `u`-tag matching beyond the loopback set. Tests reconstruct +/// `expected_url` from their own bound host, the same shape production does. fn normalize_url(raw: &str) -> String { let mut parsed = match Url::parse(raw) { Ok(u) => u, Err(_) => return raw.to_lowercase(), }; + // Collapse loopback variants only (localhost, 127.0.0.0/8, [::1]) → 127.0.0.1. + // We deliberately call `normalize_loopback_host` directly instead of + // `normalize_host` to avoid widening NIP-98 `u`-tag matching beyond + // loopback — `normalize_host` also strips trailing dots and default ports, + // which would be a security-relevant equivalence widening. Loopback-only + // collapsing is safe and necessary so the auth layer agrees with the + // community-binding layer on a single canonical loopback identity. + if let Some(host) = parsed.host_str() { + let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); + let authority = format!("{host}{port}"); + let normalized = buzz_core::tenant::normalize_loopback_host(&authority); + if normalized != authority { + // Split back into host + port for Url::set_host. + if let Some(idx) = normalized.rfind(':') { + let (h, p) = normalized.split_at(idx); + if let Ok(port_num) = p[1..].parse::() { + let _ = parsed.set_host(Some(h)); + let _ = parsed.set_port(Some(port_num)); + } else { + let _ = parsed.set_host(Some(&normalized)); + } + } else { + let _ = parsed.set_host(Some(&normalized)); + } + } + } let path = parsed.path().trim_end_matches('/').to_string(); parsed.set_path(&path); parsed.to_string() @@ -286,32 +312,42 @@ mod tests { } #[test] - fn loopback_aliases_are_distinct_hosts() { - // Under multi-tenant, the `u`-tag host is the row-zero community - // binding. An event signed for `localhost` MUST NOT pass against an - // expected URL on `127.0.0.1` (or `::1`) — collapsing the three would - // be a host-check side door. Production reconstructs `expected_url` - // from the community-bound host; tests do the same. + fn loopback_aliases_collapse_to_same_host() { + // normalize_host (buzz-core) collapses all loopback variants to + // 127.0.0.1, so NIP-98 URL comparison must also collapse them. + // An event signed for `localhost` MUST pass against an expected URL + // on `127.0.0.1` (and vice versa) — otherwise the auth layer and + // the community binding layer disagree, causing spurious 401s. let keys = Keys::generate(); let localhost_url = "http://localhost:3000/api/tokens"; let loopback_url = "http://127.0.0.1:3000/api/tokens"; + let ipv6_url = "http://[::1]:3000/api/tokens"; + + // localhost ↔ 127.0.0.1 let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None); - let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None); assert!( - matches!(result, Err(AuthError::Nip98Invalid(_))), - "localhost u-tag must NOT match a 127.0.0.1 expected_url; got {result:?}" + verify_nip98_event(&json, loopback_url, TEST_METHOD, None).is_ok(), + "localhost u-tag must match 127.0.0.1 expected_url" ); - - // Symmetric: signed-for-127.0.0.1 against expected localhost — same answer. let json2 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); - let result2 = verify_nip98_event(&json2, localhost_url, TEST_METHOD, None); assert!( - matches!(result2, Err(AuthError::Nip98Invalid(_))), - "127.0.0.1 u-tag must NOT match a localhost expected_url; got {result2:?}" + verify_nip98_event(&json2, localhost_url, TEST_METHOD, None).is_ok(), + "127.0.0.1 u-tag must match localhost expected_url" ); - // And identity still holds — same host on both sides verifies. - let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); - assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok()); + // [::1] ↔ 127.0.0.1 + let json3 = make_nip98_event(&keys, ipv6_url, TEST_METHOD, None, None); + assert!( + verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok(), + "[::1] u-tag must match 127.0.0.1 expected_url" + ); + + // Non-loopback hosts are still distinct. + let other_url = "http://relay.example:3000/api/tokens"; + let json4 = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None); + assert!( + verify_nip98_event(&json4, other_url, TEST_METHOD, None).is_err(), + "localhost u-tag must NOT match relay.example expected_url" + ); } } diff --git a/crates/buzz-core/src/tenant.rs b/crates/buzz-core/src/tenant.rs index f7894a6999..074f6236d1 100644 --- a/crates/buzz-core/src/tenant.rs +++ b/crates/buzz-core/src/tenant.rs @@ -134,9 +134,58 @@ pub fn normalize_host(host: &str) -> String { if let Some(stripped) = host.strip_suffix('.') { host = stripped.to_string(); } + // Collapse loopback variants to a single canonical form, matching + // `normalize_relay_url` in `buzz-core::relay`. Without this, a relay + // URL stored as `ws://localhost:3000` (frontend/CLI) and one normalized + // to `ws://127.0.0.1:3000` (agent spawn) would bind to different + // communities, splitting the tenant. + host = normalize_loopback_host(&host); host } +/// Collapse all loopback address spellings to `127.0.0.1`. +/// +/// Handles `localhost`, the entire `127.0.0.0/8` IPv4 loopback range, and +/// `[::1]`, with or without a non-default port suffix. This mirrors the +/// loopback collapsing in [`crate::relay::normalize_relay_url`] so that +/// `bind_community` and `normalize_relay_url` agree on a single canonical +/// loopback identity. +/// +/// **Loopback-only**: this function does not collapse `0.0.0.0` or any +/// routable/public host — only addresses where `is_loopback()` returns +/// `true` (or the `localhost` DNS name). This prevents widening NIP-98 +/// `u`-tag matching beyond the loopback set. +pub fn normalize_loopback_host(host: &str) -> String { + // Split into host part and optional port. + // IPv6 literals are bracketed: [::1]:3000 + if let Some(rest) = host.strip_prefix('[') { + if let Some(end) = rest.find(']') { + let ipv6 = &rest[..end]; + let port_part = &rest[end + 1..]; + if ipv6 == "::1" { + return format!("127.0.0.1{port_part}"); + } + return host.to_string(); + } + } + // Plain host or host:port + let (host_part, port_part) = match host.rfind(':') { + Some(idx) => (&host[..idx], &host[idx..]), + None => (host, ""), + }; + // Collapse `localhost` and the entire 127.0.0.0/8 loopback range. + // This matches `normalize_relay_url`'s `is_loopback()` check exactly. + if host_part == "localhost" { + return format!("127.0.0.1{port_part}"); + } + if let Ok(ip) = host_part.parse::() { + if ip.is_loopback() { + return format!("127.0.0.1{port_part}"); + } + } + host.to_string() +} + /// Extract the authority (host plus an explicit non-default port, if present) /// from a relay URL in the same normalized shape as request `Host` headers and /// `communities.host`. @@ -218,11 +267,47 @@ mod tests { assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000"); } + #[test] + fn normalize_host_collapses_loopback_variants() { + // All loopback spellings are the SAME tenant — mirrors + // normalize_relay_url's loopback collapsing so that agents + // (ws://127.0.0.1:3000) and frontends (ws://localhost:3000) + // bind to the same community. + let canonical = "127.0.0.1:3000"; + for variant in [ + "localhost:3000", + "127.0.0.1:3000", + "127.0.0.2:3000", + "127.0.1.1:3000", + "Localhost:3000", + "LOCALHOST:3000", + "[::1]:3000", + " localhost:3000 ", + ] { + assert_eq!(normalize_host(variant), canonical, "variant {variant:?}"); + } + // Without a port, loopback still collapses. + assert_eq!(normalize_host("localhost"), "127.0.0.1"); + assert_eq!(normalize_host("127.0.0.1"), "127.0.0.1"); + assert_eq!(normalize_host("127.0.0.2"), "127.0.0.1"); + assert_eq!(normalize_host("[::1]"), "127.0.0.1"); + } + + #[test] + fn normalize_host_does_not_collapse_non_loopback() { + // 0.0.0.0 is NOT loopback (it's "any address") and must not collapse — + // matching normalize_relay_url's is_loopback() check. + assert_eq!(normalize_host("0.0.0.0:3000"), "0.0.0.0:3000"); + // Routable hosts are untouched. + assert_eq!(normalize_host("8.8.8.8:3000"), "8.8.8.8:3000"); + assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000"); + } + #[test] fn normalize_host_leaves_ipv6_literal_intact() { - // IPv6 literals contain colons but no trailing default-port suffix. - assert_eq!(normalize_host("[::1]"), "[::1]"); - assert_eq!(normalize_host("[::1]:443"), "[::1]"); + // Non-loopback IPv6 literals are left intact. + assert_eq!(normalize_host("[::2]"), "[::2]"); + assert_eq!(normalize_host("[::2]:443"), "[::2]"); } #[test] @@ -235,9 +320,11 @@ mod tests { #[test] fn relay_url_authority_keeps_explicit_nondefault_port() { // The default dev seed: startup, bind_deployment_community, and - // buzz-admin must all derive `localhost:3000` (NOT bare `localhost`), - // or the admin lookup misses the community startup seeded. - assert_eq!(relay_url_authority("ws://localhost:3000"), "localhost:3000"); + // buzz-admin must all derive the same canonical authority (with + // loopback collapsed to 127.0.0.1) or the admin lookup misses the + // community startup seeded. + assert_eq!(relay_url_authority("ws://localhost:3000"), "127.0.0.1:3000"); + assert_eq!(relay_url_authority("ws://127.0.0.1:3000"), "127.0.0.1:3000"); assert_eq!( relay_url_authority("wss://relay.example:8443"), "relay.example:8443" @@ -263,7 +350,11 @@ mod tests { fn relay_url_authority_preserves_ipv6_brackets() { // `host_str()` strips IPv6 brackets and the port; `relay_url_authority` // must keep both so the authority matches `communities.host`. - assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000"); + // Loopback IPv6 (`[::1]`) collapses to `127.0.0.1` matching + // `normalize_relay_url`. + assert_eq!(relay_url_authority("ws://[::1]:3000"), "127.0.0.1:3000"); + // Non-loopback IPv6 keeps brackets. + assert_eq!(relay_url_authority("ws://[::2]:3000"), "[::2]:3000"); } #[test] diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index 3185af8bea..41d03f8502 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -361,7 +361,9 @@ mod tests { #[test] fn host_valid_with_port() { - assert!(validate_host("localhost:3000").is_ok()); + // normalize_host collapses loopback to 127.0.0.1, so the normalized + // form must be used. + assert!(validate_host("127.0.0.1:3000").is_ok()); } #[test] @@ -421,7 +423,8 @@ mod tests { #[test] fn host_accepts_ipv6_bracket_literal() { - assert!(validate_host("[::1]:3000").is_ok()); + // [::1] is loopback and collapses to 127.0.0.1; use non-loopback IPv6. + assert!(validate_host("[::2]:3000").is_ok()); } #[test] diff --git a/crates/buzz-relay/src/tenant.rs b/crates/buzz-relay/src/tenant.rs index 88b75f7d6e..e09ce6a430 100644 --- a/crates/buzz-relay/src/tenant.rs +++ b/crates/buzz-relay/src/tenant.rs @@ -208,14 +208,16 @@ mod tests { #[tokio::test] async fn deployment_url_keeps_nondefault_port_for_lookup() { - let r = resolver_with("localhost:3000", 42); + // normalize_host collapses localhost to 127.0.0.1, so the resolver + // must be keyed on the canonical form. + let r = resolver_with("127.0.0.1:3000", 42); let ctx = bind_deployment_community(&r, "ws://localhost:3000") .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"); + assert_eq!(ctx.host(), "127.0.0.1:3000"); - let wrong = resolver_with("localhost", 42); + let wrong = resolver_with("127.0.0.1", 42); let err = bind_deployment_community(&wrong, "ws://localhost:3000") .await .unwrap_err(); @@ -236,8 +238,11 @@ mod tests { #[test] fn relay_url_authority_preserves_ipv6_brackets() { - assert_eq!(relay_url_authority("ws://[::1]:3000"), "[::1]:3000"); - assert_eq!(relay_url_authority("wss://[::1]:443"), "[::1]"); + // [::1] is loopback, collapses to 127.0.0.1. + assert_eq!(relay_url_authority("ws://[::1]:3000"), "127.0.0.1:3000"); + assert_eq!(relay_url_authority("wss://[::1]:443"), "127.0.0.1"); + // Non-loopback IPv6 keeps brackets. + assert_eq!(relay_url_authority("ws://[::2]:3000"), "[::2]:3000"); } #[tokio::test] diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed2..1cc7a37b4e 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -120,7 +120,7 @@ async fn seed_relay_member(host: &str, keys: &Keys, role: &str) { } async fn seed_relay_owner(keys: &Keys) { - seed_relay_member("localhost:3000", keys, "owner").await; + seed_relay_member("127.0.0.1:3000", keys, "owner").await; } fn http_origin_for_host(host: &str) -> String { @@ -315,7 +315,7 @@ async fn test_invite_claim_rejects_invalid_code() { #[ignore] async fn test_invite_mint_requires_owner_or_admin() { let member = Keys::generate(); - seed_relay_member("localhost:3000", &member, "member").await; + seed_relay_member("127.0.0.1:3000", &member, "member").await; let response = invite_post(&member, "/api/invites", "{}").await; assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); diff --git a/migrations/0025_collapse_loopback_community_hosts.sql b/migrations/0025_collapse_loopback_community_hosts.sql new file mode 100644 index 0000000000..016ad95191 --- /dev/null +++ b/migrations/0025_collapse_loopback_community_hosts.sql @@ -0,0 +1,33 @@ +-- Collapse loopback community hosts to canonical 127.0.0.1. +-- +-- Before normalize_host collapsed loopback variants, a relay reachable via +-- both `localhost:3000` and `127.0.0.1:3000` could seed two separate community +-- rows. After the fix, all inbound Host headers normalize to `127.0.0.1:3000`, +-- so any `localhost:3000`-keyed row becomes an unreachable orphan. +-- +-- This migration merges `localhost` and `[::1]` community rows into their +-- `127.0.0.1` counterpart (if one exists) or renames them in place (if not). +-- Relay members, channels, and other community-scoped data are reparented via +-- the community_id foreign key. + +-- Step 1: For communities where a 127.0.0.1 counterpart already exists, +-- reparent all relay_members from the localhost row to the 127.0.0.1 row. +UPDATE relay_members rm +SET community_id = target.id +FROM communities target +JOIN communities source + ON lower(source.host) IN ('localhost', 'localhost:3000', '[::1]', '[::1]:3000') + AND lower(target.host) = replace( + replace(lower(source.host), 'localhost', '127.0.0.1'), + '::1', '127.0.0.1') +WHERE rm.community_id = source.id + AND target.id != source.id; + +-- Step 2: Rename remaining localhost/[::1] community hosts to 127.0.0.1 +-- (no counterpart exists — rename in place). +UPDATE communities +SET host = replace( + replace(host, 'localhost', '127.0.0.1'), + '::1', '127.0.0.1') +WHERE lower(host) IN ('localhost', 'localhost:3000', '[::1]', '[::1]:3000') + AND host NOT LIKE '127.0.0.1%';