From ba9afe0a1d11e5a6b4ccd648737f08e5ff2d3afa Mon Sep 17 00:00:00 2001 From: Timmy Date: Tue, 20 Oct 2026 08:21:20 +0000 Subject: [PATCH 1/4] 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. --- server.mjs | 44 ++++- src/http-body.js | 75 ++++++++ src/image-ingress.js | 158 ++++++++++++++++ src/rate-limit.js | 51 ++++++ tests/analyze-ingress.acceptance.test.js | 218 +++++++++++++++++++++++ tests/fixtures/not-a-photo.jpg | 1 + tests/http-body.test.js | 58 ++++++ tests/image-ingress.test.js | 175 ++++++++++++++++++ tests/rate-limit.test.js | 91 ++++++++++ 9 files changed, 869 insertions(+), 2 deletions(-) create mode 100644 src/http-body.js create mode 100644 src/image-ingress.js create mode 100644 src/rate-limit.js create mode 100644 tests/analyze-ingress.acceptance.test.js create mode 100644 tests/fixtures/not-a-photo.jpg create mode 100644 tests/http-body.test.js create mode 100644 tests/image-ingress.test.js create mode 100644 tests/rate-limit.test.js diff --git a/server.mjs b/server.mjs index 8ce96f8..99387e4 100644 --- a/server.mjs +++ b/server.mjs @@ -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)} } diff --git a/src/http-body.js b/src/http-body.js new file mode 100644 index 0000000..041d28d --- /dev/null +++ b/src/http-body.js @@ -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); + }); +} diff --git a/src/image-ingress.js b/src/image-ingress.js new file mode 100644 index 0000000..c904314 --- /dev/null +++ b/src/image-ingress.js @@ -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 }; +} diff --git a/src/rate-limit.js b/src/rate-limit.js new file mode 100644 index 0000000..b2b20a9 --- /dev/null +++ b/src/rate-limit.js @@ -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(); + }, + }; +} diff --git a/tests/analyze-ingress.acceptance.test.js b/tests/analyze-ingress.acceptance.test.js new file mode 100644 index 0000000..58c50b0 --- /dev/null +++ b/tests/analyze-ingress.acceptance.test.js @@ -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('', '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('', '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'); + }); +}); diff --git a/tests/fixtures/not-a-photo.jpg b/tests/fixtures/not-a-photo.jpg new file mode 100644 index 0000000..38e40f0 --- /dev/null +++ b/tests/fixtures/not-a-photo.jpg @@ -0,0 +1 @@ +This file is named .jpg but it is not an image. Synthetic fixture for the image-ingress refusal path. diff --git a/tests/http-body.test.js b/tests/http-body.test.js new file mode 100644 index 0000000..998535a --- /dev/null +++ b/tests/http-body.test.js @@ -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'); +}); diff --git a/tests/image-ingress.test.js b/tests/image-ingress.test.js new file mode 100644 index 0000000..24a791f --- /dev/null +++ b/tests/image-ingress.test.js @@ -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('', '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('', '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); +}); diff --git a/tests/rate-limit.test.js b/tests/rate-limit.test.js new file mode 100644 index 0000000..3652491 --- /dev/null +++ b/tests/rate-limit.test.js @@ -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); +}); From 9b72872466db11eeb9e56594762c84bb2410cfd6 Mon Sep 17 00:00:00 2001 From: Timmy Date: Tue, 20 Oct 2026 08:21:24 +0000 Subject: [PATCH 2/4] feat(photo): give an unreadable file its own upload-free state instead of a failed analysis (#16) Selecting a file the browser cannot decode used to render "Timmy couldn't analyze that safely", which described an upload that never happened. It now states that the file isn't a photo Timmy can read and that nothing was uploaded, with manual entry and the picker both one tap away. The state is asserted in a new browser suite that also asks the running server to refuse the same bytes, and the existing mobile capture suite asserts the new copy across both viewports. --- .gitea/workflows/quality.yml | 1 + app.js | 7 ++- package.json | 5 +- tests/ingress-capture.acceptance.mjs | 70 ++++++++++++++++++++++++++++ tests/mobile-capture.acceptance.mjs | 7 ++- 5 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 tests/ingress-capture.acceptance.mjs diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index cda55fe..b472f9a 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -51,6 +51,7 @@ jobs: npm run test:ui npm run test:photo npm run test:mobile-capture + npm run test:ingress-capture npm run test:sleek npm run test:manual-fallback npm run test:provisional diff --git a/app.js b/app.js index 3ab1abd..3703f6e 100644 --- a/app.js +++ b/app.js @@ -272,20 +272,23 @@ function photoFirstBody(mode,error=''){ if(mode==='pick')return `${visionStatusHtml()}
Timmy

One photo. Two useful suggestions.

Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.

${error?`

${esc(error)}

`:''}
`; if(mode==='ready'){const readiness=visionReadiness(visionStatus);if(!readiness.ready)return `${visionStatusHtml()}Photo kept on this device

${esc(photoHint)}

${esc(readiness.message)} Nothing is uploaded, and this photo stays on this device until you save the entry.

`;const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}Photo awaiting AI analysis

${esc(photoHint)}

`;} if(mode==='analyzing')return `
Timmy

Timmy is looking at form and color…

Not symptoms. Not disease. Not whether Taco Bell was a strategic error.

`; + // An unreadable file is its own state, not a failed analysis: nothing was sent, nothing was + // attempted, and saying "couldn't analyze that safely" here implied an upload that never happened. + if(mode==='unreadable')return `
⚠

