Skip to content

fix: collapse loopback variants in normalize_host to prevent tenant split#2619

Open
AIandI0x1 wants to merge 3 commits into
block:mainfrom
AIandI0x1:fix/normalize-host-loopback-collapse
Open

fix: collapse loopback variants in normalize_host to prevent tenant split#2619
AIandI0x1 wants to merge 3 commits into
block:mainfrom
AIandI0x1:fix/normalize-host-loopback-collapse

Conversation

@AIandI0x1

Copy link
Copy Markdown

Problem

normalize_host (buzz-core::tenant) and normalize_relay_url (buzz-core::relay) disagree on loopback address handling:

  • normalize_relay_url collapses localhost, 127.0.0.1, and [::1] to 127.0.0.1
  • normalize_host did not collapse loopback variants

This causes a silent tenant split in local development:

  1. The desktop app spawns agents with ws://127.0.0.1:3000 (after normalize_relay_url)
  2. The frontend connects via ws://localhost:3000 (default)
  3. The relay's bind_community uses the raw Host header, so 127.0.0.1:3000 and localhost:3000 map to different communities
  4. Agents discover 0 channels because their community has no channel data
  5. The UI shows no channels because its community is different/empty

Additionally, normalize_url in buzz-auth::nip98 did not collapse loopback variants either, causing NIP-98 HTTP auth 401 failures when the client signs auth events with http://localhost:3000/query but the relay expects http://127.0.0.1:3000/query.

Fix

  1. buzz-core::tenant — Add normalize_loopback_host() helper and call it from normalize_host. Collapses localhost, 127.0.0.1, 0.0.0.0, and [::1] to 127.0.0.1 (port preserved). This mirrors the loopback collapsing already in normalize_relay_url.

  2. buzz-auth::nip98 — Update normalize_url to normalize the host via normalize_host, so NIP-98 URL comparison agrees with community binding.

  3. Tests — Updated affected tests across buzz-core, buzz-auth, and buzz-relay to use the canonical 127.0.0.1 form. The previous test loopback_aliases_are_distinct_hosts (which asserted localhost ≠ 127.0.0.1) is replaced with loopback_aliases_collapse_to_same_host.

Verification

  • cargo test -p buzz-core --lib tenant::tests — 11/11 pass
  • cargo test -p buzz-auth --lib nip98 — 18/18 pass
  • cargo test -p buzz-relay --lib (affected tests) — 5/5 pass
  • End-to-end: agents now discover channels and respond to mentions; both localhost:3000 and 127.0.0.1:3000 return the same data from the relay

@AIandI0x1
AIandI0x1 requested a review from a team as a code owner July 23, 2026 21:04
…plit

`normalize_host` (buzz-core::tenant) and `normalize_relay_url`
(buzz-core::relay) disagreed on loopback address handling:
`normalize_relay_url` collapses `localhost`, `127.0.0.1`, and `[::1]`
to `127.0.0.1`, but `normalize_host` did not. This caused a silent
tenant split in local development:

- The desktop app spawns agents with `ws://127.0.0.1:3000` (after
  `normalize_relay_url`)
- The frontend connects via `ws://localhost:3000` (default)
- The relay's `bind_community` uses the raw `Host` header, so
  `127.0.0.1:3000` and `localhost:3000` map to **different communities**
- Agents discover 0 channels because their community has no channel data
- The UI shows no channels because its community is different/empty

Additionally, `normalize_url` in buzz-auth::nip98 did not collapse
loopback variants either, causing NIP-98 HTTP auth 401 failures when
the client signs auth events with `http://localhost:3000/query` but
the relay expects `http://127.0.0.1:3000/query`.

This commit:
1. Adds `normalize_loopback_host()` to buzz-core::tenant and calls it
   from `normalize_host`, collapsing `localhost`, `127.0.0.1`, `0.0.0.0`,
   and `[::1]` to `127.0.0.1` (with port preserved).
2. Updates `normalize_url` in buzz-auth::nip98 to normalize the host
   via `normalize_host`, so NIP-98 URL comparison agrees with community
   binding.
3. Updates affected tests across buzz-core, buzz-auth, and buzz-relay.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Signed-off-by: AIandI0x1 <AIandI0x1@users.noreply.github.com>
@AIandI0x1
AIandI0x1 force-pushed the fix/normalize-host-loopback-collapse branch from c778018 to b402cfd Compare July 23, 2026 21:06
@dophsquare

Copy link
Copy Markdown

P0 triage — merge candidate, one thing to verify. 🐝

Confirmed the tenant-split: normalize_relay_url collapses localhost/127.0.0.1/[::1]127.0.0.1, but the NIP-98 normalize_url path did not, so an event signed for localhost:3000 failed against an expected_url reconstructed from a 127.0.0.1-bound community — spurious 401s and agents/frontend landing in different communities in local dev.

The fix routes the authority through buzz_core::tenant::normalize_host inside normalize_url so the auth layer and the community-binding layer agree. The test flip is the right call: loopback_aliases_are_distinct_hostsloopback_aliases_collapse_to_same_host, now asserting localhost↔127.0.0.1↔[::1] all verify.

Security check before merge (the reason the old test existed): the old comment framed distinct loopback hosts as a deliberate "host-check side door" guard. Collapsing them is correct only for loopback — please confirm normalize_host collapses loopback addresses only and does not merge any routable/public host into another, so this doesn't widen NIP-98 u-tag matching beyond loopback. The rfind(':')/set_host/set_port reassembly also silently no-ops on parse failure — fine for the loopback case, just worth a quick glance that a weird authority can't slip an un-normalized host through. LGTM assuming loopback-only.

@AIandI0x1

Copy link
Copy Markdown
Author

Thanks for the triage. Confirmed on both points:

1. Loopback-only — no routable host merging. normalize_loopback_host (buzz-core/src/tenant.rs:152-174) collapses exactly four spellings: localhost, 127.0.0.1, 0.0.0.0, and [::1]. Every other host — 127.0.0.2, 8.8.8.8, relay.example, [::2], any routable IP or domain — hits the fall-through at line 162 (bracketed non-loopback IPv6) or line 173 (everything else) and is returned unchanged. The normalize_host_collapses_loopback_variants and normalize_host_leaves_ipv6_literal_intact tests pin both sides of that boundary.

2. Parse-failure no-op is safe. In normalize_url (buzz-auth/src/nip98.rs:145-173), Url::parse failure returns raw.to_lowercase() with no host manipulation (line 148). On the success path, normalize_host only produces either 127.0.0.1 (loopback collapse) or the original host string unchanged — both valid Url::set_host inputs — so the let _ = discarded results can't leave an un-normalized host behind. The rfind(':') split correctly separates host from port for all outputs normalize_host produces (which never contain bare colons outside of bracketed IPv6, and brackets are preserved).

Net: the u-tag matching surface is widened only for loopback aliases (intended) and cannot merge a routable/public host into another. LGTM to merge.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants