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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ TYPESENSE_URL=http://localhost:8108
BUZZ_BIND_ADDR=0.0.0.0:3000
# Public WebSocket URL — used in NIP-42 auth challenges
RELAY_URL=ws://localhost:3000
# Optional URL path prefix to serve the relay under. Empty (the default) mounts
# everything at /. Set this only when a gateway routes to the relay by path
# instead of by hostname — the WebSocket then answers on ws://host/<prefix> and
# every HTTP route lives under it. RELAY_URL must include the same prefix.
# BUZZ_BASE_PATH=/relay
# Stable relay signing key. Set this in dev if you want REST-created forum posts
# to keep resolving to the original author across relay restarts.
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
Expand Down
57 changes: 49 additions & 8 deletions crates/buzz-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,17 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result<String, CliError
if input.starts_with("http://") || input.starts_with("https://") {
let parsed = url::Url::parse(input)
.map_err(|e| CliError::Usage(format!("invalid media URL: {e}")))?;
if !parsed.path().starts_with("/media/") {
return Err(CliError::Usage(
"media URL must point at a /media/ path".to_string(),
));
}
let Some(sha256_ext) = parsed.path().strip_prefix("/media/") else {
let relay = url::Url::parse(relay_url)
.map_err(|e| CliError::Usage(format!("invalid relay URL: {e}")))?;
// A relay served under a base path (BUZZ_BASE_PATH) hosts media at
// `<prefix>/media/<hash>`, so the relay URL's own path is stripped before
// the `/media/` check rather than assuming the relay owns the root.
let relay_prefix = relay.path().trim_end_matches('/');
let media_path = parsed
.path()
.strip_prefix(relay_prefix)
.unwrap_or(parsed.path());
let Some(sha256_ext) = media_path.strip_prefix("/media/") else {
return Err(CliError::Usage(
"media URL must point at a /media/ path".to_string(),
));
Expand All @@ -287,8 +292,6 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result<String, CliError
"media path must be sha256, sha256.ext, or sha256.thumb.jpg".to_string(),
));
}
let relay = url::Url::parse(relay_url)
.map_err(|e| CliError::Usage(format!("invalid relay URL: {e}")))?;
if parsed.scheme() != relay.scheme()
|| parsed.host_str() != relay.host_str()
|| parsed.port_or_known_default() != relay.port_or_known_default()
Expand Down Expand Up @@ -401,6 +404,44 @@ mod media_download_tests {
);
}

/// A relay served under `BUZZ_BASE_PATH` hosts media at
/// `<prefix>/media/<hash>`, so both the sha shorthand and a full URL have to
/// work against a relay URL that carries a path.
#[test]
fn media_url_handles_a_relay_served_under_a_base_path() {
let hash = "a".repeat(64);
assert_eq!(
media_url_from_input("https://relay.example/relay", &format!("{hash}.jpg")).unwrap(),
format!("https://relay.example/relay/media/{hash}.jpg"),
"sha shorthand resolves under the prefix"
);
assert_eq!(
media_url_from_input(
"https://relay.example/relay",
&format!("https://relay.example/relay/media/{hash}.jpg")
)
.unwrap(),
format!("https://relay.example/relay/media/{hash}.jpg"),
"a full prefixed URL is accepted, not rejected as non-media"
);
assert!(
media_url_from_input(
"https://relay.example/relay",
&format!("https://relay.example/relay/media-evil/{hash}.jpg")
)
.is_err(),
"the /media/ requirement still holds inside the prefix"
);
assert!(
media_url_from_input(
"https://relay.example/relay",
&format!("https://evil.example/relay/media/{hash}.jpg")
)
.is_err(),
"origin pinning still holds under a prefix"
);
}

