From 58f10fa2da7dcc2daf5fdc90313a51f2f08036b2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 10:55:36 -0700 Subject: [PATCH] fix: require exact pairing deep link route (#4282) --- src/renderer/src/web/web-pairing.test.ts | 5 ++++ src/renderer/src/web/web-pairing.ts | 38 ++++++++++++++++-------- src/shared/pairing.test.ts | 9 ++++++ src/shared/pairing.ts | 30 ++++++++++--------- 4 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/web/web-pairing.test.ts b/src/renderer/src/web/web-pairing.test.ts index 9c62650a1..8283d5ba5 100644 --- a/src/renderer/src/web/web-pairing.test.ts +++ b/src/renderer/src/web/web-pairing.test.ts @@ -24,4 +24,9 @@ describe('web pairing input', () => { it('still parses legacy hash-form pairing URLs', () => { expect(parseWebPairingInput(`orca://pair#${encodeOffer()}`)).toEqual(offer) }) + + it('rejects orca URLs outside the exact pairing route', () => { + expect(parseWebPairingInput(`orca://pairing?code=${encodeOffer()}`)).toBeNull() + expect(parseWebPairingInput(`orca://pair-extra?code=${encodeOffer()}`)).toBeNull() + }) }) diff --git a/src/renderer/src/web/web-pairing.ts b/src/renderer/src/web/web-pairing.ts index 667799553..dc59b362f 100644 --- a/src/renderer/src/web/web-pairing.ts +++ b/src/renderer/src/web/web-pairing.ts @@ -14,19 +14,9 @@ export function parseWebPairingInput(input: string): WebPairingOffer | null { } try { - if (trimmed.startsWith('orca://pair')) { - const queryIndex = trimmed.indexOf('?') - if (queryIndex !== -1) { - const query = trimmed.slice(queryIndex + 1).split('#')[0] ?? '' - const params = new URLSearchParams(query) - const code = params.get('code') - return code ? decodePairingPayload(code) : null - } - const hashIndex = trimmed.indexOf('#') - if (hashIndex === -1) { - return null - } - return decodePairingPayload(trimmed.slice(hashIndex + 1)) + if (trimmed.toLowerCase().startsWith('orca://')) { + const code = extractPairingCodeFromUrl(trimmed) + return code ? decodePairingPayload(code) : null } return decodePairingPayload(trimmed) } catch { @@ -92,6 +82,28 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null { } } +function extractPairingCodeFromUrl(url: string): string | null { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return null + } + // Why: prefix checks accepted routes like `orca://pairing?...`; only the + // pairing deep-link host may carry runtime auth material. + if (parsed.protocol !== 'orca:' || parsed.hostname !== 'pair') { + return null + } + if (parsed.pathname !== '' && parsed.pathname !== '/') { + return null + } + const code = parsed.searchParams.get('code') + if (code) { + return code + } + return parsed.hash ? parsed.hash.slice(1) || null : null +} + function base64UrlToBytes(value: string): Uint8Array { const base64 = value.replace(/-/g, '+').replace(/_/g, '/') const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=') diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index 3c971e61d..e0761df7a 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -32,6 +32,15 @@ describe('pairing offer', () => { expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL') }) + it('rejects orca URLs outside the exact pairing route', () => { + const url = encodePairingOffer(offer) + const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')! + + expect(parsePairingCode(`orca://pairing?code=${code}`)).toBeNull() + expect(parsePairingCode(`orca://pair-extra?code=${code}`)).toBeNull() + expect(() => decodePairingOffer(`orca://pairing?code=${code}`)).toThrow('Invalid pairing URL') + }) + it('rejects URLs without a pairing code', () => { expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL') }) diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 220525c76..d24df0783 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -34,23 +34,25 @@ export function decodePairingOffer(url: string): PairingOffer { } function extractPairingCodeFromUrl(url: string): string | null { - if (!url.startsWith('orca://pair')) { + let parsed: URL + try { + parsed = new URL(url) + } catch { return null } - const queryIndex = url.indexOf('?') - if (queryIndex !== -1) { - const query = url.slice(queryIndex + 1).split('#')[0] ?? '' - const params = new URLSearchParams(query) - const code = params.get('code') - if (code) { - return code - } + // Why: prefix checks accepted routes like `orca://pairing?...`; only the + // pairing deep-link host may carry runtime auth material. + if (parsed.protocol !== 'orca:' || parsed.hostname !== 'pair') { + return null } - const hashIndex = url.indexOf('#') - if (hashIndex !== -1) { - return url.slice(hashIndex + 1) || null + if (parsed.pathname !== '' && parsed.pathname !== '/') { + return null } - return null + const code = parsed.searchParams.get('code') + if (code) { + return code + } + return parsed.hash ? parsed.hash.slice(1) || null : null } // Why: accept either an `orca://pair?...` URL or the bare base64 @@ -62,7 +64,7 @@ export function parsePairingCode(input: string): PairingOffer | null { return null } try { - if (trimmed.startsWith('orca://pair')) { + if (trimmed.toLowerCase().startsWith('orca://')) { return decodePairingOffer(trimmed) } return decodePairingBase64(trimmed)