From the CON-43 security audit (2026-09-14). Severity medium. This is a decision, not a bug report — the behaviour follows from how NIP-29 addresses work and is shared with every Nostr client that uses relay-hinted addressing. What is missing is that we ever decided it, and that the docs say it.
What happens today
A space address is <relay-host>'<group-id> and lives in the URL: /s/<host>'<group>. The chain is short and fully automatic:
src/ui/layout/AppShell.tsx:37-39 — parseGroupAddress(params.group) turns the route parameter into relayUrl, with no allowlist and no prompt.
useRelay(relayUrl) → client.want(relayUrl) opens the WebSocket.
src/nostr/relay-status.ts:19 additionally fetches https://<host> for the NIP-11 document.
src/nostr/client.ts:172 — this.pool.automaticallyAuth = (url) => this.signAuth(url). If the visitor is signed in, the NIP-42 AUTH event is signed and sent without any interaction.
So following a link is enough to make a signed-in user's browser contact a host of the link author's choosing and hand it a signed statement of who they are. The signature is scoped to that relay per NIP-42 and is not replayable elsewhere, but the pubkey, the IP and the timing are disclosed, and the visitor never chose to trust that relay.
Why this needs a decision rather than a fix
The relay is part of the group's identity in NIP-29 — a space address without a relay host is not an address. Refusing unknown relays outright would mean the app can only ever open spaces on relays already known to it, which breaks the one thing a shareable space link is for. So the plausible answers are not "fix it" but a choice:
- Accept and document. Treat it like the image decision (docs/09-security-privacy.md → "Images are loaded directly"): a stated, deliberate trade. Cheapest, and defensible — but only once it is actually written down.
- Ask on first contact. An unknown relay host prompts once ("This space lives on
relay.example. Connect?") and is remembered per host. Costs one interaction on a genuinely new space, and removes the drive-by entirely.
- Connect but do not authenticate. Open the socket to an unknown relay, but withhold AUTH until the user has confirmed the host. Reading a private group then fails until they confirm, which is most of the value — but it does keep the pubkey back, which is the part that identifies a person.
The closed-teams model (reading always needs sign-in and an invitation) argues that visitors will be signed in essentially always, so the disclosure is the normal case, not an edge case.
Scope
- Pick one of the three and record why in
docs/09-security-privacy.md.
- Implement it, unless the decision is "accept", in which case the docs change is the whole change.
- Either way, extend the privacy section of
docs/09-security-privacy.md: it currently discusses only images, so a reader can reasonably assume relay connections are more guarded than they are. The audit found no place where code and that document contradict each other — this is the one omission.
- If the decision introduces a known-hosts list, note the interaction with the CSP ticket: a narrow
connect-src and arbitrary relay hosts pull in opposite directions, and an allowlist is what would let both exist.
Out of scope
The startsWith prefix bug in the same function is its own ticket and should land regardless of what is decided here.
Acceptance
- The decision and its reasoning are in
docs/09-security-privacy.md.
- If code changed: the dev setup (
localhost:8080) still opens without friction, and tests cover the new path.
npm run typecheck && npm run lint && npm test && npm run build pass.
Affected: docs/09-security-privacy.md, possibly src/nostr/client.ts, src/ui/layout/AppShell.tsx, src/nostr/group-address.ts
Source: CON-43, finding A4.
Task: lisl309duhciphb3mt2rq515
From the CON-43 security audit (2026-09-14). Severity medium. This is a decision, not a bug report — the behaviour follows from how NIP-29 addresses work and is shared with every Nostr client that uses relay-hinted addressing. What is missing is that we ever decided it, and that the docs say it.
What happens today
A space address is
<relay-host>'<group-id>and lives in the URL:/s/<host>'<group>. The chain is short and fully automatic:src/ui/layout/AppShell.tsx:37-39—parseGroupAddress(params.group)turns the route parameter intorelayUrl, with no allowlist and no prompt.useRelay(relayUrl)→client.want(relayUrl)opens the WebSocket.src/nostr/relay-status.ts:19additionally fetcheshttps://<host>for the NIP-11 document.src/nostr/client.ts:172—this.pool.automaticallyAuth = (url) => this.signAuth(url). If the visitor is signed in, the NIP-42 AUTH event is signed and sent without any interaction.So following a link is enough to make a signed-in user's browser contact a host of the link author's choosing and hand it a signed statement of who they are. The signature is scoped to that relay per NIP-42 and is not replayable elsewhere, but the pubkey, the IP and the timing are disclosed, and the visitor never chose to trust that relay.
Why this needs a decision rather than a fix
The relay is part of the group's identity in NIP-29 — a space address without a relay host is not an address. Refusing unknown relays outright would mean the app can only ever open spaces on relays already known to it, which breaks the one thing a shareable space link is for. So the plausible answers are not "fix it" but a choice:
relay.example. Connect?") and is remembered per host. Costs one interaction on a genuinely new space, and removes the drive-by entirely.The closed-teams model (reading always needs sign-in and an invitation) argues that visitors will be signed in essentially always, so the disclosure is the normal case, not an edge case.
Scope
docs/09-security-privacy.md.docs/09-security-privacy.md: it currently discusses only images, so a reader can reasonably assume relay connections are more guarded than they are. The audit found no place where code and that document contradict each other — this is the one omission.connect-srcand arbitrary relay hosts pull in opposite directions, and an allowlist is what would let both exist.Out of scope
The
startsWithprefix bug in the same function is its own ticket and should land regardless of what is decided here.Acceptance
docs/09-security-privacy.md.localhost:8080) still opens without friction, and tests cover the new path.npm run typecheck && npm run lint && npm test && npm run buildpass.Affected:
docs/09-security-privacy.md, possiblysrc/nostr/client.ts,src/ui/layout/AppShell.tsx,src/nostr/group-address.tsSource: CON-43, finding A4.
Task: lisl309duhciphb3mt2rq515