mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
1112 lines
37 KiB
JavaScript
1112 lines
37 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import {Readable} from 'node:stream';
|
|
import {describe, test} from 'node:test';
|
|
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
|
|
|
|
installElectronStub();
|
|
|
|
const {DesktopOriginTrust, DesktopOutboundHTTP, createPinnedHostLookup, readBoundedMessage} = await import(
|
|
'./DesktopOutboundHTTP.ts'
|
|
);
|
|
const {DesktopSelectedInstanceClient} = await import('./SelectedInstanceFetch.ts');
|
|
const {getLaunchInstanceEndpointOverride} = await import('../common/DesktopConfig.ts');
|
|
|
|
const PNG_BYTES = Buffer.from('89504e470d0a1a0a', 'hex');
|
|
|
|
function stubResolver(table) {
|
|
return async (hostname) => {
|
|
const key = hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
|
|
const addresses = table[key];
|
|
if (addresses == null) {
|
|
throw new Error(`no stubbed address for ${hostname}`);
|
|
}
|
|
return addresses;
|
|
};
|
|
}
|
|
|
|
async function startServer(routes) {
|
|
const requested = [];
|
|
const server = http.createServer((request, response) => {
|
|
const url = new URL(request.url, 'http://127.0.0.1');
|
|
requested.push(url.pathname);
|
|
const route = routes[url.pathname];
|
|
if (route == null) {
|
|
response.writeHead(404);
|
|
response.end();
|
|
return;
|
|
}
|
|
route(request, response);
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
return {port: server.address().port, requested, server};
|
|
}
|
|
|
|
function sendImage(response, contentType, body, headers = {}) {
|
|
response.writeHead(200, {'content-type': contentType, ...headers});
|
|
response.end(body);
|
|
}
|
|
|
|
describe('DesktopOutboundHTTP origin anchoring', () => {
|
|
test('a publicly reachable anchor refuses an advertised origin that resolves privately', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'media.internal': ['10.0.0.1'], 'public.example': ['93.184.216.34']}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: 'https://public.example',
|
|
origins: ['https://media.internal'],
|
|
});
|
|
await assert.rejects(
|
|
outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: 'https://media.internal',
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 500,
|
|
url: 'https://media.internal/avatars/1.webp',
|
|
}),
|
|
/cannot authorize the privately resolving origin/,
|
|
);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('an anchor that cannot resolve registers under the remembered requirement', async () => {
|
|
let online = false;
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async (hostname) => {
|
|
if (!online) {
|
|
throw Object.assign(new Error(`getaddrinfo EAI_AGAIN ${hostname}`), {
|
|
code: 'EAI_AGAIN',
|
|
syscall: 'getaddrinfo',
|
|
});
|
|
}
|
|
return hostname.startsWith('media.internal') ? ['10.0.0.1'] : ['93.184.216.34'];
|
|
},
|
|
});
|
|
const registration = {anchorOrigin: 'https://public.example', origins: ['https://media.internal']};
|
|
await assert.rejects(outboundHTTP.registerAnchoredOrigins(registration), {code: 'EAI_AGAIN'});
|
|
assert.equal(outboundHTTP.isRegisteredOrigin('https://public.example'), false);
|
|
assert.equal(
|
|
await outboundHTTP.registerAnchoredOrigins({...registration, unresolvedAnchorRequirement: 'public'}),
|
|
'public',
|
|
);
|
|
assert.equal(outboundHTTP.isRegisteredOrigin('https://media.internal'), true);
|
|
online = true;
|
|
await assert.rejects(
|
|
outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: 'https://media.internal',
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 500,
|
|
url: 'https://media.internal/avatars/1.webp',
|
|
}),
|
|
/cannot authorize the privately resolving origin/,
|
|
);
|
|
assert.equal(await outboundHTTP.registerAnchoredOrigins(registration), 'public');
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('an anchor that resolves to no usable address never falls back to the remembered requirement', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'unusable.example': ['not-an-address']}),
|
|
});
|
|
await assert.rejects(
|
|
outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: 'https://unusable.example',
|
|
origins: [],
|
|
unresolvedAnchorRequirement: 'any',
|
|
}),
|
|
{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/,
|
|
);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('only cleartext origins anchored on a private address are offered to the page policy', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({
|
|
'lan.example': ['192.168.1.20'],
|
|
'public.example': ['93.184.216.34'],
|
|
}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: 'http://lan.example:48090',
|
|
origins: ['http://lan.example:48091', 'https://lan.example'],
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: 'https://public.example',
|
|
origins: ['http://public.example'],
|
|
});
|
|
assert.deepEqual(outboundHTTP.registeredCleartextOrigins(), [
|
|
'http://lan.example:48090',
|
|
'http://lan.example:48091',
|
|
]);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('a request that cannot be constructed releases its in-flight slot', async () => {
|
|
const {port, server} = await startServer({
|
|
'/ok': (_request, response) => sendImage(response, 'image/png', PNG_BYTES),
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'lan.example': ['127.0.0.1']}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: `http://lan.example:${port}`,
|
|
origins: [`http://lan.example:${port}`],
|
|
});
|
|
const send = (headers) =>
|
|
outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: `http://lan.example:${port}`,
|
|
headers,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'slot-leak',
|
|
signal: null,
|
|
timeoutMs: 2000,
|
|
url: `http://lan.example:${port}/ok`,
|
|
});
|
|
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
await assert.rejects(send({'x-bad': 'a\u0001b'}));
|
|
}
|
|
const message = await send(null);
|
|
|
|
assert.equal(message.status, 200);
|
|
message.message.resume();
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a request whose signal already aborted is refused instead of sent', async () => {
|
|
const seen = [];
|
|
const {port, server} = await startServer({
|
|
'/ok': (request, response) => {
|
|
seen.push(request.url);
|
|
sendImage(response, 'image/png', PNG_BYTES);
|
|
},
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'lan.example': ['127.0.0.1']}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: `http://lan.example:${port}`,
|
|
origins: [`http://lan.example:${port}`],
|
|
});
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
await assert.rejects(
|
|
outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: `http://lan.example:${port}`,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'aborted',
|
|
signal: controller.signal,
|
|
timeoutMs: 2000,
|
|
url: `http://lan.example:${port}/ok`,
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(seen, []);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a LAN self-hosted instance whose anchor resolves privately is allowed', async () => {
|
|
const {port, server} = await startServer({
|
|
'/media/ok': (_request, response) => sendImage(response, 'image/png', PNG_BYTES),
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'lan.example': ['127.0.0.1'], 'media.lan.example': ['127.0.0.1']}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: `http://lan.example:${port}`,
|
|
origins: [`http://media.lan.example:${port}`],
|
|
});
|
|
const message = await outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: `http://media.lan.example:${port}`,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 2000,
|
|
url: `http://media.lan.example:${port}/media/ok`,
|
|
});
|
|
assert.equal(message.status, 200);
|
|
message.message.resume();
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a streamed request body reaches the server for every method that carries one', async () => {
|
|
const received = [];
|
|
const {port, server} = await startServer({
|
|
'/api/v1/oauth2/applications/1': (request, response) => {
|
|
let body = '';
|
|
request.setEncoding('utf8');
|
|
request.on('data', (chunk) => {
|
|
body += chunk;
|
|
});
|
|
request.on('end', () => {
|
|
received.push({body, method: request.method, sudo: request.headers['x-fluxer-sudo-mode-jwt'] ?? null});
|
|
response.writeHead(204, {'x-fluxer-sudo-mode-jwt': 'granted'});
|
|
response.end();
|
|
});
|
|
},
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'lan.example': ['127.0.0.1']}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({anchorOrigin: `http://lan.example:${port}`, origins: []});
|
|
const payload = JSON.stringify({mfa_method: 'totp', mfa_code: '123456'});
|
|
for (const method of ['DELETE', 'POST', 'PATCH', 'PUT']) {
|
|
const message = await outboundHTTP.request({
|
|
body: Readable.toWeb(Readable.from([Buffer.from(payload)])),
|
|
expectedOrigin: `http://lan.example:${port}`,
|
|
headers: {'content-type': 'application/json', 'x-fluxer-sudo-mode-jwt': 'previous'},
|
|
method,
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 2000,
|
|
url: `http://lan.example:${port}/api/v1/oauth2/applications/1`,
|
|
});
|
|
assert.equal(message.status, 204);
|
|
assert.equal(message.headers['x-fluxer-sudo-mode-jwt'], 'granted');
|
|
message.message.resume();
|
|
}
|
|
assert.deepEqual(
|
|
received.map((entry) => entry.method),
|
|
['DELETE', 'POST', 'PATCH', 'PUT'],
|
|
);
|
|
for (const entry of received) {
|
|
assert.equal(entry.body, payload);
|
|
assert.equal(entry.sudo, 'previous');
|
|
}
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a private literal anchor binds and registers without a public requirement', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({resolveHostAddresses: stubResolver({})});
|
|
await outboundHTTP.registerAnchoredOrigins({anchorOrigin: 'http://192.168.1.10', origins: []});
|
|
assert.equal(outboundHTTP.isRegisteredOrigin('http://192.168.1.10'), true);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('http is refused for a publicly resolving origin and accepted for a loopback one', async () => {
|
|
const {port, server} = await startServer({
|
|
'/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'),
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'example.com': ['93.184.216.34'], localhost: ['127.0.0.1']}),
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
await assert.rejects(
|
|
client.fetch({expectedOrigin: 'http://example.com', timeoutMs: 500, url: 'http://example.com/ping'}),
|
|
/requires https for the publicly routable origin/,
|
|
);
|
|
const loopback = await client.fetch({
|
|
expectedOrigin: `http://localhost:${port}`,
|
|
timeoutMs: 2000,
|
|
url: `http://localhost:${port}/ping`,
|
|
});
|
|
assert.equal(loopback.status, 200);
|
|
const literal = await client.fetch({
|
|
expectedOrigin: `http://127.0.0.1:${port}`,
|
|
timeoutMs: 2000,
|
|
url: `http://127.0.0.1:${port}/ping`,
|
|
});
|
|
assert.equal(literal.status, 200);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('cleartext transport addresses are pinned for loopback and LAN origins and refused for public ones', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({
|
|
'gateway.example.com': ['93.184.216.34'],
|
|
'nas.lan': ['192.168.1.20'],
|
|
localhost: ['::1', '127.0.0.1'],
|
|
}),
|
|
});
|
|
assert.equal(await outboundHTTP.requireCleartextTransportAddress('http://localhost:8088'), '127.0.0.1');
|
|
assert.equal(await outboundHTTP.requireCleartextTransportAddress('http://nas.lan'), '192.168.1.20');
|
|
assert.equal(await outboundHTTP.requireCleartextTransportAddress('http://10.0.0.5:8080'), '10.0.0.5');
|
|
await assert.rejects(
|
|
outboundHTTP.requireCleartextTransportAddress('http://gateway.example.com'),
|
|
/requires https for the publicly routable origin/,
|
|
);
|
|
await assert.rejects(
|
|
outboundHTTP.requireCleartextTransportAddress('http://93.184.216.34'),
|
|
/requires https for the publicly routable origin/,
|
|
);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('a loopback host resolving to IPv6 first still reaches an IPv4 only server', async () => {
|
|
const {port, server} = await startServer({
|
|
'/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'),
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async () => ['::1', '127.0.0.1'],
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
const response = await client.fetch({
|
|
expectedOrigin: `http://localdev.example:${port}`,
|
|
timeoutMs: 2000,
|
|
url: `http://localdev.example:${port}/ping`,
|
|
});
|
|
assert.equal(
|
|
response.status,
|
|
200,
|
|
'macOS returns ::1 first for a loopback name and dev servers commonly bind IPv4 only, so pinning the first record makes every request fail with ECONNREFUSED',
|
|
);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('the pinned address survives a resolver that rebinds after the allow-list check', async () => {
|
|
const {port, server} = await startServer({
|
|
'/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'),
|
|
});
|
|
try {
|
|
let rebound = false;
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async () => {
|
|
if (rebound) {
|
|
return ['203.0.113.7'];
|
|
}
|
|
return ['127.0.0.1'];
|
|
},
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
const first = await client.fetch({
|
|
expectedOrigin: `http://rebind.example:${port}`,
|
|
timeoutMs: 2000,
|
|
url: `http://rebind.example:${port}/ping`,
|
|
});
|
|
assert.equal(first.status, 200);
|
|
rebound = true;
|
|
const second = await client.fetch({
|
|
expectedOrigin: `http://rebind.example:${port}`,
|
|
timeoutMs: 2000,
|
|
url: `http://rebind.example:${port}/ping`,
|
|
});
|
|
assert.equal(second.status, 200);
|
|
assert.equal(second.body.toString('utf8'), 'ok');
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a pinned address that stops answering is resolved again instead of staying pinned for the process lifetime', async () => {
|
|
const {port, server} = await startServer({});
|
|
await new Promise((resolve) => server.close(resolve));
|
|
let resolutions = 0;
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async () => {
|
|
resolutions += 1;
|
|
return ['127.0.0.1'];
|
|
},
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
const request = {
|
|
expectedOrigin: `http://moved.example:${port}`,
|
|
timeoutMs: 2000,
|
|
url: `http://moved.example:${port}/ping`,
|
|
};
|
|
await assert.rejects(client.fetch(request), /ECONNREFUSED/);
|
|
assert.equal(resolutions, 1);
|
|
const revived = http.createServer((_request, response) => sendImage(response, 'text/plain', 'ok'));
|
|
await new Promise((resolve) => revived.listen(port, '127.0.0.1', resolve));
|
|
try {
|
|
const response = await client.fetch(request);
|
|
assert.equal(response.status, 200);
|
|
assert.equal(
|
|
resolutions,
|
|
2,
|
|
'an address that stopped answering leaves the origin unreachable until the app restarts unless the failed binding is released',
|
|
);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
revived.close();
|
|
}
|
|
});
|
|
|
|
test('a released cleartext binding that re-resolves to a public address never sends cleartext there', async () => {
|
|
const {port, server} = await startServer({});
|
|
await new Promise((resolve) => server.close(resolve));
|
|
let rebound = false;
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async () => {
|
|
if (rebound) {
|
|
return ['93.184.216.34'];
|
|
}
|
|
return ['127.0.0.1'];
|
|
},
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
const request = {
|
|
expectedOrigin: `http://scope.example:${port}`,
|
|
timeoutMs: 2000,
|
|
url: `http://scope.example:${port}/ping`,
|
|
};
|
|
await assert.rejects(client.fetch(request), /ECONNREFUSED/);
|
|
rebound = true;
|
|
await assert.rejects(client.fetch(request), /requires https for the publicly routable origin/);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('a split-horizon origin that moves from its LAN address to a public one is resolved again instead of failing until restart', async () => {
|
|
const {port, server} = await startServer({});
|
|
await new Promise((resolve) => server.close(resolve));
|
|
let rebound = false;
|
|
let resolutions = 0;
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async () => {
|
|
resolutions += 1;
|
|
return rebound ? ['93.184.216.34'] : ['127.0.0.1'];
|
|
},
|
|
});
|
|
const request = (signal) => ({
|
|
body: null,
|
|
expectedOrigin: `https://home.example:${port}`,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.BOUND,
|
|
serviceName: 'test',
|
|
signal,
|
|
timeoutMs: 2000,
|
|
url: `https://home.example:${port}/ping`,
|
|
});
|
|
await assert.rejects(outboundHTTP.request(request(null)), /ECONNREFUSED/);
|
|
rebound = true;
|
|
const left = new AbortController();
|
|
left.abort();
|
|
await assert.rejects(outboundHTTP.request(request(left.signal)), /was aborted by its caller/);
|
|
assert.equal(resolutions, 2);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('an address that swallows connections is released when the request gives up before connecting', async () => {
|
|
let resolutions = 0;
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async () => {
|
|
resolutions += 1;
|
|
return ['10.255.255.1'];
|
|
},
|
|
});
|
|
const request = {
|
|
body: null,
|
|
expectedOrigin: 'http://blackhole.example:9',
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.BOUND,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 200,
|
|
url: 'http://blackhole.example:9/ping',
|
|
};
|
|
await assert.rejects(outboundHTTP.request(request));
|
|
await assert.rejects(outboundHTTP.request(request));
|
|
assert.equal(
|
|
resolutions,
|
|
2,
|
|
'a binding whose address never answers stays pinned for the process lifetime when a timeout does not release it',
|
|
);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
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': ['93.184.216.34', '127.0.0.1']}),
|
|
});
|
|
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();
|
|
}
|
|
});
|
|
|
|
test('a target on a different port than its declared origin is refused before any connection', async () => {
|
|
const declared = await startServer({'/ping': (_request, response) => sendImage(response, 'text/plain', 'ok')});
|
|
const other = await startServer({'/steal': (_request, response) => sendImage(response, 'text/plain', 'stolen')});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({resolveHostAddresses: stubResolver({})});
|
|
await assert.rejects(
|
|
outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: `http://127.0.0.1:${declared.port}`,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.BOUND,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 2000,
|
|
url: `http://127.0.0.1:${other.port}/steal`,
|
|
}),
|
|
/not a canonical URL under its declared origin/,
|
|
);
|
|
assert.deepEqual(other.requested, [], 'a target outside the declared origin must never be contacted');
|
|
assert.deepEqual(declared.requested, []);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
declared.server.close();
|
|
other.server.close();
|
|
}
|
|
});
|
|
|
|
test('a keepalive socket the peer closed between requests is retried exactly once', async () => {
|
|
let seen = 0;
|
|
const server = http.createServer((request, response) => {
|
|
seen += 1;
|
|
if (seen === 2) {
|
|
request.socket.destroy();
|
|
return;
|
|
}
|
|
response.writeHead(200, {'content-type': 'text/plain'});
|
|
response.end(`attempt-${seen}`);
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const {port} = server.address();
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({resolveHostAddresses: stubResolver({})});
|
|
const send = () =>
|
|
outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: `http://127.0.0.1:${port}`,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.BOUND,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 5000,
|
|
url: `http://127.0.0.1:${port}/ping`,
|
|
});
|
|
const readBody = async (message) =>
|
|
(
|
|
await readBoundedMessage({
|
|
declaredBytes: null,
|
|
description: 'test body',
|
|
maxBytes: 1024,
|
|
maxChunks: 16,
|
|
message: message.message,
|
|
})
|
|
).toString('utf8');
|
|
|
|
assert.equal(await readBody(await send()), 'attempt-1');
|
|
assert.equal(await readBody(await send()), 'attempt-3');
|
|
assert.equal(seen, 3);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a stale reused socket is not replayed for a request whose body cannot be replayed', async () => {
|
|
let seen = 0;
|
|
const server = http.createServer((request, response) => {
|
|
seen += 1;
|
|
if (seen === 2) {
|
|
request.socket.destroy();
|
|
return;
|
|
}
|
|
request.resume();
|
|
request.on('end', () => {
|
|
response.writeHead(200, {'content-type': 'text/plain'});
|
|
response.end(`attempt-${seen}`);
|
|
});
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const {port} = server.address();
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({resolveHostAddresses: stubResolver({})});
|
|
const send = (body) =>
|
|
outboundHTTP.request({
|
|
body,
|
|
expectedOrigin: `http://127.0.0.1:${port}`,
|
|
headers: {'content-type': 'text/plain'},
|
|
method: 'PUT',
|
|
originTrust: DesktopOriginTrust.BOUND,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 5000,
|
|
url: `http://127.0.0.1:${port}/upload`,
|
|
});
|
|
const streamBody = () => Readable.toWeb(Readable.from([Buffer.from('payload')]));
|
|
|
|
const first = await send(streamBody());
|
|
await readBoundedMessage({
|
|
declaredBytes: null,
|
|
description: 'test body',
|
|
maxBytes: 1024,
|
|
maxChunks: 16,
|
|
message: first.message,
|
|
});
|
|
await assert.rejects(send(streamBody()), (error) => error.code === 'ECONNRESET');
|
|
assert.equal(seen, 2, 'a streamed body must never be replayed onto a fresh socket');
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('the pinned lookup answers after its caller returns', async () => {
|
|
const lookup = createPinnedHostLookup('pinned.example', {address: '127.0.0.1', family: 4});
|
|
const order = [];
|
|
const answered = new Promise((resolve) =>
|
|
lookup('pinned.example', {all: true}, (error, addresses) => {
|
|
order.push('answered');
|
|
resolve({addresses, error});
|
|
}),
|
|
);
|
|
order.push('returned');
|
|
const {addresses, error} = await answered;
|
|
assert.deepEqual(order, ['returned', 'answered']);
|
|
assert.equal(error, null);
|
|
assert.deepEqual(addresses, [{address: '127.0.0.1', family: 4}]);
|
|
});
|
|
|
|
test('a pinned address with no route fails the request instead of the process', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'unrouted.example': ['255.255.255.255']}),
|
|
});
|
|
const uncaught = [];
|
|
const onUncaught = (error) => uncaught.push(error);
|
|
process.on('uncaughtException', onUncaught);
|
|
try {
|
|
await outboundHTTP.registerAnchoredOrigins({anchorOrigin: 'https://unrouted.example', origins: []});
|
|
await assert.rejects(
|
|
outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: 'https://unrouted.example',
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 2000,
|
|
url: 'https://unrouted.example/',
|
|
}),
|
|
(error) => typeof error.code === 'string' && error.syscall === 'connect',
|
|
);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(uncaught, []);
|
|
} finally {
|
|
process.off('uncaughtException', onUncaught);
|
|
outboundHTTP.cleanup();
|
|
}
|
|
});
|
|
|
|
test('the pinned lookup offers every validated address for happy eyeballs', async () => {
|
|
const lookup = createPinnedHostLookup('dual.example', [
|
|
{address: '2606:4700:4700::1111', family: 6},
|
|
{address: '93.184.216.34', family: 4},
|
|
]);
|
|
const all = await new Promise((resolve) =>
|
|
lookup('dual.example', {all: true}, (error, addresses) => resolve({addresses, error})),
|
|
);
|
|
assert.equal(all.error, null);
|
|
assert.deepEqual(all.addresses, [
|
|
{address: '2606:4700:4700::1111', family: 6},
|
|
{address: '93.184.216.34', family: 4},
|
|
]);
|
|
const single = await new Promise((resolve) =>
|
|
lookup('dual.example', {}, (error, address, family) => resolve({address, error, family})),
|
|
);
|
|
assert.equal(single.address, '2606:4700:4700::1111');
|
|
assert.equal(single.family, 6);
|
|
});
|
|
|
|
test('a registered origin whose IPv6 address refuses connects over IPv4 without waiting', async () => {
|
|
const {port, requested, server} = await startServer({
|
|
'/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'),
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'dual.example': ['::1', '127.0.0.1']}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({anchorOrigin: `http://dual.example:${port}`, origins: []});
|
|
const startedAt = Date.now();
|
|
const response = await outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: `http://dual.example:${port}`,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 2000,
|
|
url: `http://dual.example:${port}/ping`,
|
|
});
|
|
response.message.resume();
|
|
assert.equal(response.status, 200, 'the refused IPv6 address must fall back to the IPv4 one like Chromium does');
|
|
assert.ok(Date.now() - startedAt < 1000);
|
|
assert.deepEqual(requested, ['/ping']);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a registered origin whose first address never answers is reached on the next one', async () => {
|
|
const {port, requested, server} = await startServer({
|
|
'/ping': (_request, response) => sendImage(response, 'text/plain', 'ok'),
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'multi.example': ['127.0.0.2', '127.0.0.1']}),
|
|
});
|
|
await outboundHTTP.registerAnchoredOrigins({anchorOrigin: `http://multi.example:${port}`, origins: []});
|
|
const startedAt = Date.now();
|
|
const response = await outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: `http://multi.example:${port}`,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 3000,
|
|
url: `http://multi.example:${port}/ping`,
|
|
});
|
|
response.message.resume();
|
|
assert.equal(response.status, 200, 'a dead first address must not stop the connection from trying the others');
|
|
assert.ok(Date.now() - startedAt < 1500);
|
|
assert.deepEqual(requested, ['/ping']);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a public requirement still refuses an answer that includes a private address', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'mixed.example': ['2606:4700:4700::1111', '127.0.0.1']}),
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
await assert.rejects(
|
|
client.fetch({expectedOrigin: 'https://mixed.example', timeoutMs: 2000, url: 'https://mixed.example/ping'}),
|
|
);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('the pinned lookup refuses a hostname it was not created for', async () => {
|
|
const lookup = createPinnedHostLookup('pinned.example', {address: '127.0.0.1', family: 4});
|
|
const mismatch = await new Promise((resolve) => lookup('rebound.example', {}, resolve));
|
|
assert.equal(mismatch.name, 'PinnedLookupHostnameMismatchError');
|
|
const match = await new Promise((resolve) =>
|
|
lookup('PINNED.example.', {}, (error, address) => resolve({address, error})),
|
|
);
|
|
assert.equal(match.error, null);
|
|
assert.equal(match.address, '127.0.0.1');
|
|
});
|
|
});
|
|
|
|
describe('DesktopSelectedInstanceClient', () => {
|
|
test('a redirect on the instance path is refused instead of followed', async () => {
|
|
const {port, requested, server} = await startServer({
|
|
'/api/v1/auth/login': (_request, response) => {
|
|
response.writeHead(302, {location: '/api/v1/elsewhere'});
|
|
response.end();
|
|
},
|
|
'/api/v1/elsewhere': (_request, response) => sendImage(response, 'text/plain', 'followed'),
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'self.example': ['127.0.0.1']}),
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
await assert.rejects(
|
|
client.fetch({
|
|
expectedOrigin: `http://self.example:${port}`,
|
|
method: 'POST',
|
|
timeoutMs: 2000,
|
|
url: `http://self.example:${port}/api/v1/auth/login`,
|
|
}),
|
|
/was answered with an HTTP 302 redirect, which is never followed/,
|
|
);
|
|
assert.deepEqual(requested, ['/api/v1/auth/login']);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
test('a foreign-origin URL throws', async () => {
|
|
const outboundHTTP = new DesktopOutboundHTTP({resolveHostAddresses: stubResolver({'self.example': ['127.0.0.1']})});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
await assert.rejects(
|
|
client.fetch({
|
|
expectedOrigin: 'http://self.example',
|
|
timeoutMs: 500,
|
|
url: 'http://other.example/api/v1/users/@me',
|
|
}),
|
|
/is not a canonical URL under its declared origin/,
|
|
);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('a response beyond the one mebibyte bound aborts', async () => {
|
|
const {port, server} = await startServer({
|
|
'/big': (_request, response) => {
|
|
response.writeHead(200, {'content-type': 'application/json'});
|
|
const chunk = Buffer.alloc(256 * 1024, 0x61);
|
|
for (let index = 0; index < 8; index += 1) {
|
|
response.write(chunk);
|
|
}
|
|
response.end();
|
|
},
|
|
});
|
|
try {
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: stubResolver({'self.example': ['127.0.0.1']}),
|
|
});
|
|
const client = new DesktopSelectedInstanceClient(outboundHTTP);
|
|
await assert.rejects(
|
|
client.fetch({
|
|
expectedOrigin: `http://self.example:${port}`,
|
|
timeoutMs: 5000,
|
|
url: `http://self.example:${port}/big`,
|
|
}),
|
|
/Instance response exceeds 1048576 bytes/,
|
|
);
|
|
outboundHTTP.cleanup();
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('DesktopOutboundHTTP system proxy routes', () => {
|
|
function proxiedOutboundHTTP() {
|
|
const sent = [];
|
|
const resolved = [];
|
|
const outboundHTTP = new DesktopOutboundHTTP({
|
|
resolveHostAddresses: async (hostname) => {
|
|
resolved.push(hostname);
|
|
throw Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), {syscall: 'getaddrinfo'});
|
|
},
|
|
resolveProxy: async (url) =>
|
|
url.startsWith('https://direct.example') ? 'DIRECT' : 'PROXY 127.0.0.1:7890; DIRECT',
|
|
sendThroughSession: async (request) => {
|
|
sent.push({method: request.method, url: request.url.href, headers: request.headers});
|
|
return {
|
|
headers: {'content-type': 'image/png'},
|
|
message: Readable.from([PNG_BYTES]),
|
|
status: 200,
|
|
statusText: 'OK',
|
|
};
|
|
},
|
|
});
|
|
return {outboundHTTP, resolved, sent};
|
|
}
|
|
|
|
test('an instance behind the system proxy is anchored and fetched without a local lookup', async () => {
|
|
const {outboundHTTP, resolved, sent} = proxiedOutboundHTTP();
|
|
const requirement = await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: 'https://api.blocked.example',
|
|
origins: ['https://media.blocked.example'],
|
|
});
|
|
assert.equal(requirement, 'public');
|
|
const message = await outboundHTTP.request({
|
|
body: null,
|
|
expectedOrigin: 'https://media.blocked.example',
|
|
headers: {Accept: 'image/png'},
|
|
method: 'GET',
|
|
originTrust: DesktopOriginTrust.REGISTERED,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 500,
|
|
url: 'https://media.blocked.example/avatar.png',
|
|
});
|
|
assert.deepEqual(
|
|
await readBoundedMessage({
|
|
declaredBytes: null,
|
|
description: 'proxied',
|
|
maxBytes: 64,
|
|
maxChunks: 4,
|
|
message: message.message,
|
|
}),
|
|
PNG_BYTES,
|
|
);
|
|
assert.deepEqual(resolved, []);
|
|
assert.deepEqual(sent, [
|
|
{method: 'GET', url: 'https://media.blocked.example/avatar.png', headers: {Accept: 'image/png'}},
|
|
]);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('a proxied download goes through the session and a direct one keeps the pinned lookup', async () => {
|
|
const {outboundHTTP, resolved, sent} = proxiedOutboundHTTP();
|
|
const message = await outboundHTTP.get({
|
|
context: 'test',
|
|
timeoutMs: 500,
|
|
url: new URL('https://cdn.blocked.example/icon.png'),
|
|
});
|
|
message.message.resume();
|
|
await assert.rejects(
|
|
outboundHTTP.get({context: 'test', timeoutMs: 500, url: new URL('https://direct.example/icon.png')}),
|
|
/could not be reached/,
|
|
);
|
|
assert.deepEqual(
|
|
sent.map((entry) => entry.url),
|
|
['https://cdn.blocked.example/icon.png'],
|
|
);
|
|
assert.deepEqual(resolved, ['direct.example']);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
|
|
test('a proxied route keeps the cleartext and private address rules', async () => {
|
|
const {outboundHTTP, sent} = proxiedOutboundHTTP();
|
|
await outboundHTTP.registerAnchoredOrigins({
|
|
anchorOrigin: 'https://api.blocked.example',
|
|
origins: ['http://10.0.0.1'],
|
|
});
|
|
const request = (url, originTrust = DesktopOriginTrust.BOUND) => ({
|
|
body: null,
|
|
expectedOrigin: new URL(url).origin,
|
|
headers: null,
|
|
method: 'GET',
|
|
originTrust,
|
|
serviceName: 'test',
|
|
signal: null,
|
|
timeoutMs: 500,
|
|
url,
|
|
});
|
|
await assert.rejects(outboundHTTP.request(request('http://plain.blocked.example/x')), /requires https/);
|
|
await assert.rejects(
|
|
outboundHTTP.request(request('http://10.0.0.1/x', DesktopOriginTrust.REGISTERED)),
|
|
/cannot authorize the privately resolving origin/,
|
|
);
|
|
await assert.rejects(
|
|
outboundHTTP.get({context: 'test', timeoutMs: 500, url: new URL('https://10.0.0.1/x')}),
|
|
/could not be reached/,
|
|
);
|
|
assert.deepEqual(sent, []);
|
|
outboundHTTP.cleanup();
|
|
});
|
|
});
|
|
|
|
describe('desktop instance launch surfaces', () => {
|
|
test('--fluxer-instance is a development-only API endpoint flag', () => {
|
|
const originalDefaultApp = process.defaultApp;
|
|
try {
|
|
process.defaultApp = undefined;
|
|
assert.equal(getLaunchInstanceEndpointOverride(['electron', '.']), null);
|
|
assert.throws(
|
|
() => getLaunchInstanceEndpointOverride(['--fluxer-instance', 'https://self.example/api']),
|
|
/only accepted in a development build/,
|
|
);
|
|
process.defaultApp = true;
|
|
assert.equal(
|
|
getLaunchInstanceEndpointOverride(['--fluxer-instance', 'https://self.example/api/']),
|
|
'https://self.example/api',
|
|
);
|
|
assert.equal(
|
|
getLaunchInstanceEndpointOverride(['--fluxer-instance=https://self.example:8443/api']),
|
|
'https://self.example:8443/api',
|
|
);
|
|
assert.throws(
|
|
() => getLaunchInstanceEndpointOverride(['--fluxer-instance', '/api']),
|
|
/absolute http\(s\) API endpoint/,
|
|
);
|
|
assert.throws(() => getLaunchInstanceEndpointOverride(['--fluxer-instance']), /absolute http\(s\) API endpoint/);
|
|
} finally {
|
|
process.defaultApp = originalDefaultApp;
|
|
}
|
|
});
|
|
});
|