fix(desktop): reach split-horizon instances on their LAN IP (#3271)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 03:05:15 +02:00
1 parent 3071bc7525
commit d00389ab30
2 files changed
+65 -46

No files matched your search

@@ -115,19 +115,59 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
outboundHTTP.cleanup(); 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({ 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( await assert.rejects(
outboundHTTP.registerAnchoredOrigins({ outboundHTTP.registerAnchoredOrigins({
anchorOrigin: 'https://mixed.example', anchorOrigin: 'https://unusable.example',
origins: [], origins: [],
unresolvedAnchorRequirement: 'any', 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(); outboundHTTP.cleanup();
}); });
@@ -440,16 +480,15 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
outboundHTTP.cleanup(); 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({ const {port, requested, server} = await startServer({
'/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'), '/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'),
}); });
try { try {
const outboundHTTP = new DesktopOutboundHTTP({ 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( const response = await outboundHTTP.request({
outboundHTTP.request({
body: null, body: null,
expectedOrigin: `http://mixed.example:${port}`, expectedOrigin: `http://mixed.example:${port}`,
headers: null, headers: null,
@@ -459,10 +498,10 @@ describe('DesktopOutboundHTTP origin anchoring', () => {
signal: null, signal: null,
timeoutMs: 2000, timeoutMs: 2000,
url: `http://mixed.example:${port}/ping`, url: `http://mixed.example:${port}/ping`,
}), });
/resolved to both public and non-public addresses/, response.message.resume();
); assert.equal(response.status, 200);
assert.deepEqual(requested, [], 'a mixed-scope origin must never be contacted'); assert.deepEqual(requested, ['/ping']);
outboundHTTP.cleanup(); outboundHTTP.cleanup();
} finally { } finally {
server.close(); server.close();
+13 -33
View File
@@ -594,17 +594,12 @@ async function lookupAllAddresses(hostname: string): Promise<ReadonlyArray<strin
return records.map((record) => record.address); return records.map((record) => record.address);
} }
function selectPinnedAddress( function selectPinnedAddress(candidates: ReadonlyArray<PinnedAddress>): PinnedAddress | null {
candidates: ReadonlyArray<PinnedAddress>, const nonPublic = candidates.filter((candidate) => !isPublicPinnedAddress(candidate));
scope: DesktopOriginAddressScope | null, if (nonPublic.length === 0) {
): PinnedAddress | null { return candidates[0] ?? null;
if (candidates.length === 0) {
return null;
} }
if (scope === DesktopOriginAddressScope.NON_PUBLIC) { return nonPublic.find((candidate) => candidate.family === 4) ?? nonPublic[0];
return candidates.find((candidate) => candidate.family === 4) ?? candidates[0];
}
return candidates[0];
} }
function addressScope(address: PinnedAddress): DesktopOriginAddressScope { function addressScope(address: PinnedAddress): DesktopOriginAddressScope {
@@ -1038,16 +1033,14 @@ export class DesktopOutboundHTTP {
throw blocked(reason, context, hostname); throw blocked(reason, context, hostname);
} }
const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null); 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); throw blocked(DesktopOutboundBlockReason.NO_USABLE_ADDRESS, context, hostname);
} }
if ( if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(pinned)) {
requirement === DesktopAddressRequirement.PUBLIC &&
candidates.some((candidate) => !isPublicPinnedAddress(candidate))
) {
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname); throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
} }
return candidates[0]; return pinned;
} }
private async resolveHost(hostname: string): Promise<ReadonlyArray<string>> { private async resolveHost(hostname: string): Promise<ReadonlyArray<string>> {
@@ -1177,25 +1170,12 @@ export class DesktopOutboundHTTP {
return {address: literal, origin, scope: addressScope(literal), unreachable: false}; return {address: literal, origin, scope: addressScope(literal), unreachable: false};
} }
const rawAddresses = await this.resolveOriginHost(absoluteLookupHostname(hostname), origin); const rawAddresses = await this.resolveOriginHost(absoluteLookupHostname(hostname), origin);
const candidates: Array<PinnedAddress> = []; const candidates = rawAddresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
let scope: DesktopOriginAddressScope | null = null; const pinned = selectPinnedAddress(candidates);
for (const rawAddress of rawAddresses) { if (pinned == null) {
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) {
throw new DesktopOutboundHTTPEmptyResolutionError(origin); 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 { private markBindingUnreachable(binding: DesktopOriginAddressBinding): void {