feat: harden image ingress with magic-byte validation, safe re-encode, limits, and rate control
Some checks failed
Quality gates / quality (pull_request) Failing after 1m38s
- 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
|
|
@ -4,12 +4,12 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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:ui": "node tests/ui.acceptance.mjs",
|
||||||
"test:photo": "node tests/photo-first.acceptance.mjs",
|
"test:photo": "node tests/photo-first.acceptance.mjs",
|
||||||
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
|
||||||
"test:staging-smoke": "node tests/staging.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",
|
"check:diff": "bash scripts/check_diff.sh",
|
||||||
"start": "node server.mjs"
|
"start": "node server.mjs"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
80
scripts/gen_ingress_fixtures.py
Normal file
|
|
@ -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"<!DOCTYPE html><html><body>not an image</body></html>")
|
||||||
|
|
||||||
|
# 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"<script>alert(1)</script>" * 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'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">'
|
||||||
|
b"<rect width='10' height='10'/></svg>")
|
||||||
|
|
||||||
|
print("fixtures written")
|
||||||
88
scripts/reencode_image.py
Normal file
|
|
@ -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())
|
||||||
11
server.mjs
|
|
@ -4,9 +4,12 @@ import { readFile, stat } from 'node:fs/promises';
|
||||||
import { extname, join, normalize } from 'node:path';
|
import { extname, join, normalize } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { analyzePhoto } from './src/vision-service.js';
|
import { analyzePhoto } from './src/vision-service.js';
|
||||||
|
import { createRateLimiter } from './src/rate-limiter.js';
|
||||||
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
|
import { probeVisionProvider, resolveVisionConfig } from './src/vision-config.js';
|
||||||
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
|
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig } from './src/hermes-agent-service.js';
|
||||||
|
|
||||||
|
const analyzeRateLimiter=createRateLimiter();
|
||||||
|
|
||||||
const root=fileURLToPath(new URL('.',import.meta.url));
|
const root=fileURLToPath(new URL('.',import.meta.url));
|
||||||
const port=Number(process.env.PORT||4173);
|
const port=Number(process.env.PORT||4173);
|
||||||
const host=process.env.HOST||'0.0.0.0';
|
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(appPath==='/api/analyze'&&req.method==='POST'){
|
||||||
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
|
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);
|
const payload=await readJson(req);
|
||||||
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
|
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/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
|
||||||
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
|
if(appPath==='/api/agent/unlock'&&req.method==='POST'){
|
||||||
|
|
|
||||||
114
src/image-ingress.js
Normal file
|
|
@ -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('<script'))) return null;
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors are sanitized by construction: fixed short strings only.
|
||||||
|
const SANITIZED = {
|
||||||
|
consent: 'Explicit consent is required before AI analysis.',
|
||||||
|
format: 'Upload a JPEG, PNG, or WebP photo. That file is not a supported image.',
|
||||||
|
size: 'The photo is too large. Use an image under 4 MB.',
|
||||||
|
dimensions: 'The photo is too large. Maximum dimension is 4096 pixels.',
|
||||||
|
corrupt: 'The photo is corrupt or malformed. Try a different photo or continue manually.',
|
||||||
|
unavailable: 'Photo processing is temporarily unavailable. Continue manually.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizedError(key) {
|
||||||
|
return new Error(SANITIZED[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reencode(sourcePath, targetPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/rate-limiter.js
Normal file
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
function providerEndpoint(baseUrl) {
|
||||||
let url;
|
let url;
|
||||||
|
|
@ -8,7 +9,9 @@ function providerEndpoint(baseUrl) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzePhoto({ payload, fetchImpl = fetch, config }) {
|
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.');
|
if (!config?.model) throw new Error('AI analysis is not configured.');
|
||||||
const endpoint = providerEndpoint(config.baseUrl);
|
const endpoint = providerEndpoint(config.baseUrl);
|
||||||
const response = await fetchImpl(endpoint, {
|
const response = await fetchImpl(endpoint, {
|
||||||
|
|
|
||||||
BIN
tests/fixtures/ingress-bomb.png
vendored
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
tests/fixtures/ingress-clean.jpg
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
0
tests/fixtures/ingress-empty.jpg
vendored
Normal file
BIN
tests/fixtures/ingress-exif.jpg
vendored
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
tests/fixtures/ingress-garbage.jpg
vendored
Normal file
BIN
tests/fixtures/ingress-metadata.png
vendored
Normal file
|
After Width: | Height: | Size: 225 B |
BIN
tests/fixtures/ingress-oversized.jpg
vendored
Normal file
|
After Width: | Height: | Size: 550 KiB |
BIN
tests/fixtures/ingress-polyglot.gif
vendored
Normal file
|
After Width: | Height: | Size: 126 B |
1
tests/fixtures/ingress-script.svg
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect width='10' height='10'/></svg>
|
||||||
|
After Width: | Height: | Size: 94 B |
1
tests/fixtures/ingress-spoofed.html
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<!DOCTYPE html><html><body>not an image</body></html>
|
||||||
BIN
tests/fixtures/ingress-truncated.jpg
vendored
Normal file
|
After Width: | Height: | Size: 581 B |
BIN
tests/fixtures/ingress-zip-polyglot.jpg
vendored
Normal file
164
tests/image-ingress.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
26
tests/rate-limiter.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { analyzePhoto } from '../src/vision-service.js';
|
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 () => {
|
test('sends a bounded structured request to the configured provider and validates its response', async () => {
|
||||||
let captured;
|
let captured;
|
||||||
const fetchImpl = async (url, options) => {
|
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({
|
const result = await analyzePhoto({
|
||||||
payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true },
|
payload: { imageDataUrl, consent: true },
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
config: { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'secret', model: 'vision-model' },
|
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 () => {
|
test('fails closed when the provider is unavailable or malformed', async () => {
|
||||||
await assert.rejects(() => analyzePhoto({
|
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' }),
|
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);
|
}), /temporarily unavailable/i);
|
||||||
await assert.rejects(() => analyzePhoto({
|
await assert.rejects(() => analyzePhoto({
|
||||||
payload: { imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: true },
|
payload: { imageDataUrl, consent: true },
|
||||||
fetchImpl: async () => ({ ok: true, json: async () => ({ choices: [] }) }),
|
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);
|
}), /invalid/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requires an http(s) provider URL and never accepts credentials from the browser payload', async () => {
|
test('requires an http(s) provider URL and never accepts credentials from the browser payload', async () => {
|
||||||
await assert.rejects(() => analyzePhoto({
|
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'); },
|
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);
|
}), /provider URL/i);
|
||||||
});
|
});
|
||||||
|
|
|
||||||