Skip to content

CON-45: no plaintext ws:// for a host that merely starts with localhost - #90

Merged
molgerz merged 2 commits into
mainfrom
con-45-group-address-host
Sep 19, 2026
Merged

molgerz merged 2 commits into
mainfrom
con-45-group-address-host

Conversation

@molgerz

@molgerz molgerz commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Closes CON-45. From the CON-43 security audit.

host.startsWith('localhost') is true for localhost.evil.example — a name anyone can register. Such a host got an unencrypted ws:// connection, and because the host comes out of the route, whoever writes a space link picks it. NostrClient signs a NIP-42 AUTH event on connect, so that put the signed-in user's pubkey on the wire in the clear.

What is in it

  • The rule is anchored and lives in one exported predicate (isLocalRelayHost), so the trust gate in CON-46 asks the same question instead of keeping a second inline copy of the condition — which is how this bug started. [::1] joins the local set.
  • The predicate normalises before it decides, rather than assuming a validated host: on the bare regex localhost:99999, localhost:65536 and localhost:00000 are "local", and a caller would pick plaintext for a host no connection can be made to.
  • The host is validated by round-tripping it through URL: whatever the platform's parser reads as the host is what a connection would go to, so anything that makes input and parse disagree is rejected — evil.example/#@real-relay.example, paths, queries, userinfo, whitespace, out-of-range ports.
  • One exception, deliberate: a written-out :443 survives. URL drops it as https' default port, but a local host carries its port into a ws:// URL where :443 is not the default — and my-spaces.ts and CreateSpaceForm.tsx derive a host by stripping the scheme off DEFAULT_RELAY_URL, so a deployment whose VITE_RELAY_URL spells the port out produces exactly that shape. A padded :0443 is still rejected.
  • The second decodeURIComponent is gone: React Router decodes path params before useParams, so it was a decoder with no matching encoder — a group id ending in % threw URIError out of render (white screen), and a double-encoded link decoded back into a host the address bar never showed.

How to test

  1. Open /s/localhost.evil.example'engineering (no such relay needs to exist) and check in DevTools → Network that the attempted WebSocket is wss://, not ws://. Same for localhost.evil.example:8080.
  2. The real dev relay still connects over ws:// (localhost:8080, 127.0.0.1:8080, [::1]:8080).
  3. /s/evil.example/#@real-relay.example'group, a host with a path, a query or a port above 65535: the address is refused rather than connected to.
  4. A group id ending in % renders an error instead of a white screen.
  5. src/nostr/group-address.test.ts covers the whole table.

M added 2 commits September 14, 2026 15:54
`host.startsWith('localhost')` is true for `localhost.evil.example`, a name
anyone can register. Such a host therefore got an unencrypted `ws://`
connection — and because the host comes out of the route, whoever writes a
link picks it. NostrClient signs a NIP-42 AUTH event on connect, so that put
the signed-in user's pubkey on the wire in the clear.

The rule is anchored now and lives in one exported predicate, so the trust
gate in CON-46 asks the same question rather than keeping a second inline
copy of the condition — which is how this bug started. `[::1]` joins the
local set: a dev relay bound to IPv6 is no less local than 127.0.0.1.

The host is also validated by round-tripping it through `URL`: whatever the
platform's own parser reads as the host is what a connection would go to, so
anything that makes input and parse disagree is rejected. That covers
`evil.example/#@real-relay.example`, which only reads as the real relay, and
paths, queries, userinfo, whitespace and out-of-range ports with it.

The second `decodeURIComponent` is gone. React Router decodes path params
before `useParams`, so it was a decoder with no matching encoder, and it did
harm twice: a group id ending in a percent sign threw `URIError` out of
render, and a double-encoded link decoded back into a host that the address
bar never showed.
The predicate is exported for callers holding a host from anywhere, not only
for one `parseGroupAddress` has already validated — so it has to answer for
itself. On the bare regex `localhost:99999`, `localhost:65536` and
`localhost:00000` are all local, and a caller would pick plaintext `ws://` for
a host no connection can be made to. It runs the host through `normalizeHost`
now, which also keeps the port's range check in one place.

`normalizeHost` accepts a written-out `:443` again, as the single exception
where input and parse may disagree: `URL` drops it as https' default port, but
a local host carries its port into a `ws://` URL, where `:443` is *not* the
default and `ws://localhost:443` is a different connection than
`ws://localhost`. This is reached in practice — `my-spaces.ts` and
`CreateSpaceForm.tsx` derive the host by stripping the scheme off
`DEFAULT_RELAY_URL`, so a deployment whose `VITE_RELAY_URL` spells the port out
produces exactly that shape. The comparison stays exact, so a padded `:0443` is
still rejected.

docs/08 records the rule with the relay operations, where somebody choosing a
host will read it.
@molgerz
molgerz merged commit 878b0d9 into main Sep 19, 2026
1 check passed
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.

1 participant