mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(relay): reject unbracketed IPv6 in BUZZ_ADMIN_HOST at config parse
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 <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
+4
-2
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user