feat(backend): accept only real images in the inventory upload (#95) #104
+102
-3
@@ -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 });
|
||||||
}
|
}
|
||||||
return next(err);
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyUploadedImages(req)
|
||||||
|
.then((problem) => {
|
||||||
|
if (problem) {
|
||||||
|
res.status(400).json({ error: problem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
.catch(next);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user