Files
fluxer/fluxer_desktop/src/main/DesktopOutboundHTTP.test.mjs
T

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;
}
});
});