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
This commit is contained in:
timmy 2026-08-22 21:43:36 +00:00
parent 47294a98aa
commit ccb227921e
22 changed files with 535 additions and 12 deletions

View File

@ -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"
},

View 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
View 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())

View File

@ -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'){

114
src/image-ingress.js Normal file
View 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
View 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 };
}

View File

@ -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, {

BIN
tests/fixtures/ingress-bomb.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
tests/fixtures/ingress-clean.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

0
tests/fixtures/ingress-empty.jpg vendored Normal file
View File

BIN
tests/fixtures/ingress-exif.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
tests/fixtures/ingress-garbage.jpg vendored Normal file

Binary file not shown.

BIN
tests/fixtures/ingress-metadata.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

BIN
tests/fixtures/ingress-oversized.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

BIN
tests/fixtures/ingress-polyglot.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

1
tests/fixtures/ingress-script.svg vendored Normal file
View 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
View File

@ -0,0 +1 @@
<!DOCTYPE html><html><body>not an image</body></html>

BIN
tests/fixtures/ingress-truncated.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

BIN
tests/fixtures/ingress-zip-polyglot.jpg vendored Normal file

Binary file not shown.

164
tests/image-ingress.test.js Normal file
View 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);
});

View 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);
});

View File

@ -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);
});