From d00389ab30218497b0991c95a066dcbeb259ee9b Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 8 Oct 2026 03:05:15 +0200 Subject: [PATCH] fix(desktop): reach split-horizon instances on their LAN IP (#3271) --- .../src/main/DesktopOutboundHTTP.test.mjs | 83 ++++++++++++++----- .../src/main/DesktopOutboundHTTP.ts | 46 +++------- 2 files changed, 74 insertions(+), 55 deletions(-) diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs b/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs index faca6eef2..9e3ecc247 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs @@ -115,19 +115,59 @@ describe('DesktopOutboundHTTP origin anchoring', () => { outboundHTTP.cleanup(); }); - test('an anchor that resolves to a refused scope never falls back to the remembered requirement', async () => { + test('an anchor that resolves to no usable address never falls back to the remembered requirement', async () => { const outboundHTTP = new DesktopOutboundHTTP({ - resolveHostAddresses: stubResolver({'mixed.example': ['10.0.0.1', '93.184.216.34']}), + resolveHostAddresses: stubResolver({'unusable.example': ['not-an-address']}), }); await assert.rejects( outboundHTTP.registerAnchoredOrigins({ - anchorOrigin: 'https://mixed.example', + anchorOrigin: 'https://unusable.example', origins: [], unresolvedAnchorRequirement: 'any', }), - {name: 'DesktopOutboundHTTPMixedAddressScopeError'}, + {name: 'DesktopOutboundHTTPEmptyResolutionError'}, + ); + assert.equal(outboundHTTP.isRegisteredOrigin('https://unusable.example'), false); + outboundHTTP.cleanup(); + }); + + test('a split-horizon anchor resolving to private and public addresses is anchored on its private address', async () => { + const outboundHTTP = new DesktopOutboundHTTP({ + resolveHostAddresses: stubResolver({'chat.home.example': ['2606:4700:4700::1111', '192.168.1.20']}), + }); + assert.equal( + await outboundHTTP.registerAnchoredOrigins({anchorOrigin: 'http://chat.home.example', origins: []}), + 'any', + ); + assert.equal(await outboundHTTP.requireCleartextTransportAddress('http://chat.home.example'), '192.168.1.20'); + outboundHTTP.cleanup(); + }); + + test('a publicly reachable anchor refuses an advertised origin that also resolves privately', async () => { + const outboundHTTP = new DesktopOutboundHTTP({ + resolveHostAddresses: stubResolver({ + 'public.example': ['93.184.216.34'], + 'media.mixed.example': ['93.184.216.35', '10.0.0.1'], + }), + }); + await outboundHTTP.registerAnchoredOrigins({ + anchorOrigin: 'https://public.example', + origins: ['https://media.mixed.example'], + }); + await assert.rejects( + outboundHTTP.request({ + body: null, + expectedOrigin: 'https://media.mixed.example', + headers: null, + method: 'GET', + originTrust: DesktopOriginTrust.REGISTERED, + serviceName: 'test', + signal: null, + timeoutMs: 500, + url: 'https://media.mixed.example/avatars/1.webp', + }), + /cannot authorize the privately resolving origin/, ); - assert.equal(outboundHTTP.isRegisteredOrigin('https://mixed.example'), false); outboundHTTP.cleanup(); }); @@ -440,29 +480,28 @@ describe('DesktopOutboundHTTP origin anchoring', () => { outboundHTTP.cleanup(); }); - test('an origin resolving to both public and non-public addresses is refused outright', async () => { + test('a chosen origin resolving to both public and non-public addresses is reached on its non-public address', async () => { const {port, requested, server} = await startServer({ '/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'), }); try { const outboundHTTP = new DesktopOutboundHTTP({ - resolveHostAddresses: stubResolver({'mixed.example': ['127.0.0.1', '93.184.216.34']}), + resolveHostAddresses: stubResolver({'mixed.example': ['93.184.216.34', '127.0.0.1']}), }); - await assert.rejects( - outboundHTTP.request({ - body: null, - expectedOrigin: `http://mixed.example:${port}`, - headers: null, - method: 'GET', - originTrust: DesktopOriginTrust.BOUND, - serviceName: 'test', - signal: null, - timeoutMs: 2000, - url: `http://mixed.example:${port}/ping`, - }), - /resolved to both public and non-public addresses/, - ); - assert.deepEqual(requested, [], 'a mixed-scope origin must never be contacted'); + const response = await outboundHTTP.request({ + body: null, + expectedOrigin: `http://mixed.example:${port}`, + headers: null, + method: 'GET', + originTrust: DesktopOriginTrust.BOUND, + serviceName: 'test', + signal: null, + timeoutMs: 2000, + url: `http://mixed.example:${port}/ping`, + }); + response.message.resume(); + assert.equal(response.status, 200); + assert.deepEqual(requested, ['/ping']); outboundHTTP.cleanup(); } finally { server.close(); diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts index 383d69f44..93da88263 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts @@ -594,17 +594,12 @@ async function lookupAllAddresses(hostname: string): Promise record.address); } -function selectPinnedAddress( - candidates: ReadonlyArray, - scope: DesktopOriginAddressScope | null, -): PinnedAddress | null { - if (candidates.length === 0) { - return null; +function selectPinnedAddress(candidates: ReadonlyArray): PinnedAddress | null { + const nonPublic = candidates.filter((candidate) => !isPublicPinnedAddress(candidate)); + if (nonPublic.length === 0) { + return candidates[0] ?? null; } - if (scope === DesktopOriginAddressScope.NON_PUBLIC) { - return candidates.find((candidate) => candidate.family === 4) ?? candidates[0]; - } - return candidates[0]; + return nonPublic.find((candidate) => candidate.family === 4) ?? nonPublic[0]; } function addressScope(address: PinnedAddress): DesktopOriginAddressScope { @@ -1038,16 +1033,14 @@ export class DesktopOutboundHTTP { throw blocked(reason, context, hostname); } const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null); - if (candidates.length === 0) { + const pinned = selectPinnedAddress(candidates); + if (pinned == null) { throw blocked(DesktopOutboundBlockReason.NO_USABLE_ADDRESS, context, hostname); } - if ( - requirement === DesktopAddressRequirement.PUBLIC && - candidates.some((candidate) => !isPublicPinnedAddress(candidate)) - ) { + if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(pinned)) { throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname); } - return candidates[0]; + return pinned; } private async resolveHost(hostname: string): Promise> { @@ -1177,25 +1170,12 @@ export class DesktopOutboundHTTP { return {address: literal, origin, scope: addressScope(literal), unreachable: false}; } const rawAddresses = await this.resolveOriginHost(absoluteLookupHostname(hostname), origin); - const candidates: Array = []; - let scope: DesktopOriginAddressScope | null = null; - for (const rawAddress of rawAddresses) { - const address = parseIPAddress(rawAddress); - if (address == null) { - continue; - } - const currentScope = addressScope(address); - if (scope != null && scope !== currentScope) { - throw new DesktopOutboundHTTPMixedAddressScopeError(origin); - } - scope = currentScope; - candidates.push(address); - } - const pinned = selectPinnedAddress(candidates, scope); - if (pinned == null || scope == null) { + const candidates = rawAddresses.map((address) => parseIPAddress(address)).filter((value) => value != null); + const pinned = selectPinnedAddress(candidates); + if (pinned == null) { throw new DesktopOutboundHTTPEmptyResolutionError(origin); } - return {address: pinned, origin, scope, unreachable: false}; + return {address: pinned, origin, scope: addressScope(pinned), unreachable: false}; } private markBindingUnreachable(binding: DesktopOriginAddressBinding): void {