mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 09:05:01 +00:00
A response that closed without finishing never settled, which left the update lock taken until Haven restarted.
171 lines
7.0 KiB
JavaScript
171 lines
7.0 KiB
JavaScript
'use strict';
|
|
|
|
// Exercise the downloader's redirect and DNS boundaries without contacting
|
|
// GitHub or changing the process-wide HTTPS/DNS modules used by other tests.
|
|
const assert = require('node:assert/strict');
|
|
const { EventEmitter } = require('node:events');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const vm = require('node:vm');
|
|
|
|
const SOURCE = fs.readFileSync(path.join(__dirname, '../src/extensionUpdates.js'), 'utf8');
|
|
const BLOCKLIST_URL = 'https://ancsemi.github.io/Haven/blocklist.json';
|
|
const CANONICAL_URL = 'https://haven-app.com/blocklist.json';
|
|
|
|
function createDownloadFixture(responses, addresses = {}) {
|
|
const requests = [];
|
|
const lookups = [];
|
|
const timers = new Map();
|
|
let clock = 0;
|
|
let nextTimer = 0;
|
|
const https = {
|
|
get(url, options, onResponse) {
|
|
const req = new EventEmitter();
|
|
req.destroy = error => queueMicrotask(() => req.emit('error', error));
|
|
requests.push({ url: url.href, options });
|
|
// Real HTTPS reports responses and errors after returning its request.
|
|
queueMicrotask(() => {
|
|
options.lookup(url.hostname, {}, (error, address, family) => {
|
|
if (error) return req.destroy(error);
|
|
const response = responses[url.href];
|
|
assert.ok(response, 'Unexpected request: ' + url.href);
|
|
assert.equal(address, addresses[url.hostname] || '93.184.216.34');
|
|
assert.equal(family, 4);
|
|
clock += response.elapsed || 0;
|
|
if (response.hang) return;
|
|
const res = new EventEmitter();
|
|
res.statusCode = response.status || 200;
|
|
res.headers = response.headers || {};
|
|
let destroyed = false;
|
|
res.destroy = error => {
|
|
destroyed = true;
|
|
queueMicrotask(() => {
|
|
if (error) res.emit('error', error);
|
|
res.emit('close');
|
|
});
|
|
};
|
|
onResponse(res);
|
|
queueMicrotask(() => {
|
|
for (const chunk of response.chunks || []) {
|
|
if (destroyed) return;
|
|
res.emit('data', Buffer.from(chunk));
|
|
}
|
|
if (!destroyed) {
|
|
// A connection reset closes the response without an 'end'.
|
|
if (!response.closeWithoutEnd) res.emit('end');
|
|
res.emit('close');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
return req;
|
|
},
|
|
};
|
|
const dns = {
|
|
lookup(hostname, options, callback) {
|
|
lookups.push({ hostname, options });
|
|
callback(null, addresses[hostname] || '93.184.216.34', 4);
|
|
},
|
|
};
|
|
const context = vm.createContext({
|
|
module: { exports: {} }, exports: {}, Buffer, URL,
|
|
require: name => name === 'node:https' ? https : name === 'node:dns' ? dns : require(name),
|
|
Date: { now: () => clock },
|
|
setTimeout(handler, duration) {
|
|
const id = ++nextTimer;
|
|
timers.set(id, { handler, duration });
|
|
return id;
|
|
},
|
|
clearTimeout: id => timers.delete(id),
|
|
});
|
|
vm.runInContext(SOURCE, context, { filename: 'extensionUpdates.js' });
|
|
return { download: context.module.exports.downloadExtensionAsset, requests, lookups, timers };
|
|
}
|
|
|
|
test('the blocklist follows the GitHub Pages redirect to its exact public canonical URL', async () => {
|
|
const json = JSON.stringify({ schemaVersion: 1, updatedAt: '2026-10-03T00:00:00Z', blocked: [] });
|
|
const fixture = createDownloadFixture({
|
|
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL } },
|
|
[CANONICAL_URL]: { chunks: [json.slice(0, 20), json.slice(20)] },
|
|
});
|
|
const bytes = await fixture.download(BLOCKLIST_URL, 1024);
|
|
|
|
assert.deepEqual(JSON.parse(bytes.toString()), JSON.parse(json));
|
|
assert.deepEqual(fixture.requests.map(request => request.url), [BLOCKLIST_URL, CANONICAL_URL]);
|
|
assert.deepEqual(fixture.lookups.map(lookup => lookup.hostname), ['ancsemi.github.io', 'haven-app.com']);
|
|
assert.ok(fixture.lookups.every(lookup => lookup.options.family === 4));
|
|
assert.ok(fixture.requests.every(request => request.options.headers['Cache-Control'] === 'no-cache'));
|
|
assert.equal(fixture.timers.size, 0);
|
|
});
|
|
|
|
test('a blocklist redirect cannot authorize another host, path or URL component', async () => {
|
|
for (const destination of [
|
|
'https://unapproved.example/blocklist.json',
|
|
'https://haven-app.com/another.json',
|
|
CANONICAL_URL + '?source=redirect',
|
|
CANONICAL_URL + '#fragment',
|
|
'https://user:password@haven-app.com/blocklist.json',
|
|
'http://haven-app.com/blocklist.json',
|
|
'https://haven-app.com:444/blocklist.json',
|
|
]) {
|
|
const fixture = createDownloadFixture({
|
|
[BLOCKLIST_URL]: { status: 301, headers: { location: destination } },
|
|
});
|
|
await assert.rejects(fixture.download(BLOCKLIST_URL, 1024), /Unsafe download URL|Unapproved download host/);
|
|
assert.equal(fixture.requests.length, 1, 'unsafe redirect is rejected before a connection');
|
|
assert.equal(fixture.timers.size, 0);
|
|
}
|
|
});
|
|
|
|
test('an approved redirect still rejects a private DNS result', async () => {
|
|
const fixture = createDownloadFixture({
|
|
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL } },
|
|
[CANONICAL_URL]: { chunks: ['should not be read'] },
|
|
}, { 'haven-app.com': '127.0.0.1' });
|
|
|
|
await assert.rejects(fixture.download(BLOCKLIST_URL, 1024), /non-public address/);
|
|
assert.deepEqual(fixture.lookups.map(lookup => lookup.hostname), ['ancsemi.github.io', 'haven-app.com']);
|
|
assert.equal(fixture.timers.size, 0);
|
|
});
|
|
|
|
test('the redirected blocklist retains declared and streamed size limits', async () => {
|
|
for (const response of [
|
|
{ headers: { 'content-length': '6' }, chunks: ['123456'] },
|
|
{ chunks: ['1234', '56'] },
|
|
]) {
|
|
const fixture = createDownloadFixture({
|
|
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL } },
|
|
[CANONICAL_URL]: response,
|
|
});
|
|
await assert.rejects(fixture.download(BLOCKLIST_URL, 5), /size limit/);
|
|
assert.equal(fixture.timers.size, 0);
|
|
}
|
|
});
|
|
|
|
test('redirects share the original deadline instead of restarting the timeout', async () => {
|
|
const fixture = createDownloadFixture({
|
|
[BLOCKLIST_URL]: { status: 301, headers: { location: CANONICAL_URL }, elapsed: 15 },
|
|
[CANONICAL_URL]: { hang: true },
|
|
});
|
|
const pending = fixture.download(BLOCKLIST_URL, 1024, 0, 20);
|
|
const rejected = assert.rejects(pending, /timed out/);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
assert.equal(fixture.requests.length, 2);
|
|
assert.equal(fixture.timers.size, 1);
|
|
const timer = [...fixture.timers.values()][0];
|
|
assert.equal(timer.duration, 5, 'only the time remaining before the original deadline is available');
|
|
timer.handler();
|
|
await rejected;
|
|
assert.equal(fixture.timers.size, 0);
|
|
});
|
|
|
|
test('a response that closes before it finishes rejects instead of hanging', async () => {
|
|
const fixture = createDownloadFixture({
|
|
[CANONICAL_URL]: { chunks: ['{"schema'], closeWithoutEnd: true },
|
|
});
|
|
await assert.rejects(fixture.download(CANONICAL_URL, 1024), /ended before it finished/);
|
|
assert.equal(fixture.timers.size, 0);
|
|
});
|