Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ A few of these are worth knowing about before they fail:
JSDoc, with no build step and nothing emitted. It has to stay clean. A new
module needs two entries in `tsconfig.frontend.json`, the plain path and the
`?v=*` form, because TypeScript allows one wildcard per pattern.
- **Test code is linted and formatted** along with everything else, with a few
rules relaxed for `api/test`, `api/test-support` and `ui/test` in `biome.json`:
a stub whose signature has to match a real one keeps its unused parameter, a
scanner keeps `while ((m = re.exec(s)))`, and `'use strict'` stays where a test
depends on strict-mode semantics.

They are **not** typechecked. `checkJs` over `api/test` reports 131 errors,
almost all of them narrowing around Node's `server.address()` in test
harnesses rather than defects. Silencing them means JSDoc casts across roughly
forty files for no behaviour change, so the exclusion is deliberate; revisit it
as its own piece of work, not as a side effect of something else.

- **`npm run lint`** is Biome, from `node_modules`. Run it through npm: a bare
`npx biome` resolves to an unrelated package that exits 0 without checking
anything.
Expand Down
6 changes: 5 additions & 1 deletion api/test-support/tmp.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ function arm() {
armed = true;
process.on('exit', () => {
for (const dir of created) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* best effort */
}
}
});
}
Expand Down
4 changes: 3 additions & 1 deletion api/test-support/widget-ctx.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
const { KIND, WidgetError } = require('../src/api-error');

const errorParts = () => ({
fail: (message, opts) => { throw new WidgetError(message, opts); },
fail: (message, opts) => {
throw new WidgetError(message, opts);
},
KIND,
});

Expand Down
105 changes: 77 additions & 28 deletions api/test/api-error-integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const path = require('node:path');
const fs = require('node:fs');

process.env.ALLOW_PRIVATE_IPS = 'true';
const { tmpDir, tmpPath } = require('../test-support/tmp');
const { tmpDir } = require('../test-support/tmp');
const _tmp = tmpDir('apierr');
process.env.CONFIG_PATH = path.join(_tmp, 'apps.json');
process.env.WIDGETS_PATH = path.join(_tmp, 'widgets');
Expand All @@ -28,7 +28,8 @@ const { KIND } = require('../src/api-error');
const SECRET = 'b'.repeat(64);
let server, base, cookie;
let upstream, upstreamBase;
let upstreamStatus = 200, upstreamBody = '{"count":3}';
let upstreamStatus = 200,
upstreamBody = '{"count":3}';