That file isn’t a photo Timmy can read.

Nothing was uploaded. Choose a JPEG, PNG, or WebP photo, take a new one, or fill it out manually.

`; if(mode==='error')return `
↻

Timmy couldn’t analyze that safely.

${esc(error||'Continue manually or try a clearer photo.')}

`; if(suggestionState.status==='pending'){const copy=provenanceCopy(suggestionState,form);return `
${esc(copy.label)} · ${Math.round(suggestionState.suggestion.confidence*100)}% CONFIDENCE
BRISTOL FORMType ${suggestionState.suggestion.bristolType}
VISIBLE COLOR${esc(suggestionState.suggestion.color)}

${esc(aiSuggestion.observations||'Visual match found.')}

${esc(copy.detail)}

${esc(aiSuggestion.warning)}

`;} return `
?

No confident match.

${esc(aiSuggestion?.reason||'The image was too uncertain to prefill safely.')}

`; } function showPhotoFirst(mode='pick',error=''){ photoFirstMode=mode; - document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()}; + document.querySelector('.sheet-backdrop')?.remove();const wrap=document.createElement('div');wrap.className='sheet-backdrop';wrap.innerHTML=``;document.body.append(wrap);document.querySelector('#close-sheet').onclick=()=>wrap.remove();wrap.onclick=e=>{if(e.target===wrap)wrap.remove()}; document.querySelectorAll('#camera-photo,#gallery-photo').forEach(file=>{file.onchange=handleAiPhoto;file.addEventListener('cancel',()=>showPhotoFirst('pick','Camera or photo picker closed. If permission was denied, allow camera access in browser settings, choose from the gallery, or continue without AI.'))}); const consent=document.querySelector('#ai-consent'),analyze=document.querySelector('#analyze-photo');if(consent&&analyze)consent.onchange=()=>analyze.disabled=!consent.checked||!visionReadiness(visionStatus).ready;if(analyze)analyze.onclick=runAiAnalysis; document.querySelector('#retake-photo')?.addEventListener('click',()=>{form.photoDataUrl='';photoHint='';resetSuggestion();showPhotoFirst('pick')}); document.querySelector('#manual-from-scan')?.addEventListener('click',()=>{resetSuggestion();showLogStep(1)}); document.querySelector('#use-suggestion')?.addEventListener('click',()=>{suggestionState=confirmSuggestion(suggestionState);form=mergeVisualSuggestion(form,aiSuggestion);form.typeChosen=true;showLogStep(1)}); } -async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}} +async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);form.photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{form.photoDataUrl='';photoHint='';showPhotoFirst('unreadable')}} async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:form.photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;suggestionState=createSuggestionState(data);showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}} function openLogger(){beginDraft();photoHint='';resetSuggestion();showLogStep(1)} diff --git a/package.json b/package.json index 8c299df..0c0d950 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,11 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js", + "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js tests/escalation-regression.test.js tests/grounded-chat.test.js tests/photo-lifecycle.test.js tests/image-ingress.test.js tests/rate-limit.test.js tests/http-body.test.js tests/analyze-ingress.acceptance.test.js", "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:mobile-capture": "node tests/mobile-capture.acceptance.mjs", + "test:ingress-capture": "node tests/ingress-capture.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs", "test:manual-fallback": "node tests/manual-fallback.acceptance.mjs", "test:provisional": "node tests/provisional-suggestion.acceptance.mjs", @@ -17,7 +18,7 @@ "test:pattern-summary": "node tests/pattern-summary.acceptance.mjs", "test:grounded-chat": "node tests/grounded-chat.acceptance.mjs", "test:photo-lifecycle": "node tests/photo-lifecycle.acceptance.mjs", - "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh", + "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/image-ingress.js && node --check src/rate-limit.js && node --check src/http-body.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check src/photo-lifecycle.js && node --check src/grounded-chat.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && node --check tests/photo-lifecycle.acceptance.mjs && node --check tests/grounded-chat.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh && node --check tests/ingress-capture.acceptance.mjs && node --check tests/analyze-ingress.acceptance.test.js", "check:diff": "bash scripts/check_diff.sh", "start": "node server.mjs", "test:worker-readiness": "node tests/worker-readiness.acceptance.mjs" diff --git a/tests/ingress-capture.acceptance.mjs b/tests/ingress-capture.acceptance.mjs new file mode 100644 index 0000000..1a491d2 --- /dev/null +++ b/tests/ingress-capture.acceptance.mjs @@ -0,0 +1,70 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; + +// Issue #16, as a user experiences it: a file that only claims to be a photo never becomes an upload, +// never reaches the model worker, and still leaves manual logging one tap away. The second half of +// this suite asks the real running server to refuse the same bytes, so the browser copy and the +// server boundary are proven in the same run. + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + serviceWorkers: 'block', +}); +const page = await context.newPage(); +let analysisRequests = 0; +const analyzePayloads = []; +await page.route('**/api/vision-status', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ enabled: true, profile: 'selfhost', providerReady: true, modelSeen: true, model: 'synthetic-test-model' }), +})); +await page.route('**/api/analyze', route => { + analysisRequests += 1; + analyzePayloads.push(route.request().postData() || ''); + return route.continue(); +}); + +await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); +await page.locator('[data-scan]').click(); + +const spoofed = Buffer.from('', 'utf8'); +await page.locator('#gallery-photo').setInputFiles({ name: 'photo.jpg', mimeType: 'image/jpeg', buffer: spoofed }); + +const refusal = page.locator('[data-capture-error="unreadable"]'); +await refusal.waitFor(); +const copy = await refusal.innerText(); +assert.match(copy, /isn.t a photo Timmy can read/i, 'the refusal names what is wrong with the file'); +assert.match(copy, /Nothing was uploaded/i, 'the refusal states that nothing left the device'); +assert.equal(await page.getByText(/couldn.t analyze that safely/i).count(), 0, + 'an unreadable file must not be reported as a failed analysis'); +assert.equal(analysisRequests, 0, 'an unreadable file must never be posted to /api/analyze'); +assert.equal(await page.locator('#manual-from-scan').isVisible(), true, 'manual entry stays one tap away'); +assert.equal(await page.locator('#retake-photo').isVisible(), true, 'the picker stays reachable'); + +// The same bytes, offered to the running server directly, are refused before any provider work. +const serverResponse = await page.evaluate(async payload => { + const response = await fetch(`${document.querySelector('meta[name="timmy-base-path"]')?.content.replace(/\/$/, '') || ''}/api/analyze`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + return { status: response.status, body: await response.json() }; +}, { consent: true, imageDataUrl: `data:image/jpeg;base64,${spoofed.toString('base64')}` }); +assert.equal(serverResponse.status, 400, `the server must refuse non-image bytes, saw ${serverResponse.status}`); +assert.match(serverResponse.body.error, /not a JPEG, PNG, or WebP photo/i); +// Only this deliberate probe reached the endpoint; the app itself never posted the unreadable file. +assert.equal(analysisRequests, 1, 'only the deliberate server probe may reach /api/analyze so far'); +assert.match(analyzePayloads[0], /"consent":true/); + +// A real (synthetic) photo still reaches the consent step, so the gate is a boundary and not a wall. +await page.locator('#retake-photo').click(); +await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); +await page.locator('#ai-consent').waitFor(); +assert.equal(await page.locator('[data-capture-error="unreadable"]').count(), 0); +assert.equal(analysisRequests, 1, 'a photo that is only selected is still not uploaded before consent'); + +await context.close(); +await browser.close(); +console.log('PASS unreadable photo bytes are refused on device and at the server, with manual entry intact'); diff --git a/tests/mobile-capture.acceptance.mjs b/tests/mobile-capture.acceptance.mjs index d704f29..3123852 100644 --- a/tests/mobile-capture.acceptance.mjs +++ b/tests/mobile-capture.acceptance.mjs @@ -47,8 +47,11 @@ for (const viewport of viewports) { mimeType: 'image/jpeg', buffer: Buffer.from('not an image'), }); - assert.match(await page.locator('.scan-result').innerText(), /could not be read/i); - await page.getByText('Try another photo', { exact: true }).click(); + assert.match(await page.locator('.scan-result').innerText(), /isn.t a photo Timmy can read/i); + // Issue #16: the refusal is its own state and states plainly that nothing left the device, instead + // of borrowing the "couldn't analyze that safely" copy from a failed upload. + assert.match(await page.locator('.scan-result').innerText(), /Nothing was uploaded/i); + await page.getByText('Choose another photo', { exact: true }).click(); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); assert.equal(await page.locator('#analyze-photo').isDisabled(), true); assert.equal(await page.locator('#retake-photo').isVisible(), true); From 2b773ba58c4b1e383e84f0a0a49c8bc66ba21b99 Mon Sep 17 00:00:00 2001 From: Timmy Date: Tue, 20 Oct 2026 08:21:24 +0000 Subject: [PATCH 3/4] test(release): record the ingress refusal on camera and gate its caption (#16) The demo now picks a committed text fixture that only claims to be a photo, asserts on camera that nothing was posted to /api/analyze, and keeps manual entry in the same scene. The builder requires that caption to have painted and records image_ingress_refusal in the manifest; the threat model names each implemented control, its module, and the tests that enforce it. --- docs/PHOTO-DATAFLOW-THREAT-MODEL.md | 21 +++++++++++++++++++++ scripts/build_release.py | 5 ++++- scripts/record_release_demo.mjs | 20 ++++++++++++++++++++ tests/release-demo.test.js | 17 +++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/docs/PHOTO-DATAFLOW-THREAT-MODEL.md b/docs/PHOTO-DATAFLOW-THREAT-MODEL.md index d7995fc..17df7e7 100644 --- a/docs/PHOTO-DATAFLOW-THREAT-MODEL.md +++ b/docs/PHOTO-DATAFLOW-THREAT-MODEL.md @@ -144,6 +144,26 @@ Each entry names the abuse case, the mitigation that is already committed or que - **Testable follow-up:** #39 - **Evidence:** The production topology, secrets, backup, and rollback runbook, exercised against the deletion path so that purge reaches memory, temporary files, logs, and backups, with observability from #41 confirming no bytes reach the metrics surface. +## Ingress controls implemented for #16 + +`/api/analyze` is the only route that accepts image bytes, and every limit below is decided inside +`server.mjs` before `analyzePhoto` runs, so a refusal never costs a provider call. + +| Control | Where | Behaviour | +|---|---|---| +| Per-caller request budget | `src/rate-limit.js` | Sliding window, 12 requests per 60s by default (`TIMMY_ANALYZE_RATE_LIMIT`, `TIMMY_ANALYZE_RATE_WINDOW_MS`); refusal is `429` with `Retry-After`. The key is a salted SHA-256 of the socket address held in process memory only — no address, no identity, no bytes are stored. | +| Body ceiling and deadline | `src/http-body.js` | 6 MB ceiling and a 15s deadline (`TIMMY_BODY_TIMEOUT_MS`); `413` and `408` respectively, and the half-read socket is dropped after the refusal is written. | +| Container sniffing | `src/image-ingress.js` | The declared data-URL MIME is treated as a claim, never evidence: JPEG/PNG/WebP is decided from magic bytes, and a mismatch is refused. | +| Polyglot / trailing payloads | `src/image-ingress.js` | The container is walked to its own end marker (PNG `IEND`, JPEG `EOI`, RIFF length); anything appended after the image is refused instead of forwarded. | +| Embedded metadata | `src/image-ingress.js` | JPEG APP1/APP13/COM segments and PNG `eXIf`/`tEXt`/`zTXt`/`iTXt`/`tIME` chunks are refused. The app's own upload is a canvas re-encode (JPEG q0.7, longest edge 1200px) and carries none, so the accepted path is already metadata-free. | +| Pixel limits | `src/image-ingress.js` | Dimensions are read from the header (PNG `IHDR`, JPEG SOF, WebP `VP8X`/`VP8`/`VP8L`) and refused above a 4096px edge or 16 megapixels, so a decode bomb never reaches the worker. | +| Log hygiene | `server.mjs` | Refusal copy is fixed prose; no request body, image bytes, hash, or address is written to a log or returned in an error. | + +Residual, and not claimed as solved here: the server refuses metadata-bearing or mismatched +containers rather than transcoding them itself, so a client that cannot produce a clean re-encode +must use the app's capture path. PNG chunk CRCs are not validated (structure is), and the request +budget is per process, not per cluster. + ## Accepted residual risks - **Host compromise.** Data in a running process can be read by a sufficiently privileged attacker. This is accepted for the private beta and bounded by least privilege plus the purge procedure in #39; it is not claimed as eliminated. @@ -154,4 +174,5 @@ Each entry names the abuse case, the mitigation that is already committed or que - This document is enforced by `tests/threat-model.test.js`, which asserts that the diagram covers every byte-bearing component, that each required abuse case is a numbered threat with an owner and a tracked follow-up ticket, and that the model keeps the non-diagnostic boundary and ships no real medical images. - The test runs in `npm test` and therefore inside the CI quality gate and the release build, so the threat model cannot silently drift from the code it describes. +- The ingress controls above are enforced by `tests/image-ingress.test.js`, `tests/rate-limit.test.js`, `tests/http-body.test.js`, and `tests/analyze-ingress.acceptance.test.js`, which runs the shipping server against synthetic bytes and a counting provider stub; `tests/ingress-capture.acceptance.mjs` proves the same refusal in the running app. All of them run in `npm test` or the browser gate, so a regression fails the release instead of a promise. - Boundary and boundary-adjacent claims are reviewed by the clinical and privacy gate (#22, #23) before beta; this model does not move that gate, it feeds it. diff --git a/scripts/build_release.py b/scripts/build_release.py index 5c829e7..e1187f3 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -48,6 +48,7 @@ DEMO_REQUIRED_CAPTIONS = ( "Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident", "Every pattern line names the confirmed logs behind it, and following one shows exactly those records", "Two logs is not a pattern: the summary refuses to describe a trend it cannot support", + "A file that only claims to be a photo is refused on the device — nothing is uploaded", "The answer states the count and cites the four confirmed logs it counted", "Following a citation opens exactly the confirmed log behind that answer", "Ask something the journal cannot answer and Timmy refuses instead of reassuring", @@ -143,6 +144,7 @@ def main() -> int: run(["npm", "run", "test:ui"], tree) run(["npm", "run", "test:photo"], tree) run(["npm", "run", "test:mobile-capture"], tree) + run(["npm", "run", "test:ingress-capture"], tree) run(["npm", "run", "test:sleek"], tree) run(["npm", "run", "test:manual-fallback"], tree) run(["npm", "run", "test:provisional"], tree) @@ -199,7 +201,7 @@ def main() -> int: if not contact_sheet.exists() or contact_sheet.stat().st_size == 0: raise SystemExit("Release demo contact sheet was not produced") run(["npm", "audit", "--audit-level=high"], tree) - for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs"): + for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "src/image-ingress.js", "src/rate-limit.js", "src/http-body.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs", "tests/ingress-capture.acceptance.mjs"): run(["node", "--check", file], tree) run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree) run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree) @@ -300,6 +302,7 @@ def main() -> int: "worker_post_load_readiness": "passed", "longitudinal_pattern_summary": "passed", "grounded_chat_citations": "passed", + "image_ingress_refusal": "passed", "dependency_audit_high": "passed", "syntax": "passed", "secret_scan": "passed", diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index 233e350..7079953 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -80,7 +80,9 @@ const suggestionBody = { warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.', }; let analyzeOverride = null; +let analyzeCalls = 0; await page.route('**/api/analyze', route => { + analyzeCalls += 1; const response = analyzeOverride || { status: 200, body: suggestionBody }; return route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) }); }); @@ -226,6 +228,24 @@ await indicate('label[for="camera-photo"]'); await page.locator('#camera-photo').dispatchEvent('cancel'); await page.getByText(/Camera or photo picker closed/i).waitFor(); await caption('Camera closed cleanly — gallery and manual logging are still available', 1600); +// Issue #16, on camera: the file below is committed as text and only claims to be a photo. It must +// be refused on the device, the refusal must say nothing was uploaded, and the app must never post +// it — asserted here, not narrated. +await indicate('label[for="gallery-photo"]'); +await page.locator('#gallery-photo').setInputFiles('tests/fixtures/not-a-photo.jpg'); +await page.locator('[data-capture-error="unreadable"]').waitFor(); +const unreadableCopy = await page.locator('[data-capture-error="unreadable"]').innerText(); +if (!/isn’t a photo Timmy can read/.test(unreadableCopy) || !/Nothing was uploaded/.test(unreadableCopy)) { + throw new Error(`An unreadable file must be refused with the upload-free copy, saw: ${unreadableCopy}`); +} +if (await page.getByText(/couldn’t analyze that safely/i).count() !== 0) { + throw new Error('A file that never left the device must not be reported as a failed analysis'); +} +if (analyzeCalls !== 0) throw new Error('An unreadable file was posted to /api/analyze'); +await caption('A file that only claims to be a photo is refused on the device — nothing is uploaded', 2600); +await indicate('[data-capture-error="unreadable"]'); +await caption('Manual logging stays one tap away, and the photo picker is one tap back', 2000); +await tap('#retake-photo', 450); await indicate('label[for="gallery-photo"]'); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await caption('Nothing uploads until explicit consent', 1100); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index c403ba3..4e14175 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -113,6 +113,23 @@ test('release demo visibly exercises the red-flag language coverage instead of o assert.match(builder, /A tarry note already saved in the journal escalates the same way, before any provider call/); }); +test('release demo shows a file that only claims to be a photo being refused before upload', async () => { + const demo = await readFile(demoPath, 'utf8'); + const builder = await readFile(builderPath, 'utf8'); + + // Issue #16: the recording must show the device-side refusal of a non-image, prove nothing was + // posted, and keep manual entry reachable — all asserted in the browser, not narrated. + assert.match(demo, /tests\/fixtures\/not-a-photo\.jpg/); + assert.match(demo, /\[data-capture-error="unreadable"\]/); + assert.match(demo, /Nothing was uploaded/); + assert.match(demo, /An unreadable file was posted to \/api\/analyze/); + assert.match(demo, /A file that only claims to be a photo is refused on the device — nothing is uploaded/); + assert.match(demo, /Manual logging stays one tap away, and the photo picker is one tap back/); + // The caption is a required release gate, so a later release cannot drop the scene silently. + assert.match(builder, /A file that only claims to be a photo is refused on the device — nothing is uploaded/); + assert.match(builder, /"image_ingress_refusal": "passed"/); +}); + test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => { const builder = await readFile(builderPath, 'utf8'); assert.match(builder, /"test:sleek"/); From 8ce5bc174b29215ca3eefa61b9cf5476cbb2eb3c Mon Sep 17 00:00:00 2001 From: Timmy Release Engineering Date: Sat, 31 Oct 2026 12:05:39 +0000 Subject: [PATCH 4/4] fix(ingress): refuse hostile bytes before the disabled-model shortcut (#16) The release builder runs its browser gates with TIMMY_VISION_ENABLED=false, and the 503 "AI analysis is disabled" branch ran before the request budget, body limits, and byte-level ingress guard. Under builder conditions every hostile upload answered 503, so the refusal boundary was never exercised and the ingress-capture gate this same PR added failed: AssertionError: the server must refuse non-image bytes, saw 503 Refusals now run in cost order regardless of model availability: budget, body, bytes, then the disabled switch. Verified with vision disabled: hostile bytes -> 400 not_an_image, a valid synthetic photo -> 503 with the existing copy. npm test 277 pass; ingress-capture and the other browser gates green under the builder's env. --- server.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server.mjs b/server.mjs index 99387e4..9c95cc4 100644 --- a/server.mjs +++ b/server.mjs @@ -76,8 +76,9 @@ http.createServer(async(req,res)=>{ return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy}); } if(appPath==='/api/analyze'&&req.method==='POST'){ - if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); - // Cheap refusals first, in cost order: the request budget, then the body, then the image itself. + // Refusals first, in cost order: the request budget, then the body, then the image bytes. None of + // these depend on the model, so hostile bytes are refused as hostile even when analysis is off — + // otherwise the disabled switch hides the ingress boundary behind a 503 and the refusal is untested. const budget=analyzeLimiter.take(clientBucket(req)); if(!budget.allowed){ res.setHeader('retry-after',String(Math.max(1,Math.ceil(budget.retryAfterMs/1000)))); @@ -94,6 +95,8 @@ http.createServer(async(req,res)=>{ } try{ingressGuard(payload)} catch(error){return sendIngressError(res,error)} + // Bytes are known to be a real photo at this point, so the disabled switch is the honest answer. + if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'}); try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))} catch(error){const failure=visionFailureResponse(error);return sendJson(res,failure.status,failure.body)} }