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.
159 lines
6.8 KiB
JavaScript
159 lines
6.8 KiB
JavaScript
// Transport-level hardening for the one endpoint that accepts image bytes (issue #16).
|
||
//
|
||
// The declared MIME type in a data URL is a claim made by the caller, never evidence, so every
|
||
// decision here comes from the bytes: the container is sniffed, the container is walked to its own
|
||
// end marker so nothing can ride along behind a valid image, embedded metadata segments are refused
|
||
// (the browser always uploads a canvas re-encode, which carries none), and the pixel dimensions are
|
||
// read from the header so a decode bomb never reaches the worker.
|
||
//
|
||
// Refusal copy is fixed and deliberate: it names what is wrong with the file and never echoes the
|
||
// bytes, a hash, or any part of the upload back to the caller or into a log line.
|
||
|
||
export const MAX_EDGE = 4096;
|
||
export const MAX_PIXELS = 16_000_000;
|
||
|
||
const COPY = Object.freeze({
|
||
empty: 'The photo is empty.',
|
||
not_an_image: 'That file is not a JPEG, PNG, or WebP photo.',
|
||
container_mismatch: 'This photo is not the format it claims. Choose a JPEG, PNG, or WebP photo.',
|
||
truncated: 'This photo is incomplete or damaged. Choose another photo.',
|
||
trailing_data: 'This photo file has extra data after the image. Choose another photo.',
|
||
metadata_present: 'This photo still carries embedded metadata. Choose it through Timmy’s camera or gallery picker so it is re-encoded without metadata.',
|
||
dimensions_too_large: 'This photo is too large in pixels. Choose a smaller photo.',
|
||
});
|
||
|
||
export class IngressError extends Error {
|
||
constructor(code) {
|
||
super(COPY[code] || COPY.not_an_image);
|
||
this.name = 'IngressError';
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
const refuse = code => {
|
||
throw new IngressError(code);
|
||
};
|
||
|
||
const PNG_CHUNKS = { metadata: new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']) };
|
||
// Segment markers that carry metadata rather than image data: APP1 (Exif/XMP), APP13 (Photoshop
|
||
// resource block) and COM (free-text comment). The app's own upload is a canvas re-encode and
|
||
// contains none of them, so a file that does was not produced by the capture path.
|
||
const JPEG_METADATA_MARKERS = new Set([0xe1, 0xed, 0xfe]);
|
||
const JPEG_SOF_MARKERS = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
||
|
||
function sniff(bytes) {
|
||
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png';
|
||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
||
if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
||
return '';
|
||
}
|
||
|
||
function inspectPng(bytes) {
|
||
let offset = 8;
|
||
let width = 0;
|
||
let height = 0;
|
||
let sawHeader = false;
|
||
while (offset + 8 <= bytes.length) {
|
||
const length = bytes.readUInt32BE(offset);
|
||
const type = bytes.toString('ascii', offset + 4, offset + 8);
|
||
const dataStart = offset + 8;
|
||
const next = dataStart + length + 4;
|
||
if (next > bytes.length) refuse('truncated');
|
||
if (!sawHeader) {
|
||
if (type !== 'IHDR' || length !== 13) refuse('truncated');
|
||
width = bytes.readUInt32BE(dataStart);
|
||
height = bytes.readUInt32BE(dataStart + 4);
|
||
sawHeader = true;
|
||
}
|
||
if (PNG_CHUNKS.metadata.has(type)) refuse('metadata_present');
|
||
if (type === 'IEND') {
|
||
if (next !== bytes.length) refuse('trailing_data');
|
||
return { width, height };
|
||
}
|
||
offset = next;
|
||
}
|
||
return refuse('truncated');
|
||
}
|
||
|
||
function inspectJpeg(bytes) {
|
||
let offset = 2;
|
||
let width = 0;
|
||
let height = 0;
|
||
while (offset + 4 <= bytes.length) {
|
||
if (bytes[offset] !== 0xff) refuse('truncated');
|
||
const marker = bytes[offset + 1];
|
||
if (marker === 0xd9) {
|
||
if (offset + 2 !== bytes.length) refuse('trailing_data');
|
||
break;
|
||
}
|
||
// Standalone markers carry no length field.
|
||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0xff) {
|
||
offset += marker === 0xff ? 1 : 2;
|
||
continue;
|
||
}
|
||
const length = bytes.readUInt16BE(offset + 2);
|
||
if (length < 2 || offset + 2 + length > bytes.length) refuse('truncated');
|
||
if (JPEG_METADATA_MARKERS.has(marker)) refuse('metadata_present');
|
||
if (JPEG_SOF_MARKERS.has(marker) && length >= 7) {
|
||
height = bytes.readUInt16BE(offset + 5);
|
||
width = bytes.readUInt16BE(offset + 7);
|
||
}
|
||
offset += 2 + length;
|
||
if (marker === 0xda) {
|
||
// Entropy-coded scan data follows the start-of-scan header. The only thing left to trust is
|
||
// the end-of-image marker, and it has to be the last two bytes of the file.
|
||
if (bytes[bytes.length - 2] !== 0xff || bytes[bytes.length - 1] !== 0xd9) {
|
||
return refuse(bytes.includes(Buffer.from([0xff, 0xd9])) ? 'trailing_data' : 'truncated');
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (!width || !height) refuse('truncated');
|
||
return { width, height };
|
||
}
|
||
|
||
function inspectWebp(bytes) {
|
||
const declared = bytes.readUInt32LE(4) + 8;
|
||
if (declared !== bytes.length) refuse(declared < bytes.length ? 'trailing_data' : 'truncated');
|
||
let offset = 12;
|
||
let width = 0;
|
||
let height = 0;
|
||
while (offset + 8 <= bytes.length) {
|
||
const type = bytes.toString('ascii', offset, offset + 4);
|
||
const size = bytes.readUInt32LE(offset + 4);
|
||
const dataStart = offset + 8;
|
||
if (dataStart + size > bytes.length) refuse('truncated');
|
||
if (type === 'VP8X' && size >= 10) {
|
||
width = bytes.readUIntLE(dataStart + 4, 3) + 1;
|
||
height = bytes.readUIntLE(dataStart + 7, 3) + 1;
|
||
} else if (type === 'VP8 ' && size >= 10) {
|
||
width = bytes.readUInt16LE(dataStart + 6) & 0x3fff;
|
||
height = bytes.readUInt16LE(dataStart + 8) & 0x3fff;
|
||
} else if (type === 'VP8L' && size >= 5) {
|
||
const bits = bytes.readUInt32LE(dataStart + 1);
|
||
width = (bits & 0x3fff) + 1;
|
||
height = ((bits >> 14) & 0x3fff) + 1;
|
||
}
|
||
offset = dataStart + size + (size % 2);
|
||
}
|
||
if (!width || !height) refuse('truncated');
|
||
return { width, height };
|
||
}
|
||
|
||
// Returns the real container, its pixel dimensions, and the byte count. Throws IngressError on
|
||
// anything the model worker must never be handed.
|
||
export function inspectImageBytes(bytes, { declaredMime = '' } = {}) {
|
||
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || []);
|
||
if (!buffer.length) refuse('empty');
|
||
const actual = sniff(buffer);
|
||
if (!actual) refuse('not_an_image');
|
||
if (declaredMime && declaredMime !== actual) refuse('container_mismatch');
|
||
const dimensions = actual === 'image/png' ? inspectPng(buffer)
|
||
: actual === 'image/jpeg' ? inspectJpeg(buffer)
|
||
: inspectWebp(buffer);
|
||
if (!dimensions.width || !dimensions.height) refuse('truncated');
|
||
if (dimensions.width > MAX_EDGE || dimensions.height > MAX_EDGE
|
||
|| dimensions.width * dimensions.height > MAX_PIXELS) refuse('dimensions_too_large');
|
||
return { mime: actual, width: dimensions.width, height: dimensions.height, bytes: buffer.length };
|
||
}
|