Skip to content

feat(relay): support serving a community on host aliases via BUZZ_HOST_ALIASES - #5410

Open
obbax wants to merge 1 commit into
block:mainfrom
obbax:fix-4953-accepted-auth-origins
Open

obbax wants to merge 1 commit into
block:mainfrom
obbax:fix-4953-accepted-auth-origins

Conversation

@obbax

@obbax obbax commented Aug 9, 2026

Copy link
Copy Markdown

Problem

A community resolves from exactly one communities.host, and tenant binding rejects every other Host (fail-closed, UnmappedHost) before NIP-98/NIP-42 runs. A deployment that is legitimately reachable on two addresses — e.g. a public CDN hostname plus an internal tailnet hostname — therefore cannot authenticate on the second address: the signed NIP-98 u tag / NIP-42 relay tag is reconstructed from the tenant host and never matches the address the client actually hit.

Concretely, a request to the internal address gets:

401 NIP-98: URL mismatch: event has `https://public.example/query`,
                          expected `http://internal.example/query`

This is the root cause behind #4952 and #4953.

Approach

New opt-in env var BUZZ_HOST_ALIASES — comma-separated alias=canonical pairs:

BUZZ_HOST_ALIASES=internal.tailnet.example=chat.example.com

A request whose Host is a configured alias binds to the community of its canonical host, but TenantContext keeps the arrival host. Because nip98_expected_url / nip42_expected_relay_url already derive from tenant.host(), they now validate against exactly the URL the client signed — no change to the verification logic, and no wildcard or prefix matching anywhere.

Guarantees (and how they're enforced)

  • Default = today's behavior, exactly. Unset/empty ⇒ empty map ⇒ the pre-existing code path unchanged. (config.rs empty-parse test; tenant.rs empty-map test.)
  • A real communities.host always wins. The alias map is consulted only on a resolver Ok(None), then the canonical is resolved through the same fail-closed path — an alias can never shadow a real community host. (Enforced in the resolution branch, not just documented; covered by a shadowing test.)
  • Exact matching only, on the normalized host. Startup validation rejects malformed pairs, duplicate aliases, self-aliases, alias chains, and any host that fails the existing communities.host grammar (reuses validate_host, no new grammar).
  • No new info leak. Unmapped alias and unmapped host return the identical generic rejection.

Tests

New coverage in tenant.rs, config.rs, and bridge.rs (13 cases), notably:

  • alias binds to canonical's community while ctx.host() stays the arrival host;
  • an alias that shadows a real community host → the DB community wins;
  • a NIP-98 event signed for the canonical host, on a tenant bound via alias, is rejected (proves per-path exact binding survives);
  • config rejects malformed/duplicate/chained/self/invalid-grammar aliases.

cargo fmt --check, cargo clippy -p buzz-core -p buzz-relay --all-targets -- -D warnings, and cargo test -p buzz-core -p buzz-relay are clean (the only failing tests in my environment are pre-existing and Postgres-connectivity-related — reproduced identically on an unmodified base).

Known tradeoffs (deliberate, flagged for review)

  1. An alias-key miss costs one extra resolver lookup vs a plain unmapped host, so response timing can distinguish "configured alias" from "unknown host" even though the response is byte-identical. Kept simple here since aliases are operator-configured; happy to add a constant-time path or in-memory alias set if preferred.
  2. If a community is later created with a host equal to an existing alias key, the DB row silently wins (by design). A warning at community-creation time would close the audit gap — omitted to keep this PR focused on the auth fix, can add if wanted.

Closes #4952, closes #4953.

…T_ALIASES

A community resolves from exactly one `communities.host`, and tenant binding
rejects every other Host before NIP-98/42 runs. A deployment legitimately
reachable on two addresses (e.g. a public CDN host plus an internal tailnet
host) therefore cannot authenticate on the second address — the signed NIP-98
`u` tag / NIP-42 `relay` tag never matches (issues block#4952, block#4953).

Add an opt-in `BUZZ_HOST_ALIASES` env var ("alias=canonical", comma-separated).
An arriving request whose Host is a configured alias binds to the community of
its canonical host, while `TenantContext` keeps the *arrival* host — so the
existing `nip98_expected_url` / `nip42_expected_relay_url` helpers validate
against exactly the URL the client signed, with no change to the verification
logic and no wildcard or prefix matching anywhere.

Guarantees:
- Default (unset/empty) yields an empty map and the exact pre-existing code
  path — no behavior change for current deployments.
- A real `communities.host` always wins; the alias map is consulted only on a
  resolver miss, then the canonical is resolved through the same fail-closed
  path, so an alias can never shadow a real community host.
- Exact matching only, on the normalized host; startup validation rejects
  malformed pairs, duplicate aliases, self-aliases, alias chains, and any host
  that fails the existing `communities.host` grammar.
- Unmapped alias and unmapped host return the identical generic rejection.

Signed-off-by: obbax <robinniclasandersson@gmail.com>
@obbax
obbax requested a review from a team as a code owner August 9, 2026 16:40

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81995d7ed8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Ok(None) => Err(BindError::UnmappedHost),
Ok(None) => match host_aliases.get(&host) {
Some(canonical) => match resolver.resolve_host(canonical).await {
Ok(Some(community)) => Ok(TenantContext::resolved(community, host)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve canonical NIP-05 handles on alias requests

When a user whose profile contains alice@chat.example.com submits that kind-0 event through an alias such as internal.tailnet.example, this context exposes the alias as tenant.host(). handle_kind0_profile then validates the handle against that alias (handlers/side_effects.rs:1225-1229), converts the mismatch to an empty handle, and writes it to the user record, silently erasing the existing canonical NIP-05 identity. Preserve both the arrival authority and the community's canonical host, using the former for request-signature checks and the latter (or the complete configured host set) for persistent NIP-05 validation.

Useful? React with 👍 / 👎.

Comment on lines +433 to +435
let (alias, canonical) = entry
.split_once('=')
.expect("exactly one '=' checked above");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the production-path expect

This is a newly introduced expect() in startup configuration parsing, contrary to the repository's explicit requirement to use proper error propagation in production paths. Even though the preceding count check currently makes None unreachable, keep this parser fallible by converting a failed split into ConfigError::InvalidValue rather than adding a panic site.

AGENTS.md reference: AGENTS.md:L114-L117

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant