diff --git a/package.json b/package.json index 31839e0..ae71401 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "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/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", + "test": "node --test tests/domain.test.js tests/analysis.test.js tests/image-ingress.test.js tests/rate-limiter.test.js tests/vision-service.test.js tests/vision-config.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", "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs", "test:staging-smoke": "node tests/staging.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/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.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", + "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/image-ingress.js && node --check src/rate-limiter.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.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/reencode_image.py", "check:diff": "bash scripts/check_diff.sh", "start": "node server.mjs" }, diff --git a/scripts/gen_ingress_fixtures.py b/scripts/gen_ingress_fixtures.py new file mode 100644 index 0000000..a88d798 --- /dev/null +++ b/scripts/gen_ingress_fixtures.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Generate synthetic hostile fixtures for image-ingress security tests. + +All content is synthetic test data. No real medical images, no external uploads. +""" +import os + +from PIL import Image, PngImagePlugin + +D = os.path.join(os.path.dirname(__file__), "..", "tests", "fixtures") +D = os.path.abspath(D) +os.makedirs(D, exist_ok=True) + +img = Image.new("RGB", (64, 64)) +for y in range(64): + for x in range(64): + img.putpixel((x, y), (x * 4 % 256, y * 4 % 256, 128)) + +# 1. Clean small JPEG (valid control) +img.save(os.path.join(D, "ingress-clean.jpg"), "JPEG", quality=90) + +# 2. JPEG with EXIF metadata (Make/Model + GPS IFD pointer) +ex = img.getexif() +ex[0x010F] = "HostileCam" +ex[0x0110] = "Model-X" +gps_ifd = ex.get_ifd(0x8825) +gps_ifd[1] = "N" # GPSLatitudeRef +gps_ifd[2] = (44, 30, 0) # GPSLatitude (degrees, minutes, seconds) +gps_ifd[4] = (68, 15, 0) # GPSLongitude +img.save(os.path.join(D, "ingress-exif.jpg"), "JPEG", quality=90, exif=ex) + +# 3. PNG with tEXt metadata chunks +png_meta = PngImagePlugin.PngInfo() +png_meta.add_text("Comment", "sensitive-metadata") +png_meta.add_text("GPS", "lat:44.0 lon:-68.0") +img.save(os.path.join(D, "ingress-metadata.png"), "PNG", pnginfo=png_meta) + +# 4. Spoofed content: HTML masquerading as an image +with open(os.path.join(D, "ingress-spoofed.html"), "wb") as f: + f.write(b"not an image") + +# 5. GIF-header polyglot with embedded script payload +poly = (b"GIF89a\x01\x00\x01\x00\x00\xff\x00,\x00\x00\x00\x00\x01\x00\x01\x00" + b"\x00\x02\x00;" + b"" * 4) +with open(os.path.join(D, "ingress-polyglot.gif"), "wb") as f: + f.write(poly) + +# 6. ZIP-in-JPEG polyglot (GIFAR-style) +jpg_bytes = open(os.path.join(D, "ingress-clean.jpg"), "rb").read() +zip_poly = jpg_bytes[:2] + b"PK\x03\x04" + jpg_bytes[2:10] + b"PK\x05\x06" + b"\x00" * 18 +with open(os.path.join(D, "ingress-zip-polyglot.jpg"), "wb") as f: + f.write(zip_poly) + +# 7. Truncated JPEG (SOI present, cut before EOI) +with open(os.path.join(D, "ingress-truncated.jpg"), "wb") as f: + f.write(jpg_bytes[: len(jpg_bytes) // 2]) + +# 8. Decompression bomb: 12000x12000 sparse PNG, tiny on disk +bomb = Image.new("L", (12000, 12000), 7) +bomb.save(os.path.join(D, "ingress-bomb.png"), "PNG", optimize=True) +print("bomb size:", os.path.getsize(os.path.join(D, "ingress-bomb.png"))) + +# 9. Oversized-dimension JPEG (6000x6000, small on disk) +big = Image.new("RGB", (6000, 6000), (90, 90, 90)) +big.save(os.path.join(D, "ingress-oversized.jpg"), "JPEG", quality=40) +print("oversized size:", os.path.getsize(os.path.join(D, "ingress-oversized.jpg"))) + +# 10. Random garbage with jpeg extension +with open(os.path.join(D, "ingress-garbage.jpg"), "wb") as f: + f.write(os.urandom(2048)) + +# 11. Empty file +open(os.path.join(D, "ingress-empty.jpg"), "wb").close() + +# 12. SVG with embedded script +with open(os.path.join(D, "ingress-script.svg"), "wb") as f: + f.write(b'' + b"") + +print("fixtures written") diff --git a/scripts/reencode_image.py b/scripts/reencode_image.py new file mode 100644 index 0000000..76a3592 --- /dev/null +++ b/scripts/reencode_image.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Re-encode one image safely: verify decode, strip metadata, bound dimensions. + +Fixed argv contract only: + reencode_image.py --in SOURCE --out TARGET [--max-bytes N] [--max-dimension N] + +Reads SOURCE, decodes defensively (decompression-bomb guarded), strips all +metadata by re-encoding to baseline JPEG, and writes TARGET. Prints one JSON +line on success. Never prints image bytes or base64 to stdout/stderr. +""" +import argparse +import io +import json +import os +import sys + +Image = None # populated in main() so import errors fail closed + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser() + p.add_argument("--in", dest="source", required=True) + p.add_argument("--out", dest="target", required=True) + p.add_argument("--max-bytes", type=int, default=4 * 1024 * 1024) + p.add_argument("--max-dimension", type=int, default=4096) + return p + + +def main() -> int: + args = parser().parse_args() + + try: + from PIL import Image as _Image + except Exception: + print(json.dumps({"ok": False, "error": "image processing is unavailable"})) + return 3 + + source_size = os.path.getsize(args.source) + if source_size <= 0 or source_size > args.max_bytes: + print(json.dumps({"ok": False, "error": "rejected"})) + return 2 + + try: + # Fail closed on decompression bombs before full pixel load. + with _Image.open(args.source) as probe: + try: + probe.load() + except _Image.DecompressionBombError: + print(json.dumps({"ok": False, "error": "dimensions"})) + return 2 + width, height = probe.size + if width > args.max_dimension or height > args.max_dimension: + print(json.dumps({"ok": False, "error": "dimensions"})) + return 2 + image = probe.convert("RGB") + except Exception: + print(json.dumps({"ok": False, "error": "malformed"})) + return 2 + + buffer = io.BytesIO() + try: + # Baseline JPEG re-encode drops EXIF/GPS/XMP/tEXt entirely. + image.save(buffer, "JPEG", quality=85, optimize=True, progressive=False) + except Exception: + print(json.dumps({"ok": False, "error": "encode"})) + return 2 + + data = buffer.getvalue() + if not data or data[:3] != b"\xff\xd8\xff" or len(data) > args.max_bytes: + print(json.dumps({"ok": False, "error": "encode"})) + return 2 + + with open(args.target, "wb") as handle: + handle.write(data) + + print(json.dumps({ + "ok": True, + "format": "jpeg", + "width": image.size[0], + "height": image.size[1], + "bytes": len(data), + "metadataStripped": True, + })) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server.mjs b/server.mjs index 82f2ba1..ca1ec59 100644 --- a/server.mjs +++ b/server.mjs @@ -4,9 +4,12 @@ import { readFile, stat } from 'node:fs/promises'; import { extname, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; import { analyzePhoto } from './src/vision-service.js'; +import { createRateLimiter } from './src/rate-limiter.js'; import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js'; import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js'; +const analyzeRateLimiter=createRateLimiter(); + const root=fileURLToPath(new URL('.',import.meta.url)); const port=Number(process.env.PORT||4173); const host=process.env.HOST||'0.0.0.0'; @@ -53,9 +56,15 @@ 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 clientKey=(req.socket&&req.socket.remoteAddress)||'unknown'; + const limit=analyzeRateLimiter.take(clientKey); + if(!limit.allowed){ + res.setHeader('retry-after',Math.ceil(limit.retryAfterMs/1000)); + return sendJson(res,429,{error:'Too many photo analyses. Please slow down and try again later.'}); + } const payload=await readJson(req); try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))} - catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})} + catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large|supported image|corrupt|malformed|slow down/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})} } if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent'))); if(appPath==='/api/agent/unlock'&&req.method==='POST'){ diff --git a/src/image-ingress.js b/src/image-ingress.js new file mode 100644 index 0000000..5b8e879 --- /dev/null +++ b/src/image-ingress.js @@ -0,0 +1,114 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const MAX_IMAGE_BYTES = 4 * 1024 * 1024; +export const MAX_IMAGE_DIMENSION = 4096; +const REENCODE_TIMEOUT_MS = 15_000; + +const MAGIC = { + jpeg: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff, + png: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47, + webp: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP', +}; + +function base64Bytes(dataUrl) { + const match = /^data:[^;,]*;base64,([A-Za-z0-9+/=\r\n]+)$/.exec(String(dataUrl || '')); + if (!match) return null; + const clean = match[1].replace(/[\r\n]/g, ''); + return Buffer.from(clean, 'base64'); +} + +export function sniffImageFormat(bytes) { + if (!Buffer.isBuffer(bytes) || bytes.length < 12) return null; + for (const [format, test] of Object.entries(MAGIC)) { + if (test(bytes)) { + // Reject container polyglots: embedded archives/scripts inside image bytes. + if (bytes.includes(Buffer.from('PK\x03\x04'))) return null; + if (bytes.includes(Buffer.from(' { + execFile( + process.env.TIMMY_PYTHON || 'python3', + [ + join(fileURLToPath(new URL('..', import.meta.url)), 'scripts', 'reencode_image.py'), + '--in', sourcePath, + '--out', targetPath, + '--max-bytes', String(MAX_IMAGE_BYTES), + '--max-dimension', String(MAX_IMAGE_DIMENSION), + ], + { timeout: REENCODE_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + if (error) { + if (error.killed || error.signal === 'SIGTERM') return reject(sanitizedError('unavailable')); + // Exit code 2 with a dimensions verdict maps to a distinct message. + if (/dimensions/.test(String(stdout))) return reject(sanitizedError('dimensions')); + return reject(sanitizedError('corrupt')); + } + try { + resolve(JSON.parse(String(stdout).trim())); + } catch { + reject(sanitizedError('corrupt')); + } + }, + ); + }); +} + +export async function validateImageIngress(payload = {}) { + if (payload.consent !== true) throw sanitizedError('consent'); + const bytes = base64Bytes(payload.imageDataUrl); + if (!bytes || bytes.length === 0) throw sanitizedError('format'); + if (bytes.length > MAX_IMAGE_BYTES) throw sanitizedError('size'); + + const format = sniffImageFormat(bytes); + if (!format) throw sanitizedError('format'); + + const workDir = await mkdtemp(join(tmpdir(), 'timmy-ingress-')); + try { + const sourcePath = join(workDir, `source.${format}`); + const targetPath = join(workDir, 'reencoded.jpg'); + await writeFile(sourcePath, bytes); + let result; + try { + result = await reencode(sourcePath, targetPath); + } catch (error) { + throw error; + } + const reencoded = await readFile(targetPath); + return { + imageDataUrl: `data:image/jpeg;base64,${reencoded.toString('base64')}`, + mime: 'image/jpeg', + format: result.format, + width: result.width, + height: result.height, + bytes: reencoded.length, + originalFormat: format, + metadataStripped: result.metadataStripped === true, + }; + } finally { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/src/rate-limiter.js b/src/rate-limiter.js new file mode 100644 index 0000000..d56a01e --- /dev/null +++ b/src/rate-limiter.js @@ -0,0 +1,31 @@ +// Fixed-window in-memory rate limiter for image analysis requests. +// Bounded: per-key counters only, no payloads stored. + +export function createRateLimiter({ windowMs = 60_000, maxRequests = 10 } = {}) { + const windows = new Map(); + + function take(key, now = Date.now()) { + let entry = windows.get(key); + if (!entry || now >= entry.resetAt) { + entry = { count: 0, resetAt: now + windowMs }; + windows.set(key, entry); + } + entry.count += 1; + if (windows.size > 10_000) { + // Bound memory: drop expired entries when the map grows too large. + for (const [existingKey, existing] of windows) { + if (now >= existing.resetAt) windows.delete(existingKey); + } + } + if (entry.count > maxRequests) { + return { + allowed: false, + retryAfterMs: Math.max(1, entry.resetAt - now), + reason: 'Too many photo analyses. Please slow down and try again later.', + }; + } + return { allowed: true, remaining: maxRequests - entry.count }; + } + + return { take, limit: maxRequests, windowMs }; +} diff --git a/src/vision-service.js b/src/vision-service.js index 6edb789..c34c141 100644 --- a/src/vision-service.js +++ b/src/vision-service.js @@ -1,4 +1,5 @@ -import { buildVisionRequest, parseVisionResponse, validatePhotoPayload } from './analysis.js'; +import { validateImageIngress } from './image-ingress.js'; +import { buildVisionRequest, parseVisionResponse } from './analysis.js'; function providerEndpoint(baseUrl) { let url; @@ -8,7 +9,9 @@ function providerEndpoint(baseUrl) { } export async function analyzePhoto({ payload, fetchImpl = fetch, config }) { - const photo = validatePhotoPayload(payload); + // Hardened ingress first: consent, magic bytes, limits, safe re-encode, + // metadata stripping. All failures happen before any provider work. + const photo = await validateImageIngress(payload); if (!config?.model) throw new Error('AI analysis is not configured.'); const endpoint = providerEndpoint(config.baseUrl); const response = await fetchImpl(endpoint, { diff --git a/tests/fixtures/ingress-bomb.png b/tests/fixtures/ingress-bomb.png new file mode 100644 index 0000000..f9ef206 Binary files /dev/null and b/tests/fixtures/ingress-bomb.png differ diff --git a/tests/fixtures/ingress-clean.jpg b/tests/fixtures/ingress-clean.jpg new file mode 100644 index 0000000..93a664d Binary files /dev/null and b/tests/fixtures/ingress-clean.jpg differ diff --git a/tests/fixtures/ingress-empty.jpg b/tests/fixtures/ingress-empty.jpg new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/ingress-exif.jpg b/tests/fixtures/ingress-exif.jpg new file mode 100644 index 0000000..73153a8 Binary files /dev/null and b/tests/fixtures/ingress-exif.jpg differ diff --git a/tests/fixtures/ingress-garbage.jpg b/tests/fixtures/ingress-garbage.jpg new file mode 100644 index 0000000..dc8bb9d Binary files /dev/null and b/tests/fixtures/ingress-garbage.jpg differ diff --git a/tests/fixtures/ingress-metadata.png b/tests/fixtures/ingress-metadata.png new file mode 100644 index 0000000..cb52c64 Binary files /dev/null and b/tests/fixtures/ingress-metadata.png differ diff --git a/tests/fixtures/ingress-oversized.jpg b/tests/fixtures/ingress-oversized.jpg new file mode 100644 index 0000000..5e40db7 Binary files /dev/null and b/tests/fixtures/ingress-oversized.jpg differ diff --git a/tests/fixtures/ingress-polyglot.gif b/tests/fixtures/ingress-polyglot.gif new file mode 100644 index 0000000..de73330 Binary files /dev/null and b/tests/fixtures/ingress-polyglot.gif differ diff --git a/tests/fixtures/ingress-script.svg b/tests/fixtures/ingress-script.svg new file mode 100644 index 0000000..f157858 --- /dev/null +++ b/tests/fixtures/ingress-script.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/ingress-spoofed.html b/tests/fixtures/ingress-spoofed.html new file mode 100644 index 0000000..dd1a1df --- /dev/null +++ b/tests/fixtures/ingress-spoofed.html @@ -0,0 +1 @@ +not an image \ No newline at end of file diff --git a/tests/fixtures/ingress-truncated.jpg b/tests/fixtures/ingress-truncated.jpg new file mode 100644 index 0000000..e3eed7c Binary files /dev/null and b/tests/fixtures/ingress-truncated.jpg differ diff --git a/tests/fixtures/ingress-zip-polyglot.jpg b/tests/fixtures/ingress-zip-polyglot.jpg new file mode 100644 index 0000000..7b7191c Binary files /dev/null and b/tests/fixtures/ingress-zip-polyglot.jpg differ diff --git a/tests/image-ingress.test.js b/tests/image-ingress.test.js new file mode 100644 index 0000000..8a86842 --- /dev/null +++ b/tests/image-ingress.test.js @@ -0,0 +1,164 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { + MAX_IMAGE_BYTES, + MAX_IMAGE_DIMENSION, + sniffImageFormat, + validateImageIngress, +} from '../src/image-ingress.js'; +import { analyzePhoto } from '../src/vision-service.js'; + +const fixture = (name) => fileURLToPath(new URL(`../tests/fixtures/${name}`, import.meta.url)); +const readFixture = async (name) => await readFile(fixture(name)); + +function dataUrl(bytes, mime) { + return `data:${mime};base64,${bytes.toString('base64')}`; +} + +async function ingestFixture(name, mime = 'image/jpeg', overrides = {}) { + const bytes = await readFixture(name); + return validateImageIngress({ + imageDataUrl: dataUrl(bytes, mime), + consent: true, + ...overrides, + }); +} + +test('clean synthetic JPEG passes ingress and is re-encoded without metadata', async () => { + const result = await ingestFixture('ingress-clean.jpg'); + assert.equal(result.format, 'jpeg'); + assert.ok(result.bytes <= MAX_IMAGE_BYTES); + assert.match(result.imageDataUrl, /^data:image\/jpeg;base64,/); + const exifCount = execFileSync('python3', [ + '-c', + 'from PIL import Image;import sys,base64;print(len(Image.open(__import__("io").BytesIO(base64.b64decode(sys.argv[1]))).getexif()))', + result.imageDataUrl.split(',')[1], + ], { encoding: 'utf8' }); + assert.equal(exifCount.trim(), '0'); +}); + +test('magic bytes are verified independent of declared MIME', async () => { + // HTML payload wearing a JPEG content type must be rejected. + await assert.rejects( + () => ingestFixture('ingress-spoofed.html'), + /not a supported image/i, + ); + // A real PNG declared as JPEG must still be accepted by sniffing, not by MIME. + const png = await ingestFixture('ingress-metadata.png', 'image/jpeg'); + assert.equal(png.originalFormat, 'png'); + assert.equal(png.format, 'jpeg'); +}); + +test('polyglot payloads are rejected', async () => { + await assert.rejects(() => ingestFixture('ingress-polyglot.gif'), /not a supported image|rejected|unsafe/i); + await assert.rejects(() => ingestFixture('ingress-zip-polyglot.jpg'), /rejected|unsafe|corrupt|malformed|not a supported image/i); +}); + +test('malformed and truncated images fail closed with sanitized errors', async () => { + for (const [name, mime] of [['ingress-garbage.jpg', 'image/jpeg'], ['ingress-empty.jpg', 'image/jpeg']]) { + await assert.rejects(() => ingestFixture(name, mime), (error) => { + assert.match(error.message, /upload a jpeg, png, or webp photo/i); + return true; + }); + } + await assert.rejects(() => ingestFixture('ingress-truncated.jpg'), /corrupt or malformed/i); +}); + +test('decompression bombs and oversized dimensions fail before provider work', async () => { + await assert.rejects(() => ingestFixture('ingress-bomb.png'), /too large|dimensions/i); + await assert.rejects(() => ingestFixture('ingress-oversized.jpg'), /too large|dimensions/i); + assert.equal(MAX_IMAGE_DIMENSION <= 4096, true); +}); + +test('EXIF and GPS metadata are stripped from the re-encoded image', async () => { + const result = await ingestFixture('ingress-exif.jpg'); + assert.equal(result.metadataStripped, true); + const check = execFileSync('python3', ['-c', + 'from PIL import Image;import sys,base64,io;' + + 'im=Image.open(io.BytesIO(base64.b64decode(sys.argv[1])));' + + 'ex=im.getexif();' + + 'gps=ex.get_ifd(0x8825);' + + 'print("MAKE" if ex.get(0x010F) else "CLEAN", "GPS" if gps else "CLEAN")', + result.imageDataUrl.split(',')[1], + ], { encoding: 'utf8' }); + assert.equal(check.trim(), 'CLEAN CLEAN'); + const pngResult = await ingestFixture('ingress-metadata.png'); + const pngCheck = execFileSync('python3', ['-c', + 'from PIL import Image;import sys,base64,io;' + + 'im=Image.open(io.BytesIO(base64.b64decode(sys.argv[1])));' + + 'info=getattr(im,"text",{}) or {};' + + 'print("TEXT" if info else "CLEAN", im.format.lower())', + pngResult.imageDataUrl.split(',')[1], + ], { encoding: 'utf8' }); + assert.equal(pngCheck.trim(), 'CLEAN jpeg'); +}); + +test('body limit rejects oversized base64 bodies before decoding', async () => { + const huge = Buffer.alloc(MAX_IMAGE_BYTES + 1024, 65); + await assert.rejects( + () => validateImageIngress({ imageDataUrl: dataUrl(huge, 'image/jpeg'), consent: true }), + /under \d+ mb/i, + ); +}); + +test('consent is still required after hardening', async () => { + const bytes = await readFixture('ingress-clean.jpg'); + await assert.rejects( + () => validateImageIngress({ imageDataUrl: dataUrl(bytes, 'image/jpeg') }), + /consent/i, + ); +}); + +test('SVG uploads never pass ingress regardless of extension', async () => { + await assert.rejects( + () => ingestFixture('ingress-script.svg', 'image/svg+xml'), + /not a supported image/i, + ); +}); + +test('errors are sanitized: no image bytes, no base64, no stack in messages', async () => { + const cases = []; + for (const name of ['ingress-spoofed.html', 'ingress-polyglot.gif', 'ingress-truncated.jpg', 'ingress-bomb.png']) { + try { await ingestFixture(name); } catch (error) { cases.push(error.message); } + } + for (const message of cases) { + assert.doesNotMatch(message, /[A-Za-z0-9+/]{40,}/); + assert.doesNotMatch(message, /at\s+\S+\s+\(/); + assert.ok(message.length < 200); + } +}); + +test('rate limiter allows a bounded burst then fails closed with sanitized retry message', async () => { + const { createRateLimiter } = await import('../src/rate-limiter.js'); + const limiter = createRateLimiter(); + const first = limiter.take('client-a', Date.now()); + assert.equal(first.allowed, true); + for (let i = 0; i < limiter.limit - 1; i += 1) { + assert.equal(limiter.take('client-a', Date.now()).allowed, true); + } + const blocked = limiter.take('client-a', Date.now()); + assert.equal(blocked.allowed, false); + assert.match(blocked.reason, /try again later|slow down/i); + assert.ok(blocked.retryAfterMs > 0); + assert.doesNotMatch(blocked.reason, /image|payload|byte/i); + // A different client key is unaffected. + assert.equal(limiter.take('client-b', Date.now()).allowed, true); +}); + +test('analyzePhoto routes through hardened ingress before provider fetch', async () => { + const bytes = await readFixture('ingress-polyglot.gif'); + let providerCalled = false; + await assert.rejects( + () => analyzePhoto({ + payload: { consent: true, imageDataUrl: dataUrl(bytes, 'image/gif') }, + config: { model: 'm', baseUrl: 'http://127.0.0.1:9/v1' }, + fetchImpl: async () => { providerCalled = true; throw new Error('provider reached'); }, + }), + /not a supported image|rejected|unsafe/i, + ); + assert.equal(providerCalled, false); +}); diff --git a/tests/rate-limiter.test.js b/tests/rate-limiter.test.js new file mode 100644 index 0000000..e72a7b8 --- /dev/null +++ b/tests/rate-limiter.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createRateLimiter } from '../src/rate-limiter.js'; + +test('rate limiter blocks after the configured burst and recovers next window', () => { + const limiter = createRateLimiter({ windowMs: 1000, maxRequests: 3 }); + const t0 = 1_000_000; + assert.equal(limiter.take('k', t0).allowed, true); + assert.equal(limiter.take('k', t0 + 1).allowed, true); + assert.equal(limiter.take('k', t0 + 2).allowed, true); + const blocked = limiter.take('k', t0 + 3); + assert.equal(blocked.allowed, false); + assert.ok(blocked.retryAfterMs > 0 && blocked.retryAfterMs <= 1000); + // Next window resets cleanly. + assert.equal(limiter.take('k', t0 + 1001).allowed, true); +}); + +test('rate limiter keys are isolated and never expose payload data', () => { + const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 1 }); + assert.equal(limiter.take('a').allowed, true); + assert.equal(limiter.take('b').allowed, true); + const blocked = limiter.take('a'); + assert.equal(blocked.allowed, false); + assert.doesNotMatch(blocked.reason, /image|base64|byte/i); +}); diff --git a/tests/vision-service.test.js b/tests/vision-service.test.js index df19d6e..27924e2 100644 --- a/tests/vision-service.test.js +++ b/tests/vision-service.test.js @@ -1,7 +1,13 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + import { analyzePhoto } from '../src/vision-service.js'; +const cleanJpeg = await readFile(fileURLToPath(new URL('./fixtures/ingress-clean.jpg', import.meta.url))); +const imageDataUrl = `data:image/jpeg;base64,${cleanJpeg.toString('base64')}`; + test('sends a bounded structured request to the configured provider and validates its response', async () => { let captured; const fetchImpl = async (url, options) => { @@ -15,7 +21,7 @@ test('sends a bounded structured request to the configured provider and validate }; }; const result = await analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true }, + payload: { imageDataUrl, consent: true }, fetchImpl, config: { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'secret', model: 'vision-model' }, }); @@ -28,21 +34,21 @@ test('sends a bounded structured request to the configured provider and validate test('fails closed when the provider is unavailable or malformed', async () => { await assert.rejects(() => analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true }, + payload: { imageDataUrl, consent: true }, fetchImpl: async () => ({ ok: false, status: 503, text: async () => 'upstream detail' }), - config: { baseUrl: 'http://localhost/v1', apiKey: 'x', model: 'm' }, + config: { baseUrl: 'http://localhost/v1', apiKey: 'secret', model: 'm' }, }), /temporarily unavailable/i); await assert.rejects(() => analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true }, + payload: { imageDataUrl, consent: true }, fetchImpl: async () => ({ ok: true, json: async () => ({ choices: [] }) }), - config: { baseUrl: 'http://localhost/v1', apiKey: 'x', model: 'm' }, + config: { baseUrl: 'http://localhost/v1', apiKey: 'secret', model: 'm' }, }), /invalid/i); }); test('requires an http(s) provider URL and never accepts credentials from the browser payload', async () => { await assert.rejects(() => analyzePhoto({ - payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true, apiKey: 'browser-secret' }, + payload: { imageDataUrl, consent: true, apiKey: 'injected' }, fetchImpl: async () => { throw new Error('must not call'); }, - config: { baseUrl: 'file:///tmp/provider', apiKey: 'server-secret', model: 'm' }, + config: { baseUrl: 'file:///tmp/provider', apiKey: 'secret', model: 'm' }, }), /provider URL/i); });