From 1e2a653ad15f8d073dcaa8f5df6055b9416b8526 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 23:43:28 +0200 Subject: [PATCH] fix(nostr-client): reject non-ws(s):// relays in the spire seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npubs in the seed are bech32-checksummed, so a mis-scanned character is caught — but the relay strings are raw inside the base64. A QR misread silently turned `ws://192.168.0.32:5001/...` into `As://192.168.0.32:5001/...`, which parsed fine and then crash-looped the machine on an unreachable NIP-46 relay. Validate every `relays[]` entry (and `bunker_relay`) is a `ws://`/`wss://` URL at parse time, so a garbled scan is rejected as an invalid seed instead of persisted. Part of bitspire-#70 pairing robustness. Co-Authored-By: Claude Opus 4.8 --- packages/nostr-client/src/__tests__/seed.test.ts | 3 +++ packages/nostr-client/src/seed.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/nostr-client/src/__tests__/seed.test.ts b/packages/nostr-client/src/__tests__/seed.test.ts index 3e3bba7..6e38159 100644 --- a/packages/nostr-client/src/__tests__/seed.test.ts +++ b/packages/nostr-client/src/__tests__/seed.test.ts @@ -80,7 +80,10 @@ describe('parseSpireSeed', () => { ['missing bunker_secret', { ...VALID, bunker_secret: undefined }], ['empty relays', { ...VALID, relays: [] }], ['non-string relay', { ...VALID, relays: [123] }], + ['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }], + ['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }], ['empty bunker_relay', { ...VALID, bunker_relay: '' }], + ['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }], ])('rejects %s', (_label, json) => { expect(() => parseSpireSeed(makeSeed(json))).toThrow() }) diff --git a/packages/nostr-client/src/seed.ts b/packages/nostr-client/src/seed.ts index 482d6b9..02a6470 100644 --- a/packages/nostr-client/src/seed.ts +++ b/packages/nostr-client/src/seed.ts @@ -53,6 +53,20 @@ export interface SpireSeed { const HEX64 = /^[0-9a-f]{64}$/ +/** + * A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed, + * so a mis-scanned character is caught), the relay strings are raw inside the + * seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://` + * and the pairing then crash-loops on an unreachable relay. Reject at parse time + * so the wizard refuses a garbled scan instead of persisting it (bitspire#70). + */ +const WS_URL = /^wss?:\/\/[^\s]+$/ +function assertRelayUrl(value: string, field: string): void { + if (!WS_URL.test(value)) { + throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`) + } +} + /** Decode an unpadded base64url string in both browser and Node. */ function base64urlDecode(input: string): string { const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=') @@ -118,6 +132,7 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) { throw new Error('parseSpireSeed: relays must be a non-empty string array') } + relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`)) // Optional bunker relay; default to the first event relay. Keeps the common // case (bunker on the same relay) one field lighter, while still allowing a @@ -127,6 +142,7 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) { throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string') } + assertRelayUrl(obj.bunker_relay, 'bunker_relay') bunkerRelay = obj.bunker_relay }