#[test]
fn media_url_accepts_only_same_relay_media_urls() {
let hash = "a".repeat(64);
Expand Down
134 changes: 110 additions & 24 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,14 @@ async fn check_nip98_replay_with_guard(
/// pass and the relay would proceed against the wrong tenant's auth context),
/// and (b) reject every legitimate request whose community host isn't the
/// single configured one. Substituting `tenant.host()` closes both directions.
/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay
/// serves at the root). Callers pass the route's own unprefixed path — the same
/// literal the route is declared with — and the prefix is applied here, at the
/// single choke point, so a prefixed deployment reconstructs the URL the client
/// actually signed instead of 401ing on a path mismatch.
pub(crate) fn nip98_expected_url(
config_relay_url: &str,
base_path: &str,
tenant: &TenantContext,
path: &str,
) -> String {
Expand All @@ -202,7 +208,7 @@ pub(crate) fn nip98_expected_url(
} else {
"http"
};
format!("{scheme}://{}{path}", tenant.host())
format!("{scheme}://{}{base_path}{path}", tenant.host())
}

/// Construct the NIP-42 expected `relay` URL for a connection bound to `tenant`.
Expand All @@ -220,15 +226,22 @@ pub(crate) fn nip98_expected_url(
/// the client's connect URL embedded in the signed AUTH event; the helper
/// preserves the deployment's TLS posture from `config_relay_url`'s prefix so
/// `wss://` deployments stay `wss://` and `ws://` dev/test stays `ws://`.
/// Path is empty — clients put the bare WS origin (`ws://host[:port]`) in the
/// `relay` tag, matching how `EventBuilder::auth` accepts a [`nostr::RelayUrl`].
pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantContext) -> String {
/// Path is the deployment's `BUZZ_BASE_PATH` prefix and nothing more: clients put
/// their connect URL in the `relay` tag, which is the bare WS origin
/// (`ws://host[:port]`) at the root and `ws://host[:port]/<prefix>` when the relay
/// is served under one. Matching how `EventBuilder::auth` accepts a
/// [`nostr::RelayUrl`].
pub(crate) fn nip42_expected_relay_url(
config_relay_url: &str,
base_path: &str,
tenant: &TenantContext,
) -> String {
let scheme = if config_relay_url.trim_start().starts_with("wss://") {
"wss"
} else {
"ws"
};
format!("{scheme}://{}", tenant.host())
format!("{scheme}://{}{base_path}", tenant.host())
}

/// Extract a channel UUID from a single filter's `#h` tag.
Expand Down Expand Up @@ -632,7 +645,12 @@ pub async fn submit_event(
)
})?;

let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events");
let url = nip98_expected_url(
&state.config.relay_url,
&state.config.base_path,
&tenant,
"/events",
);
let (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
Expand Down Expand Up @@ -900,7 +918,12 @@ pub async fn query_events(
)
})?;

let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query");
let url = nip98_expected_url(
&state.config.relay_url,
&state.config.base_path,
&tenant,
"/query",
);
let (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
Expand Down Expand Up @@ -1335,7 +1358,12 @@ pub async fn count_events(
)
})?;

