feat: add fallback parsing for pairing connection payloads in old Android WebViews (#2611)

This commit is contained in:
CallMeBill
2026-08-06 23:20:40 +03:00
committed by GitHub
parent 7e0e22f6e2
commit 61083c3915
4 changed files with 121 additions and 3 deletions
@@ -4,6 +4,7 @@ import {
buildPairingConnectionPayload,
encodePairingConnectionPayload,
parsePairingConnectionPayload,
parsePairingConnectionPayloadString,
} from './connectionPayload';
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
@@ -103,3 +104,45 @@ describe('connection payload helpers', () => {
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${expired}`)).toBeNull();
});
});
describe('parsePairingConnectionPayloadString (Android WebView fallback)', () => {
const payload = buildPairingConnectionPayload({
pairingId: 'pair_123',
secret: 'one-time-secret',
label: 'Desktop',
candidates: [
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 20 },
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 },
],
});
const encoded = encodePairingConnectionPayload(payload);
test('parses the canonical link identically to the URL-based parser', () => {
// Old Android WebViews resolve the same string with hostname "" / pathname "//connect";
// the string parser must not depend on the URL API to succeed.
expect(parsePairingConnectionPayloadString(encoded)).toEqual(parsePairingConnectionPayload(encoded));
});
test('recovers a link whose scheme/host case the URL parser would reject', () => {
const mixedCase = encoded.replace('openchamber://connect', 'OpenChamber://CONNECT');
expect(parsePairingConnectionPayload(mixedCase)).toBeNull();
expect(parsePairingConnectionPayloadString(mixedCase)).toEqual(parsePairingConnectionPayload(encoded));
});
test('tolerates a trailing slash and reordered query params', () => {
const trailingSlash = encoded.replace('openchamber://connect?', 'openchamber://connect/?');
expect(parsePairingConnectionPayloadString(trailingSlash)).toEqual(parsePairingConnectionPayload(encoded));
const p = encoded.slice(encoded.indexOf('p=') + 2);
expect(parsePairingConnectionPayloadString(`openchamber://connect?p=${p}&v=2`)).toEqual(parsePairingConnectionPayload(encoded));
});
test('still rejects non-pairing and malformed payloads', () => {
expect(parsePairingConnectionPayloadString('')).toBeNull();
expect(parsePairingConnectionPayloadString('hello world')).toBeNull();
expect(parsePairingConnectionPayloadString('openchamber://connect')).toBeNull();
expect(parsePairingConnectionPayloadString('openchamber:///connect?v=2&p=x')).toBeNull();
expect(parsePairingConnectionPayloadString('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=t')).toBeNull();
expect(parsePairingConnectionPayloadString('openchamber://connect?v=2&p=not-json')).toBeNull();
});
});
+32
View File
@@ -212,3 +212,35 @@ export const parsePairingConnectionPayload = (value: string): PairingConnectionP
return null;
}
};
// URL-string-only sibling of parsePairingConnectionPayload. Old Android WebViews
// (e.g. WebView 114) mis-parse non-special schemes: `new URL('openchamber://connect?...')`
// yields hostname "" and pathname "//connect", so the URL-based parser above rejects a
// perfectly valid pairing link. This parser never touches the URL/URLSearchParams APIs —
// it matches the head with a regex and reads `v`/`p` straight off the query string.
// Used by the Android QR-scan path after the standard parse fails; keeps every existing
// validation (version, payload length, base64url, candidate normalization).
export const parsePairingConnectionPayloadString = (value: string): PairingConnectionPayload | null => {
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
const question = trimmed.indexOf('?');
if (question === -1 || !/^openchamber:\/\/connect\/?$/i.test(trimmed.slice(0, question))) return null;
let version: string | null = null;
let encoded: string | null = null;
for (const part of trimmed.slice(question + 1).split('&')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const key = part.slice(0, eq);
const value_ = part.slice(eq + 1);
if (key === 'v') version = value_;
else if (key === 'p') encoded = value_;
}
if (version !== '2' || !encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
const decoded = base64UrlDecode(encoded);
if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
try {
return normalizePairingPayload(JSON.parse(decoded) as unknown);
} catch {
return null;
}
};