Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 60 additions & 24 deletions crates/buzz-auth/src/nip98.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u16>() {
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()
Expand Down Expand Up @@ -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"
);
}
}
105 changes: 98 additions & 7 deletions crates/buzz-core/src/tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<std::net::Ipv4Addr>() {
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`.
Expand Down Expand Up @@ -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]
Expand All @@ -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"
Expand All @@ -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]
Expand Down
7 changes: 5 additions & 2 deletions crates/buzz-relay/src/handlers/community_provisioning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
15 changes: 10 additions & 5 deletions crates/buzz-relay/src/tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions crates/buzz-test-client/tests/e2e_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions migrations/0025_collapse_loopback_community_hosts.sql
Original file line number Diff line number Diff line change
@@ -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%';