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: