From 1f37a26ae75ff24656280913f34c3249d101543f Mon Sep 17 00:00:00 2001 From: Duncan Date: Mon, 10 Aug 2026 22:01:02 -0400 Subject: [PATCH] fix(relay): reject unbracketed IPv6 in BUZZ_ADMIN_HOST at config parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare IPv6 admin host (BUZZ_ADMIN_HOST=::1) passed authority validation but then interpolated unbracketed into the NIP-11 admin_api advertisement and the NIP-98 u-tag canonical URL, yielding http://::1 — which no URL parser accepts (an IPv6 authority must be bracketed per RFC 3986). Desktop discovery rejected it and no client could match the malformed signed URL. Reject the shape at config parse with an error naming the required bracketed form, matching the documented exact-authority contract. This makes the unbracketed multi-colon branch in scheme_for_host dead, so drop it. Replace the auth.rs assertions that pinned http://::1 as expected output with parseability tests; keep the advertised-vs-verified scheme-consistency invariant. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- CHANGELOG.md | 6 ++-- crates/buzz-relay/src/api/admin/auth.rs | 44 +++++++++++++++++------ crates/buzz-relay/src/config.rs | 47 +++++++++++++++++++++++++ docs/admin/README.md | 6 ++-- 4 files changed, 89 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ac3698c..85a0b037c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,11 @@ origin in an optional `admin_api` field (`scheme://host[:port]`) whenever the admin surface is configured (`BUZZ_ADMIN_HOST` set). The scheme follows the same loopback rule as NIP-98 `u`-tag verification (`http` for - `localhost`/`127.x`/`::1`, else `https`). Clients can auto-discover the admin + `localhost`/`127.x`/`[::1]`, else `https`). Clients can auto-discover the admin console instead of requiring manual URL entry; the field is omitted entirely - when no admin surface is configured. + when no admin surface is configured. IPv6 admin hosts must be bracketed + (`[::1]`, `[::1]:3000`); an unbracketed literal is a startup error because it + cannot form a valid URI authority. - `RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when `RELAY_OPERATOR_PUBKEYS` is set. The allowlist is shared by the NIP-98 admin console (which needs no origin) and the community-provisioning endpoints diff --git a/crates/buzz-relay/src/api/admin/auth.rs b/crates/buzz-relay/src/api/admin/auth.rs index 89cfc9cec..93fc55a8c 100644 --- a/crates/buzz-relay/src/api/admin/auth.rs +++ b/crates/buzz-relay/src/api/admin/auth.rs @@ -89,21 +89,19 @@ pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool { } /// Scheme for an admin authority: `http://` for loopback hosts (localhost, -/// `::1`, 127.x), else `https://` — matching local dev via the Justfile. +/// `[::1]`, 127.x), else `https://` — matching local dev via the Justfile. /// /// Shared by [`canonical_url`] (NIP-98 `u`-tag verification) and /// [`admin_api_origin`] (NIP-11 advertisement) so the origin the relay /// advertises and the origin it verifies against can never use different /// schemes. fn scheme_for_host(host: &str) -> &'static str { - // Strip any `:port` to get the bare host. IPv6 literals carry their own - // colons, so a plain `split(':')` would mangle them: bracketed authorities - // (`[::1]:3000`) take the text inside the brackets, and an unbracketed - // multi-colon host is a bare IPv6 literal with no port. + // Strip any `:port` to get the bare host. A bracketed IPv6 authority + // (`[::1]:3000`) carries its colons inside the brackets, so take the text + // between them; bare (unbracketed) IPv6 literals are rejected at config + // parse, so `split(':')` on every other accepted form only strips a port. let host_part = if let Some(rest) = host.strip_prefix('[') { rest.split(']').next().unwrap_or(rest) - } else if host.matches(':').count() > 1 { - host } else { host.split(':').next().unwrap_or(host) }; @@ -533,11 +531,38 @@ mod tests { fn admin_api_origin_uses_http_for_loopback_hosts() { assert_eq!(admin_api_origin("localhost:3000"), "http://localhost:3000"); assert_eq!(admin_api_origin("127.0.0.1:3000"), "http://127.0.0.1:3000"); - assert_eq!(admin_api_origin("::1"), "http://::1"); - // Bracketed IPv6 authority with a port (the RFC 3986 Host-header form). + // Bracketed IPv6 authority (the RFC 3986 form; bare `::1` is rejected + // at config parse). Loopback `[::1]` resolves to `http`. + assert_eq!(admin_api_origin("[::1]"), "http://[::1]"); assert_eq!(admin_api_origin("[::1]:3000"), "http://[::1]:3000"); } + /// The advertised origin and the verified `u`-tag URL must parse as valid + /// URLs for every accepted host — the round-1 defect advertised + /// `http://::1`, which no URL parser accepts. Bare IPv6 is rejected at + /// config parse, so every host reaching these helpers is bracketed or a + /// name/IPv4 authority. + #[test] + fn admin_api_origin_and_canonical_url_parse_as_valid_urls() { + for host in [ + "admin.example.com", + "admin.example.com:8443", + "localhost", + "localhost:3000", + "127.0.0.1", + "127.0.0.1:3000", + "[::1]", + "[::1]:3000", + ] { + let advertised = admin_api_origin(host); + url::Url::parse(&advertised) + .unwrap_or_else(|e| panic!("advertised origin {advertised:?} must parse: {e}")); + let verified = canonical_url(host, "/api/admin/v1/reports"); + url::Url::parse(&verified) + .unwrap_or_else(|e| panic!("canonical url {verified:?} must parse: {e}")); + } + } + /// The advertised origin and the verified `u`-tag URL must agree on scheme /// for every host, or a discovered origin would sign against a scheme the /// relay rejects. @@ -548,7 +573,6 @@ mod tests { "admin.example.com:8443", "localhost:3000", "127.0.0.1:3000", - "::1", "[::1]:3000", ] { let advertised = admin_api_origin(host); diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 54c83f55f..6a31b4bd0 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -1069,6 +1069,19 @@ impl Config { )); } + // IPv6 authorities must be bracketed (RFC 3986). An unbracketed + // literal such as `::1` cannot form a valid URI authority — the + // advertised NIP-11 origin and the NIP-98 `u`-tag verifier would + // emit `http://::1`, which no URL parser accepts, and no real + // client sends an unbracketed IPv6 `Host` header. Reject it here + // so every accepted host yields usable discovery and signing URLs. + if !host.starts_with('[') && host.matches(':').count() > 1 { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_ADMIN_HOST={host} looks like a bare IPv6 literal; \ + wrap IPv6 addresses in brackets, e.g. [::1] or [::1]:3000" + ))); + } + // Parse BUZZ_ADMIN_AUTH. Accepted values: "token" (default when // unset), "disabled", "nip98". Any other non-empty value is a // startup error (typo-proofing). @@ -1414,6 +1427,40 @@ mod tests { } } + #[test] + fn admin_host_bare_ipv6_literal_fails_closed() { + let _guard = ENV_MUTEX.lock().unwrap(); + for host in ["::1", "::1:3000", "fe80::1", "2001:db8::1"] { + let result = config_with_admin_env(&[ + ("BUZZ_ADMIN_HOST", Some(host)), + ("BUZZ_ADMIN_TOKEN", Some(VALID_ADMIN_TOKEN)), + ]); + assert!( + matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_ADMIN_HOST") && message.contains("bracket") + ), + "bare IPv6 host {host:?} must be rejected: {result:?}" + ); + } + } + + #[test] + fn admin_host_bracketed_ipv6_literal_is_accepted() { + let _guard = ENV_MUTEX.lock().unwrap(); + for host in ["[::1]", "[::1]:3000", "[2001:db8::1]:8443"] { + let admin = config_with_admin_env(&[ + ("BUZZ_ADMIN_HOST", Some(host)), + ("BUZZ_ADMIN_TOKEN", Some(VALID_ADMIN_TOKEN)), + ]) + .unwrap_or_else(|e| panic!("bracketed IPv6 host {host:?} must be accepted: {e:?}")) + .admin + .expect("admin surface is configured"); + assert_eq!(admin.host, host); + } + } + #[test] fn admin_token_without_a_host_leaves_the_surface_disabled() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/docs/admin/README.md b/docs/admin/README.md index 1b62cd8fe..9d0773c6d 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -108,9 +108,11 @@ NIP-11 relay-information document under an optional `admin_api` field: The value is the canonical origin `scheme://host[:port]` (no path), with the scheme derived by the same loopback rule as `u`-tag verification (`http` for -`localhost`/`127.x`/`::1`, else `https`). The field is omitted entirely when no +`localhost`/`127.x`/`[::1]`, else `https`). The field is omitted entirely when no admin surface is configured. Clients (such as the desktop console) read this to -auto-discover the admin endpoint instead of requiring manual URL entry. +auto-discover the admin endpoint instead of requiring manual URL entry. IPv6 +admin hosts must be bracketed (`[::1]`, `[::1]:3000`); an unbracketed literal is +a startup error. Each request requires: