CON-45: no plaintext ws:// for a host that merely starts with localhost - #90
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes CON-45. From the CON-43 security audit.
host.startsWith('localhost')is true forlocalhost.evil.example— a name anyone can register. Such a host got an unencryptedws://connection, and because the host comes out of the route, whoever writes a space link picks it.NostrClientsigns 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
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.localhost:99999,localhost:65536andlocalhost:00000are "local", and a caller would pick plaintext for a host no connection can be made to.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.:443survives.URLdrops it as https' default port, but a local host carries its port into aws://URL where:443is not the default — andmy-spaces.tsandCreateSpaceForm.tsxderive a host by stripping the scheme offDEFAULT_RELAY_URL, so a deployment whoseVITE_RELAY_URLspells the port out produces exactly that shape. A padded:0443is still rejected.decodeURIComponentis gone: React Router decodes path params beforeuseParams, so it was a decoder with no matching encoder — a group id ending in%threwURIErrorout of render (white screen), and a double-encoded link decoded back into a host the address bar never showed.How to test
/s/localhost.evil.example'engineering(no such relay needs to exist) and check in DevTools → Network that the attempted WebSocket iswss://, notws://. Same forlocalhost.evil.example:8080.ws://(localhost:8080,127.0.0.1:8080,[::1]:8080)./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.%renders an error instead of a white screen.src/nostr/group-address.test.tscovers the whole table.