Some checks failed
Quality gates / quality (pull_request) Failing after 2m39s
Closes the PR 63 hostile-review blockers: 1. Immutable pinned Python/Pillow runtime (TIMMY_PYTHON absolute, --verify-pin), deployment/runtime re-encode smoke gate in build_release + deploy_staging. 2. Header-only width/height/total-pixel/bomb rejection before full decode; proves 6000x6000 and 12000x12000 stay resource bounded (RSS + address-space caps). 3. Fail-fast decoder concurrency ceiling; tests count actual spawned children. 4. Rate-limiter key cardinality hard-bounded under 20k+ unexpired identities, trusted-loopback-proxy identity, no spoofable forwarded headers, fixed-window boundary burst smoothed by two-window sliding count. 5. build_release explicitly syntax/gates every new JS module + Python re-encoder + production-runtime smoke; CI runs reencode-image test and the runtime pin smoke. 6. Robust polyglot contract via canonical container parsing; rejects uppercase/mixed script, appended HTML, ZIP local/EOCD and archive tails, data after canonical JPEG/PNG/WebP end; no naive compressed-byte scans (false-positive controls pass). 7. Canonical base64 data-URL grammar with byte-exact round trip; rejects missing/excess padding, whitespace/CRLF, malformed and noncanonical encodings; exact MIME policy. 8. Missing Pillow / runtime-unavailable maps to sanitized 503 + manual fallback. 9. Inbound body-read timeout and stop-on-oversize; preserves sanitized 413, base path, provider suppression, and temp cleanup; socket torn down on rejection. Audited prior partial edits: reused the sound source modules, re-wired new tests into the unit/syntax gates, fixed a non-canonical readJson that destroyed the socket before delivering 413/408, and hardened test flakiness (port reuse, EPIPE, startup races).
171 lines
6.9 KiB
JavaScript
171 lines
6.9 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;
|
|
});
|
|
}
|
|
// A truncated JPEG has no EOI, so canonical container parsing rejects it
|
|
// before any decode or subprocess spawn. Either sanitized ingress error is
|
|
// acceptable; leaking anything else is not.
|
|
await assert.rejects(() => ingestFixture('ingress-truncated.jpg'), (error) => {
|
|
assert.match(error.message, /not a supported image|corrupt or malformed/i);
|
|
return true;
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|