/** * 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> = { '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; } }