Files
redefined-designs/backend/src/uploads.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

105 lines
4.4 KiB
TypeScript

/**
* Serving user-uploaded files, defensively.
*
* Everything under the uploads directory was put there by someone other than
* the people who wrote this application, and it is served over HTTP. #95 stops
* 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, or a file
* written before that validation existed.
*
* The real fix for that class is a separate origin, because the origin is the
* whole unit of trust in a browser (#103). That needs a hostname and a
* certificate, which live outside this repository, so what is here is the half
* that works either way: the app's own origin stops serving anything it does
* not recognise, and serves what it does recognise in a form that cannot be
* talked into executing.
*
* These two are complementary rather than alternatives. The separate origin
* still points at these same files, so the rules below apply there too.
*/
import express, { Request, Response, NextFunction, Router } from 'express';
import path from 'path';
import { typeForExtension } from './uploadTypes';
/**
* A directly-navigated upload gets no capabilities at all.
*
* `default-src 'none'` leaves a document unable to load or run anything, and
* `sandbox` with no allowances drops it into an opaque origin, so even a file
* that somehow renders as markup cannot reach the site's cookies or DOM.
*
* This does nothing to an `<img>` embed, which is the only way these files are
* legitimately used — a policy on an image response constrains the image's own
* (nonexistent) subresource loads, not the page displaying it.
*/
const UPLOAD_CSP = "default-src 'none'; sandbox";
/**
* Whether a request should reach the files at all.
*
* Only GET and HEAD: express.static ignores the rest anyway, but answering 405
* says so rather than falling through to a 404 that suggests the path is wrong.
*/
function methodAllowed(method: string): boolean {
return method === 'GET' || method === 'HEAD';
}
export function uploadsRouter(directory: string): Router {
const router = express.Router();
router.use((req: Request, res: Response, next: NextFunction) => {
if (!methodAllowed(req.method)) {
res.set('Allow', 'GET, HEAD');
res.status(405).json({ error: 'method not allowed' });
return;
}
// An allowlist rather than a denylist of dangerous extensions. A denylist
// has to anticipate every type a browser might execute, which is a moving
// target across browsers and years; this only has to know the three types
// the upload path can produce, and everything else — including a `.html` or
// a `.svg` sitting on disk from before there was any validation — is simply
// not a file this application will hand out.
const contentType = typeForExtension(path.extname(req.path));
if (contentType === null) {
// 404 rather than 403: whether a file exists at that path is not
// something a stranger needs to be able to distinguish.
res.status(404).json({ error: 'not found' });
return;
}
// Set here rather than only in setHeaders below, so a request that never
// reaches a file still carries them.
res.set('X-Content-Type-Options', 'nosniff');
res.set('Content-Security-Policy', UPLOAD_CSP);
next();
});
router.use(
express.static(directory, {
// No directory listings and no index.html, both of which would be content
// this application did not write being served as if it had.
index: false,
// A dotfile in an upload directory is never something to hand out.
dotfiles: 'ignore',
setHeaders: (res: Response, filePath: string) => {
const contentType = typeForExtension(path.extname(filePath));
if (contentType !== null) {
// Stated explicitly rather than left to express.static's extension
// lookup. Paired with nosniff, the type a browser sees is then the
// one this application chose, from a list of three, and never a guess
// made from the bytes.
res.set('Content-Type', contentType);
}
// Required once these are served from a hostname of their own: without
// it a resource-policy-conscious browser refuses the cross-origin
// `<img>` load. Harmless while the origin is shared.
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
}
})
);
return router;
}