Skip to content

Commit 02aba40

Browse files
Duncanwpfleger96
andcommitted
fix(relay): require BUZZ_ADMIN_HOST to be a bare authority, not just parseable
The parse-only catch-all proved the whole `http://{host}` string is a valid URL but not that {host} is exactly an authority. Query and fragment delimiters are legal URL characters and were not in the forbidden set, so `admin.example.com?x=1` and `[::1]#frag` passed startup: the suffix parsed as query/fragment, then canonical_url appended the admin path after it (`http://admin.example.com/?x=1/api/admin/v1/reports`), corrupting both the NIP-11 advertisement and the NIP-98 u-tag URL — the same accepted-config/ unusable-URL class as the bare-IPv6 defect. Validate the parsed sentinel structurally, mirroring parse_operator_api_origin: host present, no credentials, path `/`, no query, no fragment. Any non-authority character now lands in one of those and is rejected. Host still stored verbatim. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent fa0b2a2 commit 02aba40

1 file changed

Lines changed: 39 additions & 13 deletions

File tree

crates/buzz-relay/src/config.rs

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,16 +1084,31 @@ impl Config {
10841084

10851085
// Catch-all authority gate: every accepted host is interpolated
10861086
// verbatim into the NIP-11 advertisement and NIP-98 `u`-tag URLs,
1087-
// so it must form a parseable authority. The bracket guard above
1088-
// names the honest bare-IPv6 shape; this rejects any remaining
1089-
// malformed authority (e.g. the unclosed-bracket typos `[::1`
1090-
// and `[::1:3000`) that would otherwise produce an unparseable
1091-
// `http://{host}`. This is a validity gate only — the host is
1092-
// still stored verbatim, not normalized.
1093-
if url::Url::parse(&format!("http://{host}")).is_err() {
1087+
// so it must be exactly an authority — a host with an optional
1088+
// port and nothing else. Parsing `http://{host}` and requiring
1089+
// the sentinel to carry only a host rejects any shape that smuggles
1090+
// a path, query, fragment, or credentials into the value (the
1091+
// bracket guard above already names the honest bare-IPv6 shape).
1092+
// Structural check, not parse-only: `admin.example.com?x=1` parses
1093+
// as a valid URL but lands `?x=1` in the query, which would corrupt
1094+
// both the advertised origin and the canonical `u`-tag URL. Mirrors
1095+
// `parse_operator_api_origin`. Validity gate only — the host is
1096+
// still stored verbatim, not normalized (the `url` crate normalizes
1097+
// an empty path to `/`, so a bare authority satisfies `path == "/"`).
1098+
let is_bare_authority =
1099+
url::Url::parse(&format!("http://{host}")).is_ok_and(|url| {
1100+
url.host().is_some()
1101+
&& url.username().is_empty()
1102+
&& url.password().is_none()
1103+
&& url.path() == "/"
1104+
&& url.query().is_none()
1105+
&& url.fragment().is_none()
1106+
});
1107+
if !is_bare_authority {
10941108
return Err(ConfigError::InvalidValue(format!(
10951109
"BUZZ_ADMIN_HOST={host} is not a valid URL authority; \
1096-
it must be a host with an optional port, e.g. \
1110+
it must be a host with an optional port and nothing else \
1111+
(no path, query, fragment, or credentials), e.g. \
10971112
relay.example.com:8443 or [::1]:3000"
10981113
)));
10991114
}
@@ -1464,12 +1479,23 @@ mod tests {
14641479

14651480
#[test]
14661481
fn admin_host_malformed_authority_fails_closed() {
1467-
// Shapes that slip the bare-IPv6 bracket guard (they start with `[`)
1468-
// but still cannot form a valid URL authority — the unclosed-bracket
1469-
// typos. Without the catch-all parse gate these would produce an
1470-
// unparseable `http://[::1` advertisement and NIP-98 `u`-tag URL.
1482+
// Shapes that slip the earlier guards but are not a bare authority, so
1483+
// they would corrupt the NIP-11 advertisement and NIP-98 `u`-tag URL:
1484+
// - unclosed-bracket typos start with `[` (pass the bracket guard)
1485+
// but are not parseable authorities;
1486+
// - query/fragment suffixes parse as a valid URL, but the `?x=1` /
1487+
// `#frag` lands in the query/fragment rather than the host, so a
1488+
// parse-only gate would miss them — the structural check catches them.
14711489
let _guard = ENV_MUTEX.lock().unwrap();
1472-
for host in ["[::1", "[::1:3000", "[not-closed"] {
1490+
for host in [
1491+
"[::1",
1492+
"[::1:3000",
1493+
"[not-closed",
1494+
"admin.example.com?x=1",
1495+
"admin.example.com#frag",
1496+
"[::1]?x=1",
1497+
"[::1]#frag",
1498+
] {
14731499
let result = config_with_admin_env(&[
14741500
("BUZZ_ADMIN_HOST", Some(host)),
14751501
("BUZZ_ADMIN_TOKEN", Some(VALID_ADMIN_TOKEN)),

0 commit comments

Comments
 (0)