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

107 lines
4.2 KiB
TypeScript

/**
* What the inventory upload will accept, and how to tell whether a file is
* actually what it says it is.
*
* Kept apart from the route so the rules are pure and can be tested directly.
* A mistake here is not a cosmetic one: uploads are served by express.static
* from the application's own origin, so a file that gets through and is later
* navigated to runs as same-origin content. See #95 and #103.
*/
/**
* Deliberately three types, not `image/*`.
*
* SVG is excluded even though it is an image: it can carry script that executes
* when the file is navigated to directly, which is precisely the exposure #103
* describes. A photograph of a one-of-a-kind item is never a vector drawing, so
* nothing real is lost.
*
* GIF is excluded as simply not wanted for product stills rather than for any
* security reason. Adding it later means adding its signature below too.
*
* The frontend's `accept` attribute lists these same three so the file picker
* offers exactly what the server will take. The list unavoidably exists in two
* runtimes; if it changes here, change it there.
*/
export const ALLOWED_IMAGE_TYPES: readonly string[] = ['image/jpeg', 'image/png', 'image/webp'];
/**
* How many bytes of a file are needed to check any signature below. WebP is the
* longest reach: it needs byte 8 onwards.
*/
export const SIGNATURE_BYTES = 12;
const EXTENSION_FOR_TYPE: Readonly<Record<string, string>> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp'
};
/**
* The content type a stored file should be served as, from its extension.
*
* The inverse of `extensionFor`, and derived from the same record so the two
* cannot drift. Returns null for anything else, which is what lets the uploads
* route refuse to serve a file it does not recognise — the case that matters is
* a file written before this validation existed, or one that arrived through a
* gap, since nothing the current upload path accepts can produce another
* extension.
*/
export function typeForExtension(extension: string): string | null {
const lowered = extension.toLowerCase();
const found = Object.entries(EXTENSION_FOR_TYPE).find(([, ext]) => ext === lowered);
return found?.[0] ?? null;
}
export function isAllowedImageType(mimetype: string): boolean {
return ALLOWED_IMAGE_TYPES.includes(mimetype);
}
/**
* The extension a stored file should carry, derived from its validated type.
*
* Returns null for anything unrecognised so a caller has to handle it, rather
* than defaulting to an empty string and writing a file with no extension at
* all. The stored name comes from this instead of from the submitted filename,
* so the name on disk cannot disagree with what the file is.
*/
export function extensionFor(mimetype: string): string | null {
return EXTENSION_FOR_TYPE[mimetype] ?? null;
}
function startsWithBytes(head: Buffer, offset: number, expected: readonly number[]): boolean {
if (head.length < offset + expected.length) {
return false;
}
return expected.every((byte, index) => head[offset + index] === byte);
}
const ASCII_RIFF = [0x52, 0x49, 0x46, 0x46];
const ASCII_WEBP = [0x57, 0x45, 0x42, 0x50];
/**
* Whether a file's leading bytes agree with the content type it was declared as.
*
* `file.mimetype` comes from the client's multipart headers and is whatever the
* caller chose to write there, so the allowlist alone stops honest mistakes and
* nothing else. This is what stops `evil.html` renamed to `photo.jpg` and sent
* as `image/jpeg`.
*
* Fails closed on a short read and on any type not in the allowlist, so a
* truncated file or an unexpected type is refused rather than assumed fine.
*/
export function signatureMatches(mimetype: string, head: Buffer): boolean {
switch (mimetype) {
case 'image/jpeg':
return startsWithBytes(head, 0, [0xff, 0xd8, 0xff]);
case 'image/png':
return startsWithBytes(head, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// A RIFF container is not necessarily a WebP — a .wav opens the same way —
// so both the container marker and the format marker are checked.
case 'image/webp':
return startsWithBytes(head, 0, ASCII_RIFF) && startsWithBytes(head, 8, ASCII_WEBP);
default:
return false;
}
}