timmy-talking-turd/tests/image-ingress.test.js
timmy ccb227921e
Some checks failed
Quality gates / quality (pull_request) Failing after 1m38s
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
2026-08-22 21:43:36 +00:00

165 lines
6.6 KiB
JavaScript

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