let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count");
let url = nip98_expected_url(
&state.config.relay_url,
&state.config.base_path,
&tenant,
"/count",
);
let (pubkey, event_id_bytes) = verify_bridge_auth(
&headers,
"POST",
Expand Down Expand Up @@ -2066,7 +2094,12 @@ async fn authorize_moderation_read(
Some(q) if !q.is_empty() => format!("{path}?{q}"),
_ => path.to_string(),
};
let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query);
let url = nip98_expected_url(
&state.config.relay_url,
&state.config.base_path,
&tenant,
&path_with_query,
);
let (pubkey, event_id_bytes) =
verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?;
check_nip98_replay(state, &tenant, event_id_bytes).await?;
Expand Down Expand Up @@ -2462,7 +2495,7 @@ mod tests {

let config_relay_url = "wss://host-a.example"; // doesn't matter — only used for scheme.
let tenant_b = fresh_tenant("host-b.example");
let expected_url = nip98_expected_url(config_relay_url, &tenant_b, "/events");
let expected_url = nip98_expected_url(config_relay_url, "", &tenant_b, "/events");

let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
.expect_err(
Expand Down Expand Up @@ -2528,7 +2561,7 @@ mod tests {
// tenant host — proving the helper uses `tenant.host()`, not the config.
let config_relay_url = "wss://other-config-host.example";
let tenant_a = fresh_tenant("host-a.example");
let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events");
let expected_url = nip98_expected_url(config_relay_url, "", &tenant_a, "/events");

let (pubkey, _event_id_bytes) =
verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true)
Expand All @@ -2553,7 +2586,7 @@ mod tests {
Some(q) if !q.is_empty() => format!("{path}?{q}"),
_ => path.to_string(),
};
nip98_expected_url(config_relay_url, tenant, &path_with_query)
nip98_expected_url(config_relay_url, "", tenant, &path_with_query)
}

/// L7 read-auth blocker (Wren, #1591 sweep): the CLI signs the *full*
Expand Down Expand Up @@ -2675,15 +2708,15 @@ mod tests {
let tenant_a = fresh_tenant("host-a.example");
let tenant_b = fresh_tenant("host-b.example");

let url_a = nip98_expected_url("wss://config-host.example", &tenant_a, "/events");
let url_b = nip98_expected_url("wss://config-host.example", &tenant_b, "/events");
let url_a = nip98_expected_url("wss://config-host.example", "", &tenant_a, "/events");
let url_b = nip98_expected_url("wss://config-host.example", "", &tenant_b, "/events");
assert_eq!(url_a, "https://host-a.example/events");
assert_eq!(url_b, "https://host-b.example/events");

// Same tenant, two different config hosts → output is identical.
// (If config-host ever leaked into the URL, this assertion would bite.)
let url_a_alt_config =
nip98_expected_url("wss://different-config.example", &tenant_a, "/events");
nip98_expected_url("wss://different-config.example", "", &tenant_a, "/events");
assert_eq!(
url_a, url_a_alt_config,
"config-relay-url's host MUST NOT influence the NIP-98 expected URL — \
Expand All @@ -2698,17 +2731,69 @@ mod tests {
fn nip98_expected_url_derives_scheme_from_config() {
let tenant = fresh_tenant("host-a.example");
assert_eq!(
nip98_expected_url("wss://config.example", &tenant, "/events"),
nip98_expected_url("wss://config.example", "", &tenant, "/events"),
"https://host-a.example/events",
"wss:// production config → https:// URL"
);
assert_eq!(
nip98_expected_url("ws://config.example", &tenant, "/events"),
nip98_expected_url("ws://config.example", "", &tenant, "/events"),
"http://host-a.example/events",
"ws:// dev config → http:// URL"
);
}

/// A relay served under `BUZZ_BASE_PATH` must reconstruct the *prefixed* URL,
/// because that is what the client signed into its NIP-98 `u` tag. Getting
/// this wrong 401s every authenticated HTTP call on a prefixed deployment
/// while looking like a signature failure.
#[test]
fn nip98_expected_url_includes_the_base_path_prefix() {
let tenant = fresh_tenant("host-a.example");
assert_eq!(
nip98_expected_url("wss://config.example", "/relay", &tenant, "/events"),
"https://host-a.example/relay/events"
);
assert_eq!(
nip98_expected_url("wss://config.example", "/buzz/relay", &tenant, "/query"),
"https://host-a.example/buzz/relay/query"
);
assert_eq!(
nip98_expected_url(
"wss://config.example",
"/relay",
&tenant,
"/moderation/audit?limit=20"
),
"https://host-a.example/relay/moderation/audit?limit=20",
"the prefix precedes the path and the query stays last"
);
assert_eq!(
nip98_expected_url("wss://config.example", "", &tenant, "/events"),
"https://host-a.example/events",
"an empty prefix is byte-identical to pre-base-path behavior"
);
}

/// NIP-42 sibling: the `relay` tag carries the client's connect URL, which
/// includes the prefix when the relay is served under one.
#[test]
fn nip42_expected_relay_url_includes_the_base_path_prefix() {
let tenant = fresh_tenant("host-a.example");
assert_eq!(
nip42_expected_relay_url("wss://config.example", "/relay", &tenant),
"wss://host-a.example/relay"
);
assert_eq!(
nip42_expected_relay_url("ws://config.example", "/relay", &tenant),
"ws://host-a.example/relay"
);
assert_eq!(
nip42_expected_relay_url("wss://config.example", "", &tenant),
"wss://host-a.example",
"an empty prefix is byte-identical to pre-base-path behavior"
);
}

// ----- NIP-42 host-binding tests (sibling of NIP-98 row 44 obligation) -----

/// Sign a NIP-42 AUTH event with `relay` tag = `relay_url`, then verify
Expand Down Expand Up @@ -2752,7 +2837,7 @@ mod tests {
let config_relay_url = "ws://host-a.example:3100";
let signed_relay_url = "ws://host-a.example:3100";
let tenant_b = fresh_tenant("host-b.example:3100");
let expected = nip42_expected_relay_url(config_relay_url, &tenant_b);
let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_b);

let err = verify_nip42_with_urls(challenge, signed_relay_url, &expected).expect_err(
"cross-host NIP-42 AUTH event MUST be rejected — row 44 sibling: \
Expand All @@ -2778,7 +2863,7 @@ mod tests {
// host — proving the helper uses `tenant.host()`, not the config.
let config_relay_url = "ws://other-config-host.example";
let tenant_a = fresh_tenant("host-a.example:3100");
let expected = nip42_expected_relay_url(config_relay_url, &tenant_a);
let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_a);

verify_nip42_with_urls(challenge, signed_relay_url, &expected)
.expect("matching-host NIP-42 AUTH event must verify");
Expand All @@ -2792,15 +2877,16 @@ mod tests {
let tenant_a = fresh_tenant("host-a.example:3100");
let tenant_b = fresh_tenant("host-b.example:3100");

let url_a = nip42_expected_relay_url("ws://config-host.example", &tenant_a);
let url_b = nip42_expected_relay_url("ws://config-host.example", &tenant_b);
let url_a = nip42_expected_relay_url("ws://config-host.example", "", &tenant_a);
let url_b = nip42_expected_relay_url("ws://config-host.example", "", &tenant_b);
assert_eq!(url_a, "ws://host-a.example:3100");
assert_eq!(url_b, "ws://host-b.example:3100");

// Same tenant, two different config hosts → output is identical.
// (If config-host ever leaked into the URL, this assertion would bite —
// catches the exact "reverted to config host" regression.)
let url_a_alt_config = nip42_expected_relay_url("ws://different-config.example", &tenant_a);
let url_a_alt_config =
nip42_expected_relay_url("ws://different-config.example", "", &tenant_a);
assert_eq!(
url_a, url_a_alt_config,
"config-relay-url's host MUST NOT influence the NIP-42 expected URL — \
Expand All @@ -2816,12 +2902,12 @@ mod tests {
fn nip42_expected_relay_url_derives_scheme_from_config() {
let tenant = fresh_tenant("host-a.example:3100");
assert_eq!(
nip42_expected_relay_url("wss://config.example", &tenant),
nip42_expected_relay_url("wss://config.example", "", &tenant),
"wss://host-a.example:3100",
"wss:// production config → wss:// URL"
);
assert_eq!(
nip42_expected_relay_url("ws://config.example", &tenant),
nip42_expected_relay_url("ws://config.example", "", &tenant),
"ws://host-a.example:3100",
"ws:// dev config → ws:// URL"
);
Expand Down
7 changes: 6 additions & 1 deletion crates/buzz-relay/src/api/invites.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,12 @@ async fn authenticate(
)
})?;

let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path);
let url = bridge::nip98_expected_url(
&state.config.relay_url,
&state.config.base_path,
&tenant,
path,
);
let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options(
headers,
"POST",
Expand Down
Loading