From ccb227921e7e169a312e92aa769cbc2bffef530e Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 22 Aug 2026 21:43:36 +0000 Subject: [PATCH] feat: harden image ingress with magic-byte validation, safe re-encode, limits, and rate control - src/image-ingress.js: sniff magic bytes independent of declared MIME, reject polyglots (embedded ZIP/script payloads), enforce 4 MB body cap and 4096 px dimension cap before decode, re-encode via fixed-argv Pillow subprocess with 15s timeout, strip EXIF/GPS/XMP/tEXt metadata, fail closed with sanitized short errors and manual fallback. - scripts/reencode_image.py: defensive decoder; decompression-bomb guarded; prints one JSON verdict line, never image bytes. - src/rate-limiter.js + server.mjs /api/analyze: bounded fixed-window per-client limiter, 429 with retry-after, no payload retention. - tests/image-ingress.test.js: hostile synthetic fixtures only (spoofed MIME, GIF/ZIP polyglots, truncated/garbage/empty images, 12000x12000 bomb, 6000x6000 oversized, EXIF+GPS, SVG-with-script); asserts provider is never reached on rejection and error messages contain no bytes/base64/stacks. Closes #16 --- package.json | 4 +- scripts/gen_ingress_fixtures.py | 80 ++++++++++++ scripts/reencode_image.py | 88 +++++++++++++ server.mjs | 11 +- src/image-ingress.js | 114 ++++++++++++++++ src/rate-limiter.js | 31 +++++ src/vision-service.js | 7 +- tests/fixtures/ingress-bomb.png | Bin 0 -> 168456 bytes tests/fixtures/ingress-clean.jpg | Bin 0 -> 1162 bytes tests/fixtures/ingress-empty.jpg | 0 tests/fixtures/ingress-exif.jpg | Bin 0 -> 1332 bytes tests/fixtures/ingress-garbage.jpg | Bin 0 -> 2048 bytes tests/fixtures/ingress-metadata.png | Bin 0 -> 225 bytes tests/fixtures/ingress-oversized.jpg | Bin 0 -> 563126 bytes tests/fixtures/ingress-polyglot.gif | Bin 0 -> 126 bytes tests/fixtures/ingress-script.svg | 1 + tests/fixtures/ingress-spoofed.html | 1 + tests/fixtures/ingress-truncated.jpg | Bin 0 -> 581 bytes tests/fixtures/ingress-zip-polyglot.jpg | Bin 0 -> 36 bytes tests/image-ingress.test.js | 164 ++++++++++++++++++++++++ tests/rate-limiter.test.js | 26 ++++ tests/vision-service.test.js | 20 ++- 22 files changed, 535 insertions(+), 12 deletions(-) create mode 100644 scripts/gen_ingress_fixtures.py create mode 100644 scripts/reencode_image.py create mode 100644 src/image-ingress.js create mode 100644 src/rate-limiter.js create mode 100644 tests/fixtures/ingress-bomb.png create mode 100644 tests/fixtures/ingress-clean.jpg create mode 100644 tests/fixtures/ingress-empty.jpg create mode 100644 tests/fixtures/ingress-exif.jpg create mode 100644 tests/fixtures/ingress-garbage.jpg create mode 100644 tests/fixtures/ingress-metadata.png create mode 100644 tests/fixtures/ingress-oversized.jpg create mode 100644 tests/fixtures/ingress-polyglot.gif create mode 100644 tests/fixtures/ingress-script.svg create mode 100644 tests/fixtures/ingress-spoofed.html create mode 100644 tests/fixtures/ingress-truncated.jpg create mode 100644 tests/fixtures/ingress-zip-polyglot.jpg create mode 100644 tests/image-ingress.test.js create mode 100644 tests/rate-limiter.test.js 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 0000000000000000000000000000000000000000..f9ef2066ebc97b3b9daa51c6c5c238bae03082e4 GIT binary patch literal 168456 zcmeI*PiWV59LMpGI{)N9EJ7q44eHRr!k~k&3KxsH4*kQ!K!l+u^PoY@h`=>36}0Tw zrAvoSi919>2Pc$z>JpTQkVHh_ED$^h68+w8uisYP@)y;34CMFwlzxBi@q51C--Ba& z=f&qoHm!ePeQRyg=$_%1TYKzk{aV-g5AC(bzIn8*Xl-sCi>KXFUa+fyv`ZlxbC?*6MI`J}cZ+Td#-dB%ZsBf` z7>q?E+%4QKx-A%sE`_^=yG3F!7LjnbaJT5TU@W>6?iTJAiNRPz!rj8%qT7P8=u)^_ zxLYI!V-X2=3wMid3&x^L;cnq>kr<3cB-|~Q=iTDXiR)wa9*>_qd-I8j`$rbv@6!WI zzh2BKG9*Z}rUDhHAPtZpQ3AGNIv_#9Jfr~E2aYyB+Nq^AVH!8 zY{hgyf`oZU10+b4fUTGgNRTiOX@CTY60jB10g1ma@%H|~t@Wq3{dnZ5_xiuOcm7H{ z)-C-pl}_;`L83Jks6Yj2fCPyWuocq*2@>WZ4UiyF0=8m0AVI=BqyZ8nO2Af32P83{?Y^N?6LmD7Kq6BQk zbU=cHc}N2!NR)uBm<~vgFb`>f1c?%`71IHUUP>JJZhCcHZ_TZJFB}|swXd}eQ-gVd z&ZAQ>NRTiOX@CTY60jB10SOZ3Aq|iqQ3AGNIv_#9Jfr~rY?>Pw)C9u3_-{+@tmHVoT`6;mLvJFU(_^;m^|N70w z7T&YjsjHjw_H}-Cr4ttwbO*vbgn|SK_lGn50K!SvMNCPBDlz^?64oHwN4{6|TCI0-)#&X@QeaG^& z^XL=|5+uw+8X!TU1Z>50K!SvMNCPBDlz^?64oHwN4{3k|i4w3C(*X$*<{=G`AW;Ig zVmcr}!aSq_5+q8%R!j#ZNSKE-K!QXG*ox`kE+r-}eRi;Z80F;9Z{L1%>80)SKbZ3L zz?oHff!3U2L4t&NNCPBDlz^?64oHwN4{3k|i4w3C(*X$*<{=G`AW;IgVmcr}!aSq_ z5+q8%R!j#ZNSKE-K!QXG*ox_Z1PSwy21t-70b4O0+`Yt?r}{V4)i&Pv{G;>JKdkFK zom_pcKQG`!q69RN4oHwN4{3k|i4w3C(*X$*<{=G`AW;IgVmcr}!aSq_5+q8%R!j#Z zNSKE-K!QXG*ox_Z1PSwy21t-70b4O0kRV|m(g2CxN(@X~AFC^lpFDf>iHZA1THCSi zm&KQG>RNCK1ql-NL>eGLq6BQkbU=cHc}N2!NR)uBm<~vgFb`>f1c?%`71IF;66PTd zkRVY4wqiOULBc$w0TLuiz*bBLBuJQtG(dtx3G`+w&K$n5vaWXG_NSlBe7x(x(9yMZ zoc|biBGFV}9y(u8Y8TiAD#$7zL82pID<%UHB+Nq^AVH!8Y{hgyf`oZU10+b4fUTGg zNRTiOX@CTY60jB10SOZ3Aq|iqQ3AGNIv_#9JfwktPU5fMU@Wh>VQMf>JC9DmAVI=B zqyZ8nO2Af32P843!lU*hbC$F{Zh!RMpHLwobtXq-d|nD2Bzf`oZU z10+b4fUTGgNRTiOX@CTY60jB10SOZ3Aq|iqQ3AGNIw0}yO8n`YjotaiW1YXz*p@3R z@)vU=Q39Gs2P83{?Y^NE2aYyB+Nq^ zAVH!8Y{hgyf`oZU10+b4fUTGgNRTiOX@ErUB;MXXxV5gf?Z+cez1RQMy?3rWJ9TyQ z;!8Nif&_`yRG3{?Y^NE2aYyB+Nq^AVH!8Y{hgyf`oZU10;GWv3u_FOkHnw;P|yebB|2d^R0cy>S5u- ziG(K700|N$U@N8r5+uw+8X!TU1Z>50K!SvMNCPBDlz^?64oHwN4{3k|i4w3C(*X$* z<{=G`AW;IgVmcr}!aSq_5+q8%R!j#ZdMWYBchjrudTVa&d*R^7t9|YCz?oHff#%eu q;1UWFBy5H>K!QXGEc>mv<;m^)FFgJ8PyO}Tjz0VR@Tq6Ur+x<${0y7` literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-clean.jpg b/tests/fixtures/ingress-clean.jpg new file mode 100644 index 0000000000000000000000000000000000000000..93a664d182623ce72c9c691bed436e6b8a1a5a8c GIT binary patch literal 1162 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*$nqK7 zV+eoUV&GwB1V$dSAcH-_pHEvZ?JA5mn-%UqZCkF%x;bHImrcF=bo~pf8|RHroi66x zYWUV<-F)4gTbZ_YQ8$5#YqoCAT=L~-_Mx>spY20YRK!}Wo$af=Ty%5h(vnAew16gj z&Dm~nJ6P_r*UO_o6OiSR4X~?+n!B{*XLcG;@uzDLS5$2?KJ%YJ_tTxaTQk1R@`pGP z*)fH2qB-d^f31t|$FyZD*cBju09_%!ZJ8<1V_mnvHf;n-{mjk+*>t@%TJl-l>S>R^ zUHSP7Z1vS;lP^DA+YR){>F6+!rF&li&G@t9bm8x;;4-YpNH#aYzkN_{AARjlkfS7=wu!yLrC=b85gqVngkcg-V$Ph+mpdJ=hPF7Y< z5ngUy5t70G0}O&33=Rwq%#2D5OoEKef{g!d!VGdXP_`B*&%h$cDx_%W$R-?^$gWfnAuRebI{N?Mn?>~P20{M#(m?B^RtG@(+{$gTbVP;_m`HPXM z9OQ997FI<=HX+AA_QXPAC8I_T5vPd@Hy-3vHV*nAnpAX=OH9S&q3TDF*T6m_&SOnv z`3&wcguiYv@Gt`tsvxrA zfF^v+*=}$$*L zX^+2M`S}ZM_0?sQFF#$|4fM$A=rE9_dtU*~__O15;qR>EsLWWqD3BqA(PFd0{j|}| zI0lvq14$);e4Pu{@L_eb&H1aFK;F0p4(YYvFohddv-M%vvC8?Yp6>X&)vj*KrK*~( zAlLuQPP*&~42-KFmwyMD@#((q@wY2`K|b3CG-FQKQBZ8HT@4C#uo0_MfkqgDr9iq6 m{s6`=Bt~LjVFA&08sraPz=B;=QMz$n`m3FJ_PPH5Zvp@Ydjq5Z literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-garbage.jpg b/tests/fixtures/ingress-garbage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dc8bb9d87092afd15aad76727f330ca531f9d979 GIT binary patch literal 2048 zcmV+b2>Qhx*@to^pLF9;3T>sRs3)e?nk7Pt@CkIbc=k|RA zLr`%!IeQQH-np5TYrg`X>gf`E$K0RUjqInf)A#ArXb6Q*qzD%?5^(D`?A{E<y(AdiUAVuQrgMGh~Hob;^xoe@CRr^UByFwHEC@t9CMfeNdb$h2h5e-jv-;j`; zHm=s`Rjw{{O6*Xhr%oLLF<4F4VwXG)LjAh|4{!oxu~YPj9j($nrTlaPqAVcAlh{3G z@Y4H@1#y+&|6&qH2OjKI4@+Zh^8D7jDkk^j$Ub7L)Nqg09&Mrh!1{K7&{MwU^*(6K z_aw&y%x943*i+tqc*#jko|IAQY75g6Xf`FLd@Icw^1A5&30XB>W>mX+BASw;qQ^ik zkqJ->1>w8Jts~0Tlc-Sb1an6_fq~cQmhEPssR|cn{Z|-ZV#MT9&7m23Vbb~XSCa1h8YoCnU>WFK zY46E=6NBzvm9dF|3oxi7Jo`kyfVZ~yVUc5c-@18CrE;QIL~BPd!DvL(Ic9uV(QJ;a z!3`!uhJV(y;b-Tba`ch8^N-*s-Xtv^7-{z1Qgf_NNJ$z(E9Sa09{D?!m1&*PlaH1k zS2!@=8>NbsrfwG8Y3}7vQr!(emw; zx5-vz$(i_&8Z-zUErZ_(fN9X zOjg2XK2q0+#@c19bJ}rY)s}8cQ|oVJCe@}m8lbiX-xzaE7>)7F7MLE;+e+hG&|#1F zK$*QhO#rsoQ4DMA(*4Qv@_v)u0UpAtD8mqmc$4=-0V*x__6Ic zh|U5$LNl)om?Btc+9p3xv=30gK~$diiGc}bljQK0<%J_ZJHL$DeCz2T1H_;WTn$G5 z>zDU@Cktoz1O;zx)<}?xf{p6p)713AG_mW(v{Ui{)54Flj^3en2hnd7T!_iM$czJs z=pWEVTe7CUY3)YvU>17=*}r-U5?_=pY6s6B2mKQih(!>j=qbG1App8sT9p7*twefo zcST;|3A)xw9GqDg%^?yi`3p@QZu3nV2D@(JpFZa%Oal$(2 zZg=astkL9ifa*-|-aRk(TMoi|Y({XJE}8?6TYdf7kTE?cDH^yx!n!z1(eLC`Av*4h zF4qf8Uxbp&^ggHITyGUhkqd7zA}6_+I;^S89csk*@h#cZPw~(@$z_oB(!IC zLBjBCSy`LGG)_Cf+`2Yy$UsQYX6%2jqQ2HODqmWu(Q~kQKK#3NPrt~;4zXaX3qB1k z?q=^wzutRO{<@#@Vb`X0ACsv`$I@qZk%y?{-@^sGdU|H1yfRbLo70~ac~r|BWjTkf z2afIy=DYd75h=jK_3ABw4zhwQ283U?OcgS!h3rmdm z2`G=sJUTh-KV73qka>nyBTNk#8V*i0H}zS^1B%=9PzfZH>q}45FmfcXEaVk&Ga<9Z eHmwwHae+O?e5P}A+qX;N*}rhJ40C`yLhyynJO>>B literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-metadata.png b/tests/fixtures/ingress-metadata.png new file mode 100644 index 0000000000000000000000000000000000000000..cb52c640018b75b87d4a289185677d01a91c4970 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGH$sS?+S66gHf+|;}hhT_z`;>?oF zvQ*vN)RM%M#FE5g8d*I+Rbn7j?g7CJIf*4!CMJ3Y3OV_CR=Q>udImfPs@?%rBzU?w zhE&XXd!CV#!I0s|hUys~JnJP>Cj>D6-B!JAx^?xeMoQozopr094vjdH?_b literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-oversized.jpg b/tests/fixtures/ingress-oversized.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5e40db747e796aa264ef3f9035bc575f28ce2d42 GIT binary patch literal 563126 zcmeIwNo-Vg9KiAaoA;*E)`iY6Wss0DQwlCIBoq{s7+LIK!I(k?fs<5lOGtpKMJ*;% zH#B;{1=L$rK)s=&=t&e=yy3!n0o*--yI#guFqn8!&Y$1Q|6SfcFaPg*pZcNtvCuNN z+Eoov6hf3T7EVL9#t;Nyhj!Jp#*z&bkOlK}xB zV`)`sRUEa2Og@V9QGHjqI6Y@JIz8dh!-nM+j?RVUH*WG>h+o5+dwl&9 zPd@YPbI)(w^umiTz5L3no40Iz?e#a_eCzG)@4UO?z4t%-XxGQP_w3#G$)}%v{>6cV zhYlY(e&WlozW(Oh@4o-x)Q>;?{L8Pu{r<!>vrXKfhTwzFy6KKaSF$7w5xuVW_j%9a+8s0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdb zFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?0 z00Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u< z3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs# zzyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d| z0}L?000Rs#zyJdbFu(u<3^2d|0}L?000Rs#zyJdbFu(u<3^2d|0}Py_fn5FYU#?=r A!~g&Q literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-polyglot.gif b/tests/fixtures/ingress-polyglot.gif new file mode 100644 index 0000000000000000000000000000000000000000..de73330e94bd6c12290af33b469aec44e58d0c96 GIT binary patch literal 126 ycmZ?wbhEHbWMp7uVEE6V!vF+eHWP!jO>uHjWiV^^O^d_+Y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e3eed7c7612994feb28ce8822e4dcc45c61a4343 GIT binary patch literal 581 zcmex=``2_j6xdp@o1cgOJMMZh|#U;coyG6VInuyV4pa*FVB^NNrR z{vTivX!XqN1l2cOC(lau%ic3n%$}1|Xnp;}i+B-VC zCQY6)b=ve9GiNPYykzOJeA&aSFc^aar4&0 zM~|O8efIpt%U2&ieg5+G+xH(oe}VkP$iNKo7LbH^49#DHKz}i@urRZ*gZ#zFR1U<< zf-J0xhHOHPf$WKe!b(Ps93oB=7j8VrscandK{To8BA1wo$wSqTAg_UaMx4i*Nbnf| Dqi>^Z literal 0 HcmV?d00001 diff --git a/tests/fixtures/ingress-zip-polyglot.jpg b/tests/fixtures/ingress-zip-polyglot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7b7191c262c187b5f77ac21cd06a7f19146338e6 GIT binary patch literal 36 ccmex=Bfy)P<^KZ)0WUXCw*YTeHU 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); });