Harden the server and add a Links page

Security headers, cross-site write refusal, atomic JSON writes, admin backup download, /healthz and Docker health check, OnlyOffice requires a JWT secret, npm test, CI audit, arm64 hub image. New Links tab for personal bookmarks, saved with per-person settings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:43:34 -04:00
1 parent b0d3aec811
commit 1f8c9493b7
16 files changed
+147 -25

No files matched your search

+14
View File
@@ -60,6 +60,20 @@ const wait = ms => new Promise(r => setTimeout(r, ms));
ok('the app page links its code with the current build number, so a cached old copy can never be used', home.includes(`/app.js?v=${cfg2.build}`) && home.includes(`/style.css?v=${cfg2.build}`), home.match(/app\.js[^"]*/));
const login = await (await fetch(base + '/login')).text(); ok('so does the sign-in page', login.includes(`/login.js?v=${cfg2.build}`), login.match(/login\.js[^"]*/));
const va = await fetch(base + `/app.js?v=${cfg2.build}`, { headers: { cookie } }); ok('the versioned address serves the file', va.status === 200 && (await va.text()).includes('addPersonSheet'), va.status);
// hardening: headers, cross-site writes, health check, backup, links setting
const h1 = await fetch(base + '/login'); await h1.text();
ok('responses carry security headers', h1.headers.get('x-content-type-options') === 'nosniff' && /frame-ancestors 'self'/.test(h1.headers.get('content-security-policy') || ''), [...h1.headers]);
const hz = await fetch(base + '/healthz'); ok('/healthz answers without signing in', hz.status === 200 && (await hz.json()).ok === true, hz.status);
const cs = await fetch(base + '/api/tasks/lists', { method: 'POST', headers: { cookie, origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' }); await cs.text();
ok('a write coming from another website is refused', cs.status === 403, cs.status);
const cs2 = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, 'sec-fetch-site': 'cross-site', 'content-type': 'application/json' }, body: '{"key":"links","value":[]}' }); await cs2.text();
ok('and so is one the browser marks cross-site', cs2.status === 403, cs2.status);
const sl = await fetch(base + '/api/settings', { method: 'PUT', headers: { cookie, origin: base, 'content-type': 'application/json' }, body: JSON.stringify({ key: 'links', value: [{ id: 'a', title: 'T', url: 'https://example.com', group: '' }] }) }); await sl.text();
const gl = await (await fetch(base + '/api/settings', { headers: { cookie } })).json();
ok('links are saved with the settings and read back', sl.status === 200 && gl.values.links && gl.values.links[0].url === 'https://example.com', [sl.status, gl]);
const bk = await fetch(base + '/api/backup', { headers: { cookie } }); const bb = Buffer.from(await bk.arrayBuffer());
ok('an admin can download a backup (a gzip file)', bk.status === 200 && bb[0] === 0x1f && bb[1] === 0x8b, [bk.status, bb.length]);
const bn = await fetch(base + '/api/backup'); await bn.text(); ok('but not without signing in', bn.status === 401, bn.status);
ok('the server is still running after all of that', alive);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
if (failed) { console.log('\n--- server log ---\n' + log.split('\n').filter(l => /error|Error|not defined/.test(l)).slice(0, 12).join('\n')); }