fix: inspect uploaded image dimensions on the server
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-11 00:35:07 -04:00
parent cce395baf6
commit e6510c0317
11 changed files with 170 additions and 13 deletions
+2
View File
@@ -1,3 +1,4 @@
import { createSharpImageInspector } from "../infrastructure/images/sharp-image-inspector.js";
import dotenv from "dotenv";
import { fileURLToPath } from "node:url";
@@ -193,6 +194,7 @@ async function startServer() {
publicImageService,
}),
inventoryImageService: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: createLocalImageStorage(imageStorageRoot),
imageDerivativeJobRepository,
inventoryImageRepository,
@@ -0,0 +1,18 @@
import sharp from 'sharp';
const options = { failOn: 'warning', limitInputPixels: 100_000_000 };
const mimeTypes = { jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
export function createSharpImageInspector() {
return {
async readMetadata(data) {
const metadata = await sharp(data, options).metadata();
return { width: metadata.width, height: metadata.height, mimeType: mimeTypes[metadata.format] };
},
async validateDecoding(data) {
// Decode only after the service has checked metadata limits. The tiny
// output is discarded; originals and derivatives retain their own flow.
await sharp(data, options).resize(1, 1).raw().toBuffer();
},
};
}
@@ -1,5 +1,5 @@
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
import { createInventoryImageSchema, moveInventoryImageSchema, updateImageOrderSchema, updateImageAltTextSchema } from '@pokemon-inventory/validation/image';
import { createInventoryImageSchema, uploadInventoryImageSchema, moveInventoryImageSchema, updateImageOrderSchema, updateImageAltTextSchema } from '@pokemon-inventory/validation/image';
import { randomUUID } from 'node:crypto';
import { createApplicationError } from '../../../shared/errors/application-error.js';
@@ -42,13 +42,11 @@ export async function registerAdminImageRoutes(app, options) {
const data = await part.toBuffer();
const fields = part.fields;
const originalFilename = part.filename;
const result = createInventoryImageSchema.safeParse({
const result = uploadInventoryImageSchema.safeParse({
storageKey: `inventory/${request.params.id}/${randomUUID()}`,
originalFilename,
mimeType: part.mimetype,
fileSize: data.length,
width: Number(fields.width?.value),
height: Number(fields.height?.value),
altText: fields.altText?.value,
sortOrder: fields.sortOrder ? Number(fields.sortOrder.value) : undefined,
isPrimary: fields.isPrimary?.value === 'true',
@@ -2,7 +2,7 @@ import { validateImageMove } from '../domain/image-order.js';
import { createApplicationError } from '../../../shared/errors/application-error.js';
import { normalizeImageAltText, validateImageSortOrder, validateImageSignature, validateInventoryImageMetadata } from '../domain/inventory-image.js';
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage }) {
export function createInventoryImageService({ inventoryImageRepository, imageDerivativeJobRepository, imageStorage, imageInspector }) {
return {
async create(input) {
validateInventoryImageMetadata(input);
@@ -30,8 +30,19 @@ export function createInventoryImageService({ inventoryImageRepository, imageDer
async upload(input) {
const { data, ...metadata } = input;
validateInventoryImageMetadata(metadata);
validateImageSignature(data, metadata.mimeType);
// Bound work before decoding; client dimensions and byte counts are not authoritative.
validateInventoryImageMetadata({ ...metadata, fileSize: data.length, width: 1, height: 1 });
let inspected;
try { inspected = await imageInspector.readMetadata(data); }
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
if (inspected.mimeType !== metadata.mimeType) {
throw createApplicationError('IMAGE_SIGNATURE_INVALID', 'Image format does not match the declared MIME type.', 400);
}
Object.assign(metadata, { width: inspected.width, height: inspected.height, fileSize: data.length });
validateInventoryImageMetadata(metadata);
try { await imageInspector.validateDecoding(data); }
catch { throw createApplicationError('IMAGE_DECODE_INVALID', 'Image could not be decoded.', 400); }
await imageStorage.put({ key: metadata.storageKey, data });
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { createSharpImageInspector } from '../../src/infrastructure/images/sharp-image-inspector.js';
import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js';
function createService() {
const writes = [];
return { writes, service: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: { async put(input) { writes.push(input); }, async delete() {} },
inventoryImageRepository: { async createForSeller(input) { return { id: 'image-1', ...input }; } },
}) };
}
const metadata = { inventoryItemId: 'item-1', sellerId: 'seller-1', storageKey: 'test', originalFilename: 'test.png', width: 9999, height: 9999, fileSize: 1 };
test('inspects JPEG, PNG and WebP bytes and stores their true dimensions and size', async () => {
for (const [format, mimeType] of [['jpeg', 'image/jpeg'], ['png', 'image/png'], ['webp', 'image/webp']]) {
const data = await sharp({ create: { width: 12, height: 8, channels: 3, background: '#123456' } }).toFormat(format).toBuffer();
const { service, writes } = createService();
const image = await service.upload({ ...metadata, data, mimeType });
assert.equal(image.width, 12);
assert.equal(image.height, 8);
assert.equal(image.fileSize, data.length);
assert.equal(writes.length, 1);
}
});
test('rejects actual oversized dimensions, signature-only and truncated images before storage', async () => {
const oversized = await sharp({ create: { width: 10001, height: 1, channels: 3, background: 'white' } }).png().toBuffer();
const valid = await sharp({ create: { width: 12, height: 8, channels: 3, background: 'white' } }).png().toBuffer();
const incompletePixels = valid.subarray(0, valid.length - 15);
assert.equal((await createSharpImageInspector().readMetadata(incompletePixels)).width, 12);
for (const [data, code] of [[incompletePixels, 'IMAGE_DECODE_INVALID'], [oversized, 'IMAGE_DIMENSIONS_INVALID'], [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 'IMAGE_DECODE_INVALID'], [valid.subarray(0, 40), 'IMAGE_DECODE_INVALID']]) {
const { service, writes } = createService();
await assert.rejects(() => service.upload({ ...metadata, width: 1, height: 1, data, mimeType: 'image/png' }), { code });
assert.deepEqual(writes, []);
}
});
test('checks true byte length before image inspection', async () => {
const data = Buffer.alloc(10 * 1024 * 1024 + 1);
Buffer.from([0xff, 0xd8, 0xff]).copy(data);
const service = createInventoryImageService({ imageInspector: { async readMetadata() { assert.fail('oversized input decoded'); } } });
await assert.rejects(() => service.upload({ ...metadata, data, mimeType: 'image/jpeg' }), { code: 'IMAGE_FILE_TOO_LARGE' });
});
@@ -19,6 +19,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
const stored = [];
const jobs = [];
const service = createInventoryImageService({
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
imageStorage: {
async put(value) { stored.push(value); },
async delete() {},
@@ -38,6 +39,9 @@ test('stores validated upload bytes before persisting image metadata', async ()
assert.equal(stored.length, 1);
assert.deepEqual(stored[0], { key: input.storageKey, data: input.data });
assert.equal(image.id, 'image-1');
assert.equal(image.width, 2);
assert.equal(image.height, 3);
assert.equal(image.fileSize, input.data.length);
assert.equal(image.processingStatus, 'PENDING');
assert.equal('data' in image, false);
assert.deepEqual(jobs, [{
@@ -50,6 +54,7 @@ test('stores validated upload bytes before persisting image metadata', async ()
test('removes stored bytes when metadata persistence fails', async () => {
const deleted = [];
const service = createInventoryImageService({
imageInspector: { async readMetadata() { return { width: 2, height: 3, mimeType: 'image/jpeg' }; }, async validateDecoding() {} },
imageStorage: {
async put() {},
async delete(key) { deleted.push(key); },
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import sharp from 'sharp';
import { buildApp } from '../../src/app/build-app.js';
import { createInventoryImageService } from '../../src/modules/inventory/services/inventory-image-service.js';
import { createSharpImageInspector } from '../../src/infrastructure/images/sharp-image-inspector.js';
test('multipart uploads derive dimensions and reject malformed bytes before writes', async (context) => {
const saved = [];
let stored = 0;
let jobs = 0;
const app = buildApp({
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
inventoryImageService: createInventoryImageService({
imageInspector: createSharpImageInspector(),
imageStorage: { async put() { stored += 1; }, async delete() {} },
inventoryImageRepository: { async createForSeller(input, sellerId) { assert.equal(sellerId, 'seller-1'); saved.push(input); return { id: 'image-1', ...input }; } },
imageDerivativeJobRepository: { async create() { jobs += 1; } },
}),
});
context.after(() => app.close());
const data = await sharp({ create: { width: 7, height: 5, channels: 3, background: 'white' } }).png().toBuffer();
const send = (bytes, fields = '') => app.inject({
method: 'POST', url: '/api/v1/admin/inventory/item-1/images',
headers: { 'content-type': 'multipart/form-data; boundary=image-test' },
payload: Buffer.concat([Buffer.from(`${fields}--image-test\r\nContent-Disposition: form-data; name="file"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`), bytes, Buffer.from('\r\n--image-test--\r\n')]),
});
for (const fields of ['', '--image-test\r\nContent-Disposition: form-data; name="width"\r\n\r\n1\r\n--image-test\r\nContent-Disposition: form-data; name="height"\r\n\r\n1\r\n']) {
const response = await send(data, fields);
assert.equal(response.statusCode, 201);
assert.equal(response.json().data.width, 7);
assert.equal(response.json().data.height, 5);
assert.equal(response.json().data.fileSize, data.length);
}
const response = await send(data.subarray(0, 40));
assert.equal(response.statusCode, 400);
assert.equal(response.json().error.code, 'IMAGE_DECODE_INVALID');
assert.equal(saved.length, 2);
assert.equal(stored, 2);
assert.equal(jobs, 2);
});