Files
timmy-talking-turd/src/image-ingress.js
T
timmy ba9afe0a1d feat(ingress): decide photo validity from bytes, budget, and header before any provider work (#16)
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.
2026-10-20 08:21:20 +00:00

159 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}