The declared data-URL MIME was a claim the boundary believed. Every image decision now comes from the bytes: the container is sniffed, walked to its own end marker, refused when metadata segments remain, and refused above a 4096px edge or 16MP. The request path adds a per-caller sliding-window budget (429 + Retry-After) and a body deadline (408), so a flood, a stalled body, or a decode bomb fails before a provider call. Log hygiene is part of the contract: refusal copy is fixed prose, the bucket key is a salted hash kept in memory only, and the acceptance test asserts no image bytes reach the process log.
176 lines
6.4 KiB
JavaScript
176 lines
6.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { IngressError, inspectImageBytes, MAX_EDGE, MAX_PIXELS } from '../src/image-ingress.js';
|
|
|
|
// Every fixture built here is synthetic container bytes assembled in code. No photograph of any
|
|
// person or specimen is used, and nothing is fetched.
|
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
function pngChunk(type, data = Buffer.alloc(0)) {
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(data.length);
|
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
// The parser walks chunk lengths to find the end of the container; the CRC is not read, so a
|
|
// synthetic zero CRC is enough to be structurally complete.
|
|
return Buffer.concat([length, body, Buffer.alloc(4)]);
|
|
}
|
|
|
|
function png({ width = 1200, height = 900, extraChunks = [], tail = Buffer.alloc(0) } = {}) {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 2; // truecolour
|
|
return Buffer.concat([
|
|
PNG_SIGNATURE,
|
|
pngChunk('IHDR', ihdr),
|
|
...extraChunks,
|
|
pngChunk('IDAT', Buffer.from([0x78, 0x9c, 0x01])),
|
|
pngChunk('IEND'),
|
|
tail,
|
|
]);
|
|
}
|
|
|
|
function jpeg({ width = 1200, height = 900, segments = [], tail = Buffer.alloc(0) } = {}) {
|
|
const sof = Buffer.alloc(17);
|
|
sof.writeUInt16BE(0xffc0, 0);
|
|
sof.writeUInt16BE(15, 2); // payload length
|
|
sof[4] = 8; // precision
|
|
sof.writeUInt16BE(height, 5);
|
|
sof.writeUInt16BE(width, 7);
|
|
sof[9] = 3; // component count
|
|
for (let i = 0; i < 3; i += 1) {
|
|
sof[10 + i * 3] = i + 1;
|
|
sof[11 + i * 3] = 0x11;
|
|
sof[12 + i * 3] = 0;
|
|
}
|
|
return Buffer.concat([
|
|
Buffer.from([0xff, 0xd8]),
|
|
...segments,
|
|
sof,
|
|
Buffer.from([0xff, 0xd9]),
|
|
tail,
|
|
]);
|
|
}
|
|
|
|
function exifSegment() {
|
|
const payload = Buffer.concat([
|
|
Buffer.from('Exif', 'ascii'),
|
|
Buffer.from([0x00, 0x00]),
|
|
Buffer.from('GPSLatitude=0.000', 'ascii'),
|
|
]);
|
|
const header = Buffer.alloc(4);
|
|
header.writeUInt16BE(0xffe1, 0);
|
|
header.writeUInt16BE(payload.length + 2, 2);
|
|
return Buffer.concat([header, payload]);
|
|
}
|
|
|
|
function webp({ width = 1200, height = 900 } = {}) {
|
|
// VP8X canvas payload: flags(1) + reserved(3) + width-1(3, LE) + height-1(3, LE).
|
|
const payload = Buffer.alloc(10);
|
|
payload[0] = 0;
|
|
payload.writeUIntLE(width - 1, 4, 3);
|
|
payload.writeUIntLE(height - 1, 7, 3);
|
|
const chunk = Buffer.alloc(8);
|
|
chunk.write('VP8X', 0, 'ascii');
|
|
chunk.writeUInt32LE(payload.length, 4);
|
|
const body = Buffer.concat([Buffer.from('WEBP', 'ascii'), chunk, payload]);
|
|
const riff = Buffer.alloc(8);
|
|
riff.write('RIFF', 0, 'ascii');
|
|
riff.writeUInt32LE(body.length, 4);
|
|
return Buffer.concat([riff, body]);
|
|
}
|
|
|
|
const expectRefusal = (bytes, declaredMime, pattern) => {
|
|
assert.throws(
|
|
() => inspectImageBytes(bytes, { declaredMime }),
|
|
error => {
|
|
assert.ok(error instanceof IngressError, `expected an IngressError, got ${error?.name}`);
|
|
assert.match(error.message, pattern);
|
|
return true;
|
|
},
|
|
);
|
|
};
|
|
|
|
test('refuses a file whose bytes are not a JPEG, PNG, or WebP', () => {
|
|
expectRefusal(Buffer.from('<html><script>alert(1)</script></html>', 'utf8'), 'image/jpeg', /not a JPEG, PNG, or WebP photo/i);
|
|
});
|
|
|
|
test('refuses a PNG that claims to be a JPEG', () => {
|
|
expectRefusal(png(), 'image/jpeg', /not the format it claims/i);
|
|
});
|
|
|
|
test('refuses a JPEG that claims to be a PNG', () => {
|
|
expectRefusal(jpeg(), 'image/png', /not the format it claims/i);
|
|
});
|
|
|
|
test('refuses a truncated container instead of forwarding a partial image', () => {
|
|
expectRefusal(PNG_SIGNATURE.subarray(0, 8), 'image/png', /incomplete or damaged/i);
|
|
expectRefusal(Buffer.from([0xff, 0xd8, 0xff, 0xe0]), 'image/jpeg', /incomplete or damaged/i);
|
|
});
|
|
|
|
test('refuses a PNG with bytes appended after its end marker', () => {
|
|
expectRefusal(png({ tail: Buffer.from('PK\u0003\u0004zip-appended', 'binary') }), 'image/png', /extra data after the image/i);
|
|
});
|
|
|
|
test('refuses a JPEG with bytes appended after its end marker', () => {
|
|
expectRefusal(jpeg({ tail: Buffer.from('<script>alert(1)</script>', 'utf8') }), 'image/jpeg', /extra data after the image/i);
|
|
});
|
|
|
|
test('refuses a JPEG that still carries an EXIF segment', () => {
|
|
expectRefusal(jpeg({ segments: [exifSegment()] }), 'image/jpeg', /embedded metadata/i);
|
|
});
|
|
|
|
test('refuses a PNG that carries an eXIf chunk', () => {
|
|
const exifChunk = pngChunk('eXIf', Buffer.from('MM\u0000*metadata', 'binary'));
|
|
expectRefusal(png({ extraChunks: [exifChunk] }), 'image/png', /embedded metadata/i);
|
|
});
|
|
|
|
test('accepts a clean JPEG and reports its real dimensions', () => {
|
|
const intake = inspectImageBytes(jpeg({ width: 1200, height: 900 }), { declaredMime: 'image/jpeg' });
|
|
assert.equal(intake.mime, 'image/jpeg');
|
|
assert.equal(intake.width, 1200);
|
|
assert.equal(intake.height, 900);
|
|
assert.equal(intake.bytes, jpeg({ width: 1200, height: 900 }).length);
|
|
});
|
|
|
|
test('accepts a clean PNG and reports its real dimensions', () => {
|
|
const intake = inspectImageBytes(png({ width: 800, height: 600 }), { declaredMime: 'image/png' });
|
|
assert.equal(intake.mime, 'image/png');
|
|
assert.equal(intake.width, 800);
|
|
assert.equal(intake.height, 600);
|
|
});
|
|
|
|
test('accepts a clean WebP and reports its canvas dimensions', () => {
|
|
const intake = inspectImageBytes(webp({ width: 640, height: 480 }), { declaredMime: 'image/webp' });
|
|
assert.equal(intake.mime, 'image/webp');
|
|
assert.equal(intake.width, 640);
|
|
assert.equal(intake.height, 480);
|
|
});
|
|
|
|
test(`refuses an edge longer than ${MAX_EDGE} pixels even when the file is small`, () => {
|
|
expectRefusal(png({ width: MAX_EDGE + 1, height: 200 }), 'image/png', /too large in pixels/i);
|
|
});
|
|
|
|
test(`refuses an image above ${MAX_PIXELS} pixels when each edge fits`, () => {
|
|
const edge = 4096;
|
|
expectRefusal(jpeg({ width: edge, height: Math.ceil(MAX_PIXELS / edge) + 1 }), 'image/jpeg', /too large in pixels/i);
|
|
});
|
|
|
|
test('never repeats image bytes back to the caller in a refusal message', () => {
|
|
const marker = 'SECRETPIXELMARKER';
|
|
try {
|
|
inspectImageBytes(Buffer.from(`not an image ${marker}`, 'utf8'), { declaredMime: 'image/jpeg' });
|
|
assert.fail('expected a refusal');
|
|
} catch (error) {
|
|
assert.ok(error instanceof IngressError);
|
|
assert.doesNotMatch(error.message, new RegExp(marker));
|
|
assert.doesNotMatch(error.message, /[A-Za-z0-9+/=]{40,}/);
|
|
}
|
|
});
|
|
|
|
test('refuses an empty body with a bounded message', () => {
|
|
expectRefusal(Buffer.alloc(0), 'image/jpeg', /empty/i);
|
|
});
|