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.
This commit is contained in:
1 parent
d0b3c10aea
commit
ba9afe0a1d
9 files changed
+869
-2
No files matched your search
+42
-2
@@ -1,11 +1,15 @@
|
||||
import http from 'node:http';
|
||||
import { isIP } from 'node:net';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { analyzePhoto, visionFailureResponse } from './src/vision-service.js';
|
||||
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
|
||||
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
|
||||
import { readJsonBody } from './src/http-body.js';
|
||||
import { inspectImageBytes, IngressError } from './src/image-ingress.js';
|
||||
import { createRateLimiter } from './src/rate-limit.js';
|
||||
|
||||
const root=fileURLToPath(new URL('.',import.meta.url));
|
||||
const port=Number(process.env.PORT||4173);
|
||||
@@ -32,7 +36,27 @@ const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${c
|
||||
|
||||
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
|
||||
function escapeHtmlAttribute(value){return String(value).replace(/[&<>"]/g,character=>({'&':'&','<':'<','>':'>','"':'"'}[character]));}
|
||||
function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
|
||||
// Photo ingress (issue #16). Every limit here is decided from the request itself and fails before
|
||||
// any provider call: a per-caller request budget, a deadline for a body that never arrives, a byte
|
||||
// ceiling, and a container/pixel inspection of the decoded image. The bucket key is a salted hash
|
||||
// that lives only in this process's memory, so no address and no image byte is ever logged.
|
||||
const bodyTimeoutMs=Number(process.env.TIMMY_BODY_TIMEOUT_MS||15_000);
|
||||
const analyzeLimiter=createRateLimiter({limit:Number(process.env.TIMMY_ANALYZE_RATE_LIMIT||12),windowMs:Number(process.env.TIMMY_ANALYZE_RATE_WINDOW_MS||60_000)});
|
||||
const bucketSalt=randomBytes(16).toString('hex');
|
||||
function clientBucket(req){return createHash('sha256').update(`${bucketSalt}:${req.socket.remoteAddress||'unknown'}`).digest('hex').slice(0,24);}
|
||||
function readJson(req,maxBytes=6*1024*1024){return readJsonBody(req,{maxBytes,timeoutMs:bodyTimeoutMs});}
|
||||
function ingressGuard(payload){
|
||||
const raw=String(payload?.imageDataUrl||'');
|
||||
const match=/^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/.exec(raw);
|
||||
// A payload that is not shaped like a data URL is left to the semantic validator, so the user still
|
||||
// gets the specific "upload a JPEG, PNG, or WebP photo" copy rather than a second opinion here.
|
||||
if(!match)return null;
|
||||
return inspectImageBytes(Buffer.from(match[2],'base64'),{declaredMime:match[1]});
|
||||
}
|
||||
function sendIngressError(res,error){
|
||||
if(error instanceof IngressError)return sendJson(res,400,{error:error.message,code:error.code});
|
||||
return sendJson(res,error.status||400,{error:error.message,code:error.code});
|
||||
}
|
||||
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
|
||||
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
|
||||
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
|
||||
@@ -53,7 +77,23 @@ http.createServer(async(req,res)=>{
|
||||
}
|
||||
if(appPath==='/api/analyze'&&req.method==='POST'){
|
||||
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
|
||||
const payload=await readJson(req);
|
||||
// Cheap refusals first, in cost order: the request budget, then the body, then the image itself.
|
||||
const budget=analyzeLimiter.take(clientBucket(req));
|
||||
if(!budget.allowed){
|
||||
res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000))));
|
||||
return sendJson(res,429,{error:'Too many photos from this device. Wait a moment, then try again — your journal and manual logging still work.'});
|
||||
}
|
||||
let payload;
|
||||
try{payload=await readJson(req)}
|
||||
catch(error){
|
||||
const sent=sendIngressError(res,error);
|
||||
// The refusal is written first, then the half-read socket is dropped so a stalled body cannot
|
||||
// hold a connection or its partial buffer open.
|
||||
if(error?.code==='body_timeout'||error?.code==='too_large')req.destroy?.();
|
||||
return sent;
|
||||
}
|
||||
try{ingressGuard(payload)}
|
||||
catch(error){return sendIngressError(res,error)}
|
||||
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
|
||||
catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Bounded request-body reading for the API boundary (issue #16).
|
||||
//
|
||||
// Three limits have to fail before any provider work starts: the byte ceiling, a deadline for a body
|
||||
// that never arrives, and JSON validity. All three refuse with a client-visible status and a fixed
|
||||
// message; none of them logs or returns the body.
|
||||
|
||||
const COPY = Object.freeze({
|
||||
too_large: [413, 'Request is too large.'],
|
||||
invalid_json: [400, 'Invalid JSON request.'],
|
||||
body_timeout: [408, 'The request took too long to arrive. Try again.'],
|
||||
});
|
||||
|
||||
export class BodyError extends Error {
|
||||
constructor(code) {
|
||||
const [status, message] = COPY[code] || COPY.invalid_json;
|
||||
super(message);
|
||||
this.name = 'BodyError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsonBody(req, { maxBytes = 6 * 1024 * 1024, timeoutMs = 15_000 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
const finish = callback => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(deadline);
|
||||
req.removeListener('data', onData);
|
||||
req.removeListener('end', onEnd);
|
||||
req.removeListener('error', onError);
|
||||
callback();
|
||||
};
|
||||
const deadline = setTimeout(() => {
|
||||
finish(() => {
|
||||
// Stop reading and let the caller answer 408; the server destroys the socket once the
|
||||
// refusal is written, so a half-read body cannot be held open indefinitely.
|
||||
req.pause?.();
|
||||
reject(new BodyError('body_timeout'));
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
function onData(chunk) {
|
||||
if (settled) return;
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
finish(() => {
|
||||
req.destroy?.();
|
||||
reject(new BodyError('too_large'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
function onEnd() {
|
||||
finish(() => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
reject(new BodyError('invalid_json'));
|
||||
}
|
||||
});
|
||||
}
|
||||
function onError(error) {
|
||||
finish(() => reject(error));
|
||||
}
|
||||
|
||||
req.on('data', onData);
|
||||
req.on('end', onEnd);
|
||||
req.on('error', onError);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Bounded, in-process request budget for the photo-analysis endpoint (issue #16).
|
||||
//
|
||||
// The limiter exists so a caller cannot make Timmy the transport for a flood of images to the
|
||||
// model worker. It keeps timestamps only, never the bytes, the file name, or the caller's address:
|
||||
// the server hands it a short-lived opaque bucket key. Refusals never extend a window, so a caller
|
||||
// cannot earn a longer penalty than the request it already made.
|
||||
|
||||
export function createRateLimiter({ limit = 12, windowMs = 60_000, maxKeys = 2048, now = () => Date.now() } = {}) {
|
||||
const hits = new Map();
|
||||
let lastPrune = now();
|
||||
|
||||
function prune(at) {
|
||||
const cutoff = at - windowMs;
|
||||
for (const [key, stamps] of hits) {
|
||||
const kept = stamps.filter(stamp => stamp > cutoff);
|
||||
if (kept.length) hits.set(key, kept);
|
||||
else hits.delete(key);
|
||||
}
|
||||
lastPrune = at;
|
||||
}
|
||||
|
||||
return {
|
||||
take(key) {
|
||||
const at = now();
|
||||
// Pruning is at most once per window, plus whenever the map outgrows its ceiling, so the
|
||||
// bookkeeping stays bounded no matter how many distinct callers appear.
|
||||
if (at - lastPrune >= windowMs || hits.size > maxKeys) prune(at);
|
||||
const window = (hits.get(key) || []).filter(stamp => stamp > at - windowMs);
|
||||
if (window.length >= limit) {
|
||||
hits.set(key, window);
|
||||
return { allowed: false, remaining: 0, retryAfterMs: Math.max(0, window[0] + windowMs - at) };
|
||||
}
|
||||
window.push(at);
|
||||
hits.set(key, window);
|
||||
if (hits.size > maxKeys) {
|
||||
for (const oldest of hits.keys()) {
|
||||
if (hits.size <= maxKeys) break;
|
||||
hits.delete(oldest);
|
||||
}
|
||||
}
|
||||
return { allowed: true, remaining: Math.max(0, limit - window.length), retryAfterMs: 0 };
|
||||
},
|
||||
size() {
|
||||
return hits.size;
|
||||
},
|
||||
reset() {
|
||||
hits.clear();
|
||||
lastPrune = now();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// End-to-end proof for issue #16 at the real boundary: the server process that ships, not a mock of
|
||||
// it. Every image here is synthetic container bytes assembled in this file, and the provider is a
|
||||
// local stub that counts requests, so "the provider was never called" is measurable rather than
|
||||
// assumed.
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
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);
|
||||
return Buffer.concat([length, Buffer.from(type, 'ascii'), data, Buffer.alloc(4)]);
|
||||
}
|
||||
function cleanJpeg({ width = 1200, height = 900, tail = Buffer.alloc(0) } = {}) {
|
||||
const sof = Buffer.alloc(17);
|
||||
sof.writeUInt16BE(0xffc0, 0);
|
||||
sof.writeUInt16BE(15, 2);
|
||||
sof[4] = 8;
|
||||
sof.writeUInt16BE(height, 5);
|
||||
sof.writeUInt16BE(width, 7);
|
||||
sof[9] = 3;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
sof[10 + i * 3] = i + 1;
|
||||
sof[11 + i * 3] = 0x11;
|
||||
}
|
||||
return Buffer.concat([Buffer.from([0xff, 0xd8]), sof, Buffer.from([0xff, 0xd9]), tail]);
|
||||
}
|
||||
function polyglotPng() {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(1200, 0);
|
||||
ihdr.writeUInt32BE(900, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2;
|
||||
return Buffer.concat([
|
||||
PNG_SIGNATURE,
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('IDAT', Buffer.from([0x78, 0x9c, 0x01])),
|
||||
pngChunk('IEND'),
|
||||
Buffer.from('<html><script>alert(1)</script></html>', 'utf8'),
|
||||
]);
|
||||
}
|
||||
|
||||
const dataUrl = (mime, bytes) => `data:${mime};base64,${bytes.toString('base64')}`;
|
||||
|
||||
async function freePort() {
|
||||
const server = net.createServer();
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address();
|
||||
server.close();
|
||||
return port;
|
||||
}
|
||||
|
||||
async function startProvider() {
|
||||
const state = { calls: 0 };
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ data: [{ id: 'ingress-test-model' }] }));
|
||||
return;
|
||||
}
|
||||
state.calls += 1;
|
||||
for await (const _ of req) { /* drain */ }
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
isStool: true, bristolType: 4, color: 'brown', confidence: 0.8,
|
||||
imageQuality: 'good', observations: 'Synthetic test card.',
|
||||
}),
|
||||
},
|
||||
}],
|
||||
}));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return {
|
||||
state,
|
||||
port: server.address().port,
|
||||
// fetch keeps connections alive, so a plain close() would wait for sockets the test already
|
||||
// finished with and hang teardown.
|
||||
close: () => new Promise(resolve => {
|
||||
server.closeAllConnections?.();
|
||||
server.close(resolve);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(env, run) {
|
||||
const provider = await startProvider();
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['server.mjs'], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(port),
|
||||
TIMMY_VISION_ENABLED: 'true',
|
||||
TIMMY_VISION_PROFILE: 'selfhost',
|
||||
TIMMY_VISION_BASE_URL: `http://127.0.0.1:${provider.port}/v1`,
|
||||
TIMMY_VISION_MODEL: 'ingress-test-model',
|
||||
...env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const logs = [];
|
||||
child.stdout.on('data', chunk => logs.push(chunk.toString()));
|
||||
child.stderr.on('data', chunk => logs.push(chunk.toString()));
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
// Readiness is the server's own health route, not a sleep.
|
||||
const deadline = Date.now() + 15_000;
|
||||
for (;;) {
|
||||
try {
|
||||
const response = await fetch(`${base}/api/healthz`);
|
||||
if (response.ok) break;
|
||||
} catch { /* not up yet */ }
|
||||
if (Date.now() > deadline) throw new Error(`server did not start: ${logs.join('')}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
await run({ base, provider, logs });
|
||||
} finally {
|
||||
child.kill('SIGKILL');
|
||||
await provider.close();
|
||||
}
|
||||
}
|
||||
|
||||
const postAnalyze = (base, payload) => fetch(`${base}/api/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
test('the shipping server refuses bytes that are not an image before any provider work', async () => {
|
||||
await withServer({}, async ({ base, provider }) => {
|
||||
const response = await postAnalyze(base, {
|
||||
consent: true,
|
||||
imageDataUrl: dataUrl('image/jpeg', Buffer.from('<html><script>alert(1)</script></html>', 'utf8')),
|
||||
});
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(body.error, /not a JPEG, PNG, or WebP photo/i);
|
||||
assert.equal(provider.state.calls, 0, 'a non-image must never reach the model provider');
|
||||
});
|
||||
});
|
||||
|
||||
test('the shipping server refuses a valid image with a hidden payload appended', async () => {
|
||||
await withServer({}, async ({ base, provider, logs }) => {
|
||||
const response = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/png', polyglotPng()) });
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(body.error, /extra data after the image/i);
|
||||
assert.equal(provider.state.calls, 0, 'a polyglot file must never reach the model provider');
|
||||
// The refusal path must not copy the upload into the process log, where it would outlive the request.
|
||||
const logged = logs.join('');
|
||||
assert.doesNotMatch(logged, /base64/i);
|
||||
assert.doesNotMatch(logged, /[A-Za-z0-9+/]{120,}/, 'no run of image bytes may appear in the log');
|
||||
});
|
||||
});
|
||||
|
||||
test('the shipping server still analyses a clean re-encoded image', async () => {
|
||||
await withServer({}, async ({ base, provider }) => {
|
||||
const response = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) });
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.status, 'suggestion');
|
||||
assert.equal(provider.state.calls, 1, 'the clean path must reach the provider exactly once');
|
||||
});
|
||||
});
|
||||
|
||||
test('the shipping server refuses a flood with 429 and stops calling the provider', async () => {
|
||||
await withServer({ TIMMY_ANALYZE_RATE_LIMIT: '2', TIMMY_ANALYZE_RATE_WINDOW_MS: '60000' }, async ({ base, provider }) => {
|
||||
const payload = { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) };
|
||||
assert.equal((await postAnalyze(base, payload)).status, 200);
|
||||
assert.equal((await postAnalyze(base, payload)).status, 200);
|
||||
const refused = await postAnalyze(base, payload);
|
||||
const body = await refused.json();
|
||||
assert.equal(refused.status, 429);
|
||||
assert.match(body.error, /too many photos|wait a minute/i);
|
||||
assert.ok(Number(refused.headers.get('retry-after')) > 0, 'a refusal must say how long to wait');
|
||||
assert.equal(provider.state.calls, 2, 'a refused request must not reach the provider');
|
||||
});
|
||||
});
|
||||
|
||||
test('the shipping server gives up on a body that never arrives', async () => {
|
||||
await withServer({ TIMMY_BODY_TIMEOUT_MS: '250' }, async ({ base, provider }) => {
|
||||
const port = Number(new URL(base).port);
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
const socket = net.connect(port, '127.0.0.1', () => {
|
||||
socket.write([
|
||||
'POST /api/analyze HTTP/1.1',
|
||||
'Host: 127.0.0.1',
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: 400',
|
||||
'', '',
|
||||
].join('\r\n'));
|
||||
});
|
||||
socket.setTimeout(10_000, () => { socket.destroy(); reject(new Error('no response before the client timeout')); });
|
||||
socket.on('data', chunk => {
|
||||
const line = chunk.toString('utf8').split('\r\n')[0];
|
||||
socket.destroy();
|
||||
resolve(Number(line.split(' ')[1]));
|
||||
});
|
||||
socket.on('error', error => { if (error.code !== 'ECONNRESET') reject(error); });
|
||||
});
|
||||
assert.equal(status, 408);
|
||||
assert.equal(provider.state.calls, 0, 'a stalled body must never reach the provider');
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><html><body>This file is named .jpg but it is not an image. Synthetic fixture for the image-ingress refusal path.</body></html>
|
||||
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { BodyError, readJsonBody } from '../src/http-body.js';
|
||||
|
||||
const bodyStream = chunks => {
|
||||
const stream = new PassThrough();
|
||||
for (const chunk of chunks) stream.write(chunk);
|
||||
stream.end();
|
||||
return stream;
|
||||
};
|
||||
|
||||
test('reads a bounded JSON request body', async () => {
|
||||
const parsed = await readJsonBody(bodyStream([Buffer.from('{"consent":'), Buffer.from('true}')]), { maxBytes: 1024 });
|
||||
assert.deepEqual(parsed, { consent: true });
|
||||
});
|
||||
|
||||
test('refuses a body above the byte limit instead of buffering it', async () => {
|
||||
await assert.rejects(
|
||||
() => readJsonBody(bodyStream([Buffer.alloc(64, 0x61)]), { maxBytes: 16 }),
|
||||
error => {
|
||||
assert.ok(error instanceof BodyError);
|
||||
assert.equal(error.code, 'too_large');
|
||||
assert.equal(error.status, 413);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses malformed JSON with a client error', async () => {
|
||||
await assert.rejects(
|
||||
() => readJsonBody(bodyStream([Buffer.from('{not json')]), { maxBytes: 1024 }),
|
||||
error => {
|
||||
assert.ok(error instanceof BodyError);
|
||||
assert.equal(error.code, 'invalid_json');
|
||||
assert.equal(error.status, 400);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('gives up on a body that never finishes and stops reading it', async () => {
|
||||
const stalled = new PassThrough();
|
||||
stalled.write(Buffer.from('{"consent":true'));
|
||||
const started = Date.now();
|
||||
await assert.rejects(
|
||||
() => readJsonBody(stalled, { maxBytes: 1024, timeoutMs: 40 }),
|
||||
error => {
|
||||
assert.ok(error instanceof BodyError);
|
||||
assert.equal(error.code, 'body_timeout');
|
||||
assert.equal(error.status, 408);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.ok(Date.now() - started < 2_000, 'the deadline must fire promptly');
|
||||
assert.equal(stalled.isPaused(), true, 'the stalled body must stop being read after the deadline');
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createRateLimiter } from '../src/rate-limit.js';
|
||||
|
||||
// A clock the test owns, so no test sleeps and every window boundary is exact.
|
||||
function manualClock(start = 1_700_000_000_000) {
|
||||
let current = start;
|
||||
return { now: () => current, advance: ms => { current += ms; } };
|
||||
}
|
||||
|
||||
test('allows requests up to the limit and refuses the next one', () => {
|
||||
const clock = manualClock();
|
||||
const limiter = createRateLimiter({ limit: 3, windowMs: 60_000, now: clock.now });
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
const verdict = limiter.take('device-a');
|
||||
assert.equal(verdict.allowed, true, `attempt ${attempt} must be allowed`);
|
||||
assert.equal(verdict.remaining, 3 - attempt);
|
||||
}
|
||||
|
||||
const refused = limiter.take('device-a');
|
||||
assert.equal(refused.allowed, false);
|
||||
assert.equal(refused.remaining, 0);
|
||||
assert.equal(refused.retryAfterMs, 60_000);
|
||||
});
|
||||
|
||||
test('counts each caller on its own budget', () => {
|
||||
const clock = manualClock();
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, now: clock.now });
|
||||
|
||||
assert.equal(limiter.take('device-a').allowed, true);
|
||||
assert.equal(limiter.take('device-a').allowed, false);
|
||||
assert.equal(limiter.take('device-b').allowed, true);
|
||||
});
|
||||
|
||||
test('lets a caller back in once its window has passed', () => {
|
||||
const clock = manualClock();
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 60_000, now: clock.now });
|
||||
|
||||
assert.equal(limiter.take('device-a').allowed, true);
|
||||
assert.equal(limiter.take('device-a').allowed, false);
|
||||
clock.advance(60_001);
|
||||
const allowedAgain = limiter.take('device-a');
|
||||
assert.equal(allowedAgain.allowed, true);
|
||||
assert.equal(allowedAgain.remaining, 0);
|
||||
});
|
||||
|
||||
test('a refused request does not extend the block', () => {
|
||||
const clock = manualClock();
|
||||
const limiter = createRateLimiter({ limit: 1, windowMs: 1_000, now: clock.now });
|
||||
|
||||
assert.equal(limiter.take('device-a').allowed, true);
|
||||
clock.advance(400);
|
||||
assert.equal(limiter.take('device-a').allowed, false);
|
||||
clock.advance(601);
|
||||
// The original hit is now 1001ms old, so the window has moved on even though the caller hammered
|
||||
// the endpoint in between. A refusal must never buy a longer penalty than the caller earned.
|
||||
assert.equal(limiter.take('device-a').allowed, true);
|
||||
});
|
||||
|
||||
test('reports the wait from the oldest hit still inside the window', () => {
|
||||
const clock = manualClock();
|
||||
const limiter = createRateLimiter({ limit: 2, windowMs: 10_000, now: clock.now });
|
||||
|
||||
limiter.take('device-a');
|
||||
clock.advance(4_000);
|
||||
limiter.take('device-a');
|
||||
clock.advance(1_000);
|
||||
const refused = limiter.take('device-a');
|
||||
assert.equal(refused.allowed, false);
|
||||
assert.equal(refused.retryAfterMs, 5_000);
|
||||
});
|
||||
|
||||
test('keeps its caller map bounded when many callers appear', () => {
|
||||
const clock = manualClock();
|
||||
const limiter = createRateLimiter({ limit: 5, windowMs: 60_000, maxKeys: 8, now: clock.now });
|
||||
|
||||
for (let index = 0; index < 50; index += 1) limiter.take(`device-${index}`);
|
||||
assert.ok(limiter.size() <= 8, `expected at most 8 tracked callers, saw ${limiter.size()}`);
|
||||
});
|
||||
|
||||
test('prunes expired callers instead of growing for the life of the process', () => {
|
||||
const clock = manualClock();
|
||||
const limiter = createRateLimiter({ limit: 5, windowMs: 1_000, now: clock.now });
|
||||
|
||||
for (let index = 0; index < 20; index += 1) limiter.take(`device-${index}`);
|
||||
clock.advance(1_001);
|
||||
limiter.take('device-fresh');
|
||||
assert.equal(limiter.size(), 1);
|
||||
});
|
||||
Reference in new issue
Block a user