Files
redefined-designs/backend/tests/integration/uploadServing.integration.test.ts
T
bermudalamb cf1680dbfb feat(security): stop the app origin serving anything it does not recognise, and make the uploads origin configurable (#103)
The uploads directory is the only place in this application where content someone else authored is served over HTTP. #95 stopped a dangerous file being stored; this stops a stored file doing damage if one ever gets there anyway — through a gap, a path added later, a restore, or a file written before that validation existed.

Two halves, complementary rather than alternative.

The app's own origin now serves uploads defensively. An allowlist of the three extensions the upload path can produce, so a `.html` or a `.svg` on disk is simply not a file this application hands out — 404, the same answer as a file that is not there, so the response cannot be used to learn which paths exist. An allowlist rather than a denylist because a denylist has to anticipate every type a browser might execute, which is a moving target across browsers and years, while this only has to know three. The content type is stated explicitly from that same list rather than sniffed or guessed from a name someone else chose, paired with `nosniff`. `default-src 'none'; sandbox` gives a directly-navigated file no capabilities at all, which is the only way one of these can do harm — an `<img>` embed does not execute script. Writes get 405 rather than falling through to a 404 that suggests the path is wrong.

The other half is the separate origin, which is the real fix, because the origin is the whole unit of trust in a browser. That needs a hostname and a certificate, which live outside this repository, so what is here is the switch: `UPLOADS_BASE_URL`, sent to the frontend at runtime through `/api/config` and joined onto stored paths by `uploadUrl`. Empty means the app's own origin, which is the default and what local development has, so nothing changes until it is pointed somewhere.

Stored paths stay site-relative. A stored value outlives any hostname baked into it, and rewriting them would be a migration to undo the day the hostname changes.

Runtime rather than built in, so one image serves every environment — the same reason `paypalClientId` and `demoMode` are already there. `UPLOADS_BASE_URL` has a line in `docker-compose.prod.yml` while still empty, deliberately: a Portainer stack variable with no line there is substituted into the file and never reaches the container, which is exactly how `UPLOADS_DIR` went missing on 2026-08-23.

Unset warns at boot, in the same shape as the admin gate — a working configuration with one defence switched off is worth saying out loud. Set without a scheme is refused outright, because a bare hostname joins onto a stored path as if it were relative and breaks every image on the site rather than failing visibly.

The compose guard now resolves `${VAR:-default}` to its default, which is what the container actually receives when the stack variable behind it is unset. A bare `${VAR}` is still left opaque, so a required variable referenced that way goes on counting as present — that check is about the line existing, not about the stack being filled in.

Closes #103
2026-08-24 17:38:55 -05:00

146 lines
5.9 KiB
TypeScript

import request from 'supertest';
import { promises as fs } from 'fs';
import path from 'path';
import app from '../../src/app';
import { pool } from '../../src/db';
import { closeDb } from './setup/testDb';
/**
* What the application will and will not hand back out of the uploads
* directory.
*
* #95 covers what can be *stored*; this covers what can be *served*, which is a
* separate question because a file can reach that directory without going
* through the upload route — one written before the validation existed, one
* restored from a backup, one put there by a path added later. The uploads
* directory is the only place in this application where content someone else
* authored is served over HTTP, so it gets its own rules (#103).
*
* The files here are written straight to disk rather than uploaded, precisely
* because the interesting cases are the ones the upload route would refuse.
*/
const UPLOADS_DIR = process.env.UPLOADS_DIR as string;
const REAL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
// What a stored cross-site scripting payload looks like: same-origin markup
// carrying script, reachable by navigating straight to it.
const HOSTILE_HTML = Buffer.from('<!doctype html><script>alert(document.cookie)</script>', 'utf8');
const HOSTILE_SVG = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.cookie)</script></svg>',
'utf8'
);
const written: string[] = [];
async function place(name: string, bytes: Buffer): Promise<string> {
await fs.writeFile(path.join(UPLOADS_DIR, name), bytes);
written.push(name);
return `/uploads/${name}`;
}
beforeAll(async () => {
await fs.mkdir(UPLOADS_DIR, { recursive: true });
});
afterAll(async () => {
await Promise.all(
written.map((name) => fs.rm(path.join(UPLOADS_DIR, name), { force: true }))
);
await pool.end();
await closeDb();
});
describe('serving uploaded files', () => {
it('serves a stored image', async () => {
const url = await place('serving-ok.png', REAL_PNG);
const res = await request(app).get(url);
expect(res.status).toBe(200);
expect(res.body).toEqual(REAL_PNG);
});
// Stated by this application from a list of three, rather than sniffed from
// the bytes or guessed from a name someone else chose.
it('states the content type explicitly', async () => {
const url = await place('serving-type.png', REAL_PNG);
const res = await request(app).get(url);
expect(res.headers['content-type']).toContain('image/png');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
// Not a header for the image's own sake — an image loads no subresources.
// It constrains the document a browser makes when someone navigates directly
// to the file, which is the only way one of these can do harm.
it('sends a policy that leaves a directly-navigated file with no capabilities', async () => {
const url = await place('serving-csp.png', REAL_PNG);
const res = await request(app).get(url);
expect(res.headers['content-security-policy']).toContain("default-src 'none'");
expect(res.headers['content-security-policy']).toContain('sandbox');
});
// Needed the moment these are served from a hostname of their own, or a
// browser refuses the cross-origin <img> load.
it('allows the file to be embedded cross-origin', async () => {
const url = await place('serving-corp.png', REAL_PNG);
const res = await request(app).get(url);
expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin');
});
// The case the whole file exists for. Both of these execute script when
// navigated to, and neither can be produced by the upload route — so a file
// like this on disk means an earlier control failed, and this is the one that
// still holds.
it('refuses to serve markup that would run as the site', async () => {
const html = await place('serving-hostile.html', HOSTILE_HTML);
const svg = await place('serving-hostile.svg', HOSTILE_SVG);
expect((await request(app).get(html)).status).toBe(404);
expect((await request(app).get(svg)).status).toBe(404);
});
it('refuses an extension it does not recognise, whatever the file contains', async () => {
// Genuinely a PNG, and still not served: the rule is about the name the
// browser will judge the response by, not about the bytes.
const url = await place('serving-mislabelled.txt', REAL_PNG);
expect((await request(app).get(url)).status).toBe(404);
});
// 404 rather than 403, so a stranger cannot use the response to learn which
// paths exist.
it('answers the same for a refused type as for a file that is not there', async () => {
const missing = await request(app).get('/uploads/serving-absent.png');
const refused = await request(app).get('/uploads/serving-absent.html');
expect(missing.status).toBe(404);
expect(refused.status).toBe(404);
});
it('accepts an extension in any case', async () => {
const url = await place('serving-shouted.PNG', REAL_PNG);
const res = await request(app).get(url);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/png');
});
it('refuses to be written to', async () => {
const res = await request(app).post('/uploads/serving-ok.png').send('anything');
expect(res.status).toBe(405);
expect(res.headers.allow).toBe('GET, HEAD');
});
});
describe('GET /api/config', () => {
// Runtime rather than built in, so one image serves every environment. Empty
// is the default and means the app's own origin, which is what local
// development has.
it('names the origin uploaded images should be fetched from', async () => {
const res = await request(app).get('/api/config');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('uploadsBaseUrl');
expect(typeof res.body.uploadsBaseUrl).toBe('string');
});
});