Merge pull request 'feat(backend): accept only real images in the inventory upload (#95)' (#104) from feature/95-upload-type-validation into main
SonarQube Analysis / sonarqube (push) Canceled after 0s
Tests / lint (push) Canceled after 0s
Tests / backend-unit (push) Canceled after 0s
Tests / frontend-e2e (push) Canceled after 0s

Reviewed-on: #104
This commit was merged in pull request #104.
This commit is contained in:
2026-08-21 16:45:56 -05:00
5 changed files with 463 additions and 4 deletions
+101 -2
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer'; import multer from 'multer';
import path from 'path'; import { promises as fs } from 'fs';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { PoolClient } from 'pg'; import { PoolClient } from 'pg';
import { pool } from '../db'; import { pool } from '../db';
@@ -8,6 +8,13 @@ import { ADMIN_ITEM_SELECT } from '../itemSelect';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
import { tagColorFor } from '../utils'; import { tagColorFor } from '../utils';
import {
ALLOWED_IMAGE_TYPES,
SIGNATURE_BYTES,
extensionFor,
isAllowedImageType,
signatureMatches
} from '../uploadTypes';
import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts'; import { notifyFavoritersOfSale, notifyFavoritersOfRemoval, collectFavoriteRecipients } from '../favoriteAlerts';
const router = Router(); const router = Router();
@@ -25,13 +32,30 @@ const MAX_IMAGE_BYTES = 8_000_000;
const MAX_TEXT_FIELDS = 8; const MAX_TEXT_FIELDS = 8;
const MAX_TEXT_FIELD_BYTES = 64 * 1024; const MAX_TEXT_FIELD_BYTES = 64 * 1024;
// Refused before a byte is written. This catches the honest mistake — picking a
// PDF by accident — and nothing more, because file.mimetype is whatever the
// caller wrote in the multipart headers. The bytes are checked after the write;
// see verifyUploadedImages.
class UnsupportedImageTypeError extends Error {}
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: UPLOADS_DIR, destination: UPLOADS_DIR,
// Stored names come from a CSPRNG rather than a timestamp plus Math.random, // Stored names come from a CSPRNG rather than a timestamp plus Math.random,
// which is predictable enough that a caller could guess (or collide with) // which is predictable enough that a caller could guess (or collide with)
// another upload's path. // another upload's path.
//
// The extension comes from the validated content type rather than from
// path.extname(file.originalname), so the name on disk cannot disagree with
// what the file claims to be — a caller cannot get `.html` onto the uploads
// volume by naming their file that way.
filename: (_req, file, cb) => { filename: (_req, file, cb) => {
const ext = path.extname(file.originalname); const ext = extensionFor(file.mimetype);
if (!ext) {
// Unreachable while fileFilter runs first, and here so that it stays
// unreachable rather than silently writing a file with no extension.
cb(new UnsupportedImageTypeError(`unsupported image type ${file.mimetype}`), '');
return;
}
cb(null, `${randomUUID()}${ext}`); cb(null, `${randomUUID()}${ext}`);
} }
}); });
@@ -43,18 +67,93 @@ const upload = multer({
files: MAX_IMAGES_PER_REQUEST, files: MAX_IMAGES_PER_REQUEST,
fields: MAX_TEXT_FIELDS, fields: MAX_TEXT_FIELDS,
fieldSize: MAX_TEXT_FIELD_BYTES fieldSize: MAX_TEXT_FIELD_BYTES
},
fileFilter: (_req, file, cb) => {
if (!isAllowedImageType(file.mimetype)) {
cb(new UnsupportedImageTypeError(
`${file.mimetype} is not an accepted image type — allowed: ${ALLOWED_IMAGE_TYPES.join(', ')}`
));
return;
}
cb(null, true);
} }
}); });
// Reads only the leading bytes — enough to identify a format, not enough to
// care how large the file is. The handle is closed before anything is unlinked,
// because an open handle makes the unlink fail on Windows.
async function readHead(filePath: string): Promise<Buffer> {
const handle = await fs.open(filePath, 'r');
try {
const buffer = Buffer.alloc(SIGNATURE_BYTES);
const { bytesRead } = await handle.read(buffer, 0, SIGNATURE_BYTES, 0);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
}
// Best effort: a file that cannot be removed should not turn a 400 into a 500,
// but it must not be left behind quietly either.
async function discardUploads(files: Express.Multer.File[]): Promise<void> {
await Promise.all(
files.map((file) =>
fs.unlink(file.path).catch((err: unknown) => {
console.error(`[upload] could not remove rejected file ${file.path}:`, err);
})
)
);
}
/**
* Confirms each stored file actually is what it was declared to be.
*
* This cannot happen in multer's fileFilter, which runs before the stream has
* been read — there are no bytes to look at yet. So the check runs after the
* write, and a failure removes *every* file from the request rather than only
* the offending one: a half-accepted upload would leave files on the volume
* that the request was refused for.
*
* Returns the message to refuse with, or null when everything checks out.
*/
async function verifyUploadedImages(req: Request): Promise<string | null> {
const files = (req.files as Express.Multer.File[]) || [];
for (const file of files) {
const head = await readHead(file.path);
if (!signatureMatches(file.mimetype, head)) {
await discardUploads(files);
return `${file.originalname} does not contain ${file.mimetype} data`;
}
}
return null;
}
// No error-handling middleware is mounted on the app, so translate multer's // No error-handling middleware is mounted on the app, so translate multer's
// limit errors here instead of letting them surface as a generic 500. // limit errors here instead of letting them surface as a generic 500.
const uploadImages = (req: Request, res: Response, next: NextFunction) => { const uploadImages = (req: Request, res: Response, next: NextFunction) => {
upload.array('images', MAX_IMAGES_PER_REQUEST)(req, res, (err: unknown) => { upload.array('images', MAX_IMAGES_PER_REQUEST)(req, res, (err: unknown) => {
if (err instanceof UnsupportedImageTypeError) {
return res.status(400).json({ error: err.message });
}
if (err instanceof multer.MulterError) { if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message }); return res.status(status).json({ error: err.message });
} }
if (err) {
return next(err); return next(err);
}
verifyUploadedImages(req)
.then((problem) => {
if (problem) {
res.status(400).json({ error: problem });
return;
}
next();
})
.catch(next);
}); });
}; };
+90
View File
@@ -0,0 +1,90 @@
/**
* 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'
};
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;
}
}
@@ -0,0 +1,155 @@
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 { resetDb, closeDb } from './setup/testDb';
const UPLOADS_DIR = process.env.UPLOADS_DIR as string;
// A genuine 1x1 PNG, so the accepted case exercises the whole path rather than
// a buffer that merely starts with the right bytes.
const REAL_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
const HTML_BYTES = Buffer.from('<!doctype html><script>alert(1)</script>', 'utf8');
const PDF_BYTES = Buffer.from('%PDF-1.4\n%\n', 'binary');
// No test has ever attached a file before this one, so the uploads directory
// has never had to exist. multer.diskStorage does not create its destination.
beforeAll(async () => {
await fs.mkdir(UPLOADS_DIR, { recursive: true });
});
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
async function storedFiles(): Promise<string[]> {
return fs.readdir(UPLOADS_DIR);
}
function createItem() {
return request(app)
.post('/api/admin/items')
.field('name', 'Photographed thing')
.field('description', '')
.field('price', '30');
}
describe('what the inventory upload accepts', () => {
it('accepts a real PNG and stores it', async () => {
const res = await createItem().attach('images', REAL_PNG, {
filename: 'photo.png',
contentType: 'image/png'
});
expect(res.status).toBe(200);
expect(res.body.images).toHaveLength(1);
expect(res.body.images[0].image_path).toMatch(/^\/uploads\/[0-9a-f-]+\.png$/);
});
// The stored name is derived from the validated type, so a caller cannot put
// a .html path onto a volume that express.static serves from the app's own
// origin just by naming their file that way. See #103.
it('names the stored file from its type, not from the submitted filename', async () => {
const res = await createItem().attach('images', REAL_PNG, {
filename: 'evil.html',
contentType: 'image/png'
});
expect(res.status).toBe(200);
expect(res.body.images[0].image_path).toMatch(/\.png$/);
expect(res.body.images[0].image_path).not.toContain('html');
});
});
describe('what it refuses', () => {
it('refuses a type that is not an accepted image, naming what is', async () => {
const res = await createItem().attach('images', PDF_BYTES, {
filename: 'brochure.pdf',
contentType: 'application/pdf'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('application/pdf');
expect(res.body.error).toContain('image/png');
});
// SVG is an image and is deliberately not accepted, because it executes
// script when navigated to directly.
it('refuses SVG even though it is an image', async () => {
const res = await createItem().attach('images', Buffer.from('<svg xmlns="..."/>'), {
filename: 'logo.svg',
contentType: 'image/svg+xml'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('image/svg+xml');
});
// The case an allowlist on the declared type alone waves straight through.
it('refuses a document wearing an image content type', async () => {
const res = await createItem().attach('images', HTML_BYTES, {
filename: 'photo.jpg',
contentType: 'image/jpeg'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('does not contain');
});
it('leaves nothing on the volume when it refuses the content', async () => {
const before = await storedFiles();
await createItem().attach('images', HTML_BYTES, {
filename: 'photo.jpg',
contentType: 'image/jpeg'
});
expect(await storedFiles()).toHaveLength(before.length);
});
// A rejection must take the whole request with it. Accepting the good file
// from a refused upload would leave a file behind that nothing references.
it('discards the valid files from a request that also carried an invalid one', async () => {
const before = await storedFiles();
const res = await createItem()
.attach('images', REAL_PNG, { filename: 'good.png', contentType: 'image/png' })
.attach('images', HTML_BYTES, { filename: 'bad.jpg', contentType: 'image/jpeg' });
expect(res.status).toBe(400);
expect(await storedFiles()).toHaveLength(before.length);
});
it('does not create the item when the upload is refused', async () => {
await createItem().attach('images', HTML_BYTES, {
filename: 'photo.jpg',
contentType: 'image/jpeg'
});
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM items`);
expect(rows[0].n).toBe(0);
});
});
describe('the uploads directory', () => {
it('only ever gains files with an extension the allowlist produced', async () => {
await createItem().attach('images', REAL_PNG, {
filename: 'whatever.jpeg',
contentType: 'image/png'
});
const files = await storedFiles();
for (const file of files) {
expect(['.jpg', '.png', '.webp']).toContain(path.extname(file));
}
});
});
+107
View File
@@ -0,0 +1,107 @@
import {
ALLOWED_IMAGE_TYPES,
SIGNATURE_BYTES,
extensionFor,
isAllowedImageType,
signatureMatches
} from '../../src/uploadTypes';
// Heads long enough to satisfy the WebP check, which needs twelve bytes.
const JPEG_HEAD = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]);
const PNG_HEAD = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
const WEBP_HEAD = Buffer.concat([
Buffer.from('RIFF', 'ascii'),
Buffer.from([0, 0, 0, 0]),
Buffer.from('WEBP', 'ascii')
]);
// What an attacker actually sends: a document declared as an image.
const HTML_HEAD = Buffer.from('<!doctype html><script>', 'ascii');
describe('isAllowedImageType', () => {
it.each(['image/jpeg', 'image/png', 'image/webp'])('accepts %s', (type) => {
expect(isAllowedImageType(type)).toBe(true);
});
// SVG is an image and can carry script that runs when the file is navigated
// to directly, which is the whole reason it is excluded rather than the
// generic image/* being accepted. See #103.
it('refuses SVG despite it being an image', () => {
expect(isAllowedImageType('image/svg+xml')).toBe(false);
});
it.each(['text/html', 'application/pdf', 'image/gif', 'application/octet-stream', ''])(
'refuses %p',
(type) => {
expect(isAllowedImageType(type)).toBe(false);
}
);
it('exposes exactly the three types the frontend offers', () => {
expect([...ALLOWED_IMAGE_TYPES].sort()).toEqual(['image/jpeg', 'image/png', 'image/webp']);
});
});
describe('extensionFor', () => {
it.each([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp']
])('maps %s to %s', (type, ext) => {
expect(extensionFor(type)).toBe(ext);
});
// The stored name is derived from this rather than from the submitted
// filename, so an unmapped type must not silently produce an empty extension.
it('returns null for a type it does not know', () => {
expect(extensionFor('image/svg+xml')).toBeNull();
expect(extensionFor('text/html')).toBeNull();
});
});
describe('signatureMatches', () => {
it('accepts each type whose bytes agree with its declared type', () => {
expect(signatureMatches('image/jpeg', JPEG_HEAD)).toBe(true);
expect(signatureMatches('image/png', PNG_HEAD)).toBe(true);
expect(signatureMatches('image/webp', WEBP_HEAD)).toBe(true);
});
// The case declared-type checking alone waves straight through: rename
// evil.html to photo.jpg and say it is image/jpeg.
it('refuses a document wearing an image content type', () => {
expect(signatureMatches('image/jpeg', HTML_HEAD)).toBe(false);
expect(signatureMatches('image/png', HTML_HEAD)).toBe(false);
expect(signatureMatches('image/webp', HTML_HEAD)).toBe(false);
});
// One real image type declared as another is still a mismatch — the stored
// extension comes from the declared type, so the name would lie about it.
it('refuses a real image whose bytes are a different format', () => {
expect(signatureMatches('image/png', JPEG_HEAD)).toBe(false);
expect(signatureMatches('image/jpeg', PNG_HEAD)).toBe(false);
});
// WebP is RIFF at 0 and WEBP at 8; a RIFF container that is not WebP (a wav,
// say) must not pass on the first four bytes alone.
it('refuses a RIFF container that is not WebP', () => {
const wav = Buffer.concat([
Buffer.from('RIFF', 'ascii'),
Buffer.from([0, 0, 0, 0]),
Buffer.from('WAVE', 'ascii')
]);
expect(signatureMatches('image/webp', wav)).toBe(false);
});
// A truncated read must fail closed rather than throwing or passing.
it('refuses a head too short to identify', () => {
expect(signatureMatches('image/webp', Buffer.from([0x52, 0x49]))).toBe(false);
expect(signatureMatches('image/png', Buffer.alloc(0))).toBe(false);
});
it('refuses a type it was never told to allow', () => {
expect(signatureMatches('image/svg+xml', Buffer.from('<svg', 'ascii'))).toBe(false);
});
it('reads enough bytes for the longest signature it checks', () => {
expect(SIGNATURE_BYTES).toBeGreaterThanOrEqual(12);
});
});
+9 -1
View File
@@ -319,7 +319,15 @@ function Inventory() {
</Form.Item> </Form.Item>
)} )}
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}> <Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
<Upload fileList={fileList} beforeUpload={() => false} onChange={({ fileList }) => setFileList(fileList.slice(-6))} {/* The exact three types the server accepts, not image/*. Offering
a type the API will refuse — SVG, notably — turns a picker
choice into a 400 the admin has to decode. The operating
system's own "All files" option is unaffected: `accept`
chooses the default filter, it does not remove that escape
hatch, and it is not a control either way. The list is
enforced in backend/src/uploadTypes.ts; change both. */}
<Upload accept="image/jpeg,image/png,image/webp"
fileList={fileList} beforeUpload={() => false} onChange={({ fileList }) => setFileList(fileList.slice(-6))}
maxCount={6} multiple listType="picture-card"> maxCount={6} multiple listType="picture-card">
<div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div> <div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div>
</Upload> </Upload>