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
64 changes: 47 additions & 17 deletions crates/buzz-auth/src/nip98.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,26 @@ fn normalize_url(raw: &str) -> String {
Ok(u) => u,
Err(_) => return raw.to_lowercase(),
};
// Normalize the host so that loopback variants (localhost, 127.0.0.1,
// [::1]) all compare equal — mirrors buzz_core::tenant::normalize_host
// and buzz_core::relay::normalize_relay_url.
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_host(&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 +306,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"
);
}
}
79 changes: 72 additions & 7 deletions crates/buzz-core/src/tenant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,45 @@ 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`, `127.0.0.1`, `[::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.
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, ""),
};
if host_part == "localhost" || host_part == "127.0.0.1" || host_part == "0.0.0.0" {
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 +254,34 @@ 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",
"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("[::1]"), "127.0.0.1");
}

#[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 +294,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 +324,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