before(async () => {
const passwordHash = await hashPassword('correct-horse');
Expand All @@ -49,27 +50,47 @@ before(async () => {
});

after(async () => {
await new Promise(r => { server.closeAllConnections?.(); server.close(r); });
await new Promise(r => { upstream.closeAllConnections?.(); upstream.close(r); });
await new Promise(r => {
server.closeAllConnections?.();
server.close(r);
});
await new Promise(r => {
upstream.closeAllConnections?.();
upstream.close(r);
});
});

function post(pathname, body, opts = {}) {
const data = JSON.stringify(body);
const u = new URL(base + pathname);
return new Promise((resolve, reject) => {
const r = http.request({
hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
headers: Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
Origin: base,
Cookie: opts.cookie === null ? '' : (opts.cookie || cookie),
}),
}, res => {
let b = '';
res.on('data', c => { b += c; });
res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); });
});
const r = http.request(
{
hostname: u.hostname,
port: u.port,
path: u.pathname,
method: 'POST',
headers: Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
Origin: base,
Cookie: opts.cookie === null ? '' : opts.cookie || cookie,
}),
},
res => {
let b = '';
res.on('data', c => {
b += c;
});
res.on('end', () => {
let j = null;
try {
j = JSON.parse(b);
} catch {}
resolve({ status: res.statusCode, body: j });
});
},
);
r.on('error', reject);
r.end(data);
});
Expand All @@ -89,11 +110,29 @@ test('a cross-origin write keeps its 403 and gains a kind', async () => {
const data = JSON.stringify({ url: upstreamBase });
const u = new URL(base + '/api/ping');
const r = await new Promise((resolve, reject) => {
const q = http.request({
hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Origin: 'http://evil.example', Cookie: cookie },
}, res => { let b = ''; res.on('data', c => { b += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) })); });
q.on('error', reject); q.end(data);
const q = http.request(
{
hostname: u.hostname,
port: u.port,
path: u.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
Origin: 'http://evil.example',
Cookie: cookie,
},
},
res => {
let b = '';
res.on('data', c => {
b += c;
});
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(b) }));
},
);
q.on('error', reject);
q.end(data);
});
assert.equal(r.status, 403);
assert.equal(r.body.kind, KIND.INVALID);
Expand All @@ -102,22 +141,25 @@ test('a cross-origin write keeps its 403 and gains a kind', async () => {
/* ── badge-proxy ──────────────────────────────────────────────────────────── */

test('badge-proxy reports an upstream 401 as a failure, not a bare success', async () => {
upstreamStatus = 401; upstreamBody = '{"detail":"missing token"}';
upstreamStatus = 401;
upstreamBody = '{"detail":"missing token"}';
const r = await post('/api/badge-proxy', { url: upstreamBase });
assert.equal(r.status, 502);
assert.equal(r.body.kind, KIND.UPSTREAM);
assert.equal(r.body.detail.status, 401);
});

test('badge-proxy does not leak the upstream response body into detail', async () => {
upstreamStatus = 403; upstreamBody = '{"secret":"hunter2"}';
upstreamStatus = 403;
upstreamBody = '{"secret":"hunter2"}';
const r = await post('/api/badge-proxy', { url: upstreamBase });
assert.deepEqual(Object.keys(r.body.detail), ['status'], 'detail is server-derived only');
assert.ok(!JSON.stringify(r.body).includes('hunter2'));
});

test('badge-proxy still succeeds on a 200', async () => {
upstreamStatus = 200; upstreamBody = '{"count":3}';
upstreamStatus = 200;
upstreamBody = '{"count":3}';
const r = await post('/api/badge-proxy', { url: upstreamBase });
assert.equal(r.status, 200);
assert.ok(!('kind' in r.body), 'a success carries no kind');
Expand All @@ -127,7 +169,8 @@ test('badge-proxy still succeeds on a 200', async () => {
/* The counterpart to /api/badges no longer sending the body (P4-3): this is the
endpoint the admin field picker uses, and it is the one that must keep it. */
test('badge-proxy still returns the upstream body for the field picker', async () => {
upstreamStatus = 200; upstreamBody = '{"count":3,"nested":{"other":9}}';
upstreamStatus = 200;
upstreamBody = '{"count":3,"nested":{"other":9}}';
const r = await post('/api/badge-proxy', { url: upstreamBase });
assert.deepEqual(r.body.data, { count: 3, nested: { other: 9 } });
assert.ok(r.body.numbers.some(n => n.path === 'count' && n.value === 3));
Expand All @@ -136,7 +179,10 @@ test('badge-proxy still returns the upstream body for the field picker', async (
test('badge-proxy tags an unreachable target as a network failure', async () => {
const dead = await new Promise(res => {
const s = http.createServer(() => {});
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
s.listen(0, '127.0.0.1', () => {
const p = s.address().port;
s.close(() => res(p));
});
});
const r = await post('/api/badge-proxy', { url: `http://127.0.0.1:${dead}` });
assert.equal(r.status, 502);
Expand All @@ -155,7 +201,10 @@ test('badge-proxy tags a missing url as invalid', async () => {
test('ping keeps its ok:false shape and gains a kind', async () => {
const dead = await new Promise(res => {
const s = http.createServer(() => {});
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
s.listen(0, '127.0.0.1', () => {
const p = s.address().port;
s.close(() => res(p));
});
});
const r = await post('/api/ping', { url: `http://127.0.0.1:${dead}` });
assert.equal(r.status, 200);
Expand Down
10 changes: 7 additions & 3 deletions api/test/api-error.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const { test } = require('node:test');
const assert = require('node:assert/strict');
const { KIND, ApiError, classify, errorBody } = require('../src/api-error');


/* ── classify ─────────────────────────────────────────────────────────────── */

test('a socket error is classified as network and carries its errno', () => {
Expand Down Expand Up @@ -46,7 +45,11 @@ test('an SSRF block is classified as blocked, by name not instanceof', () => {

test('a malformed request body is classified as invalid', () => {
let thrown;
try { JSON.parse('{nope'); } catch (e) { thrown = e; }
try {
JSON.parse('{nope');
} catch (e) {
thrown = e;
}
assert.equal(classify(thrown).kind, KIND.INVALID);
});

Expand All @@ -59,7 +62,8 @@ test('an unrecognised failure falls back to internal, it does not throw', () =>

test('an explicit kind on the error wins over inference', () => {
const e = Object.assign(new Error('connect ECONNREFUSED 1.2.3.4:80'), {
code: 'ECONNREFUSED', kind: KIND.BLOCKED,
code: 'ECONNREFUSED',
kind: KIND.BLOCKED,
});
assert.equal(classify(e).kind, KIND.BLOCKED);
});
Expand Down
86 changes: 59 additions & 27 deletions api/test/auth-hardening.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

const path = require('node:path');

const { tmpDir, tmpPath } = require('../test-support/tmp');
const { tmpDir } = require('../test-support/tmp');
const _tmp = tmpDir('auth');
process.env.CONFIG_PATH = path.join(_tmp, 'apps.json');

Expand Down Expand Up @@ -44,10 +44,10 @@ test('a wrong password does not verify', async () => {
test('a malformed stored hash resolves false instead of crashing', async () => {
const cases = [
'somesalt:not-valid-hex',
'somesalt:' + 'a'.repeat(127), /* odd length, one byte short after decode */
'somesalt:' + 'a'.repeat(130), /* too long */
'somesalt:' + 'a'.repeat(127) /* odd length, one byte short after decode */,
'somesalt:' + 'a'.repeat(130) /* too long */,
'somesalt:',
'zz:' + 'a'.repeat(128), /* non-hex salt */
'zz:' + 'a'.repeat(128) /* non-hex salt */,
':' + 'a'.repeat(128),
'nocolon',
'',
Expand Down Expand Up @@ -89,9 +89,16 @@ before(async () => {
base = `http://127.0.0.1:${server.address().port}`;
});

after(async () => { await new Promise(r => { server.closeAllConnections?.(); server.close(r); }); });
after(async () => {
await new Promise(r => {
server.closeAllConnections?.();
server.close(r);
});
});

beforeEach(() => { saveConfig({ items: [], settings: {} }); });
beforeEach(() => {
saveConfig({ items: [], settings: {} });
});

/* Once auth is enabled the config route requires a session, so these tests must
send one. Without it the write 401s and the assertions below pass for the
Expand All @@ -105,19 +112,33 @@ function post(pathname, body, opts = {}) {
const data = JSON.stringify(body);
const u = new URL(base + pathname);
return new Promise((resolve, reject) => {
const r = http.request({
hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
Origin: base,
Cookie: opts.cookie || '',
const r = http.request(
{
hostname: u.hostname,
port: u.port,
path: u.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
Origin: base,
Cookie: opts.cookie || '',
},
},
}, res => {
let b = '';
res.on('data', c => { b += c; });
res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, body: j }); });
});
res => {
let b = '';
res.on('data', c => {
b += c;
});
res.on('end', () => {
let j = null;
try {
j = JSON.parse(b);
} catch {}
resolve({ status: res.statusCode, body: j });
});
},
);
r.on('error', reject);
r.end(data);
});
Expand All @@ -138,10 +159,14 @@ test('a config write cannot replace an existing password hash', async () => {
cfg.settings.auth = { enabled: true, passwordHash: await hashPassword('correct-horse'), secret: 'real-secret' };
saveConfig(cfg);

const r = await post('/api/config', {
items: [],
settings: { auth: { enabled: false, passwordHash: 'x:zz', secret: 'attacker-chosen' } },
}, { cookie: sessionCookie() });
const r = await post(
'/api/config',
{
items: [],
settings: { auth: { enabled: false, passwordHash: 'x:zz', secret: 'attacker-chosen' } },
},
{ cookie: sessionCookie() },
);
assert.equal(r.status, 200, 'the write must actually be accepted, or this proves nothing');

const after = loadConfig().settings.auth;
Expand All @@ -157,16 +182,23 @@ test('a config write cannot add a new field to settings.auth', async () => {
cfg.settings.auth = { enabled: true, secret: 'real-secret' };
saveConfig(cfg);

const r = await post('/api/config', {
items: [],
settings: { auth: { enabled: true, secret: 'real-secret', somethingNew: 'injected' } },
}, { cookie: sessionCookie() });
const r = await post(
'/api/config',
{
items: [],
settings: { auth: { enabled: true, secret: 'real-secret', somethingNew: 'injected' } },
},
{ cookie: sessionCookie() },
);
assert.equal(r.status, 200, 'the write must actually be accepted, or this proves nothing');
assert.ok(!('somethingNew' in loadConfig().settings.auth));
});

test('an ordinary config write is unaffected', async () => {
const r = await post('/api/config', { items: [{ id: 'a1', type: 'app', name: 'App' }], settings: { language: 'en' } });
const r = await post('/api/config', {
items: [{ id: 'a1', type: 'app', name: 'App' }],
settings: { language: 'en' },
});
assert.equal(r.status, 200);
const cfg = loadConfig();
assert.equal(cfg.settings.language, 'en');
Expand Down
Loading
Loading