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'") + +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('