The declared data-URL MIME was a claim the boundary believed. Every image decision now comes from the bytes: the container is sniffed, walked to its own end marker, refused when metadata segments remain, and refused above a 4096px edge or 16MP. The request path adds a per-caller sliding-window budget (429 + Retry-After) and a body deadline (408), so a flood, a stalled body, or a decode bomb fails before a provider call. Log hygiene is part of the contract: refusal copy is fixed prose, the bucket key is a salted hash kept in memory only, and the acceptance test asserts no image bytes reach the process log.
219 lines
8.2 KiB
JavaScript
219 lines
8.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import { spawn } from 'node:child_process';
|
|
import { once } from 'node:events';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// End-to-end proof for issue #16 at the real boundary: the server process that ships, not a mock of
|
|
// it. Every image here is synthetic container bytes assembled in this file, and the provider is a
|
|
// local stub that counts requests, so "the provider was never called" is measurable rather than
|
|
// assumed.
|
|
|
|
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
function pngChunk(type, data = Buffer.alloc(0)) {
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(data.length);
|
|
return Buffer.concat([length, Buffer.from(type, 'ascii'), data, Buffer.alloc(4)]);
|
|
}
|
|
function cleanJpeg({ width = 1200, height = 900, tail = Buffer.alloc(0) } = {}) {
|
|
const sof = Buffer.alloc(17);
|
|
sof.writeUInt16BE(0xffc0, 0);
|
|
sof.writeUInt16BE(15, 2);
|
|
sof[4] = 8;
|
|
sof.writeUInt16BE(height, 5);
|
|
sof.writeUInt16BE(width, 7);
|
|
sof[9] = 3;
|
|
for (let i = 0; i < 3; i += 1) {
|
|
sof[10 + i * 3] = i + 1;
|
|
sof[11 + i * 3] = 0x11;
|
|
}
|
|
return Buffer.concat([Buffer.from([0xff, 0xd8]), sof, Buffer.from([0xff, 0xd9]), tail]);
|
|
}
|
|
function polyglotPng() {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(1200, 0);
|
|
ihdr.writeUInt32BE(900, 4);
|
|
ihdr[8] = 8;
|
|
ihdr[9] = 2;
|
|
return Buffer.concat([
|
|
PNG_SIGNATURE,
|
|
pngChunk('IHDR', ihdr),
|
|
pngChunk('IDAT', Buffer.from([0x78, 0x9c, 0x01])),
|
|
pngChunk('IEND'),
|
|
Buffer.from('<html><script>alert(1)</script></html>', 'utf8'),
|
|
]);
|
|
}
|
|
|
|
const dataUrl = (mime, bytes) => `data:${mime};base64,${bytes.toString('base64')}`;
|
|
|
|
async function freePort() {
|
|
const server = net.createServer();
|
|
server.listen(0, '127.0.0.1');
|
|
await once(server, 'listening');
|
|
const { port } = server.address();
|
|
server.close();
|
|
return port;
|
|
}
|
|
|
|
async function startProvider() {
|
|
const state = { calls: 0 };
|
|
const server = http.createServer(async (req, res) => {
|
|
if (req.url === '/v1/models') {
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ data: [{ id: 'ingress-test-model' }] }));
|
|
return;
|
|
}
|
|
state.calls += 1;
|
|
for await (const _ of req) { /* drain */ }
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
choices: [{
|
|
message: {
|
|
content: JSON.stringify({
|
|
isStool: true, bristolType: 4, color: 'brown', confidence: 0.8,
|
|
imageQuality: 'good', observations: 'Synthetic test card.',
|
|
}),
|
|
},
|
|
}],
|
|
}));
|
|
});
|
|
server.listen(0, '127.0.0.1');
|
|
await once(server, 'listening');
|
|
return {
|
|
state,
|
|
port: server.address().port,
|
|
// fetch keeps connections alive, so a plain close() would wait for sockets the test already
|
|
// finished with and hang teardown.
|
|
close: () => new Promise(resolve => {
|
|
server.closeAllConnections?.();
|
|
server.close(resolve);
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function withServer(env, run) {
|
|
const provider = await startProvider();
|
|
const port = await freePort();
|
|
const child = spawn(process.execPath, ['server.mjs'], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
HOST: '127.0.0.1',
|
|
PORT: String(port),
|
|
TIMMY_VISION_ENABLED: 'true',
|
|
TIMMY_VISION_PROFILE: 'selfhost',
|
|
TIMMY_VISION_BASE_URL: `http://127.0.0.1:${provider.port}/v1`,
|
|
TIMMY_VISION_MODEL: 'ingress-test-model',
|
|
...env,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const logs = [];
|
|
child.stdout.on('data', chunk => logs.push(chunk.toString()));
|
|
child.stderr.on('data', chunk => logs.push(chunk.toString()));
|
|
const base = `http://127.0.0.1:${port}`;
|
|
try {
|
|
// Readiness is the server's own health route, not a sleep.
|
|
const deadline = Date.now() + 15_000;
|
|
for (;;) {
|
|
try {
|
|
const response = await fetch(`${base}/api/healthz`);
|
|
if (response.ok) break;
|
|
} catch { /* not up yet */ }
|
|
if (Date.now() > deadline) throw new Error(`server did not start: ${logs.join('')}`);
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
await run({ base, provider, logs });
|
|
} finally {
|
|
child.kill('SIGKILL');
|
|
await provider.close();
|
|
}
|
|
}
|
|
|
|
const postAnalyze = (base, payload) => fetch(`${base}/api/analyze`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
test('the shipping server refuses bytes that are not an image before any provider work', async () => {
|
|
await withServer({}, async ({ base, provider }) => {
|
|
const response = await postAnalyze(base, {
|
|
consent: true,
|
|
imageDataUrl: dataUrl('image/jpeg', Buffer.from('<html><script>alert(1)</script></html>', 'utf8')),
|
|
});
|
|
const body = await response.json();
|
|
assert.equal(response.status, 400);
|
|
assert.match(body.error, /not a JPEG, PNG, or WebP photo/i);
|
|
assert.equal(provider.state.calls, 0, 'a non-image must never reach the model provider');
|
|
});
|
|
});
|
|
|
|
test('the shipping server refuses a valid image with a hidden payload appended', async () => {
|
|
await withServer({}, async ({ base, provider, logs }) => {
|
|
const response = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/png', polyglotPng()) });
|
|
const body = await response.json();
|
|
assert.equal(response.status, 400);
|
|
assert.match(body.error, /extra data after the image/i);
|
|
assert.equal(provider.state.calls, 0, 'a polyglot file must never reach the model provider');
|
|
// The refusal path must not copy the upload into the process log, where it would outlive the request.
|
|
const logged = logs.join('');
|
|
assert.doesNotMatch(logged, /base64/i);
|
|
assert.doesNotMatch(logged, /[A-Za-z0-9+/]{120,}/, 'no run of image bytes may appear in the log');
|
|
});
|
|
});
|
|
|
|
test('the shipping server still analyses a clean re-encoded image', async () => {
|
|
await withServer({}, async ({ base, provider }) => {
|
|
const response = await postAnalyze(base, { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) });
|
|
const body = await response.json();
|
|
assert.equal(response.status, 200);
|
|
assert.equal(body.status, 'suggestion');
|
|
assert.equal(provider.state.calls, 1, 'the clean path must reach the provider exactly once');
|
|
});
|
|
});
|
|
|
|
test('the shipping server refuses a flood with 429 and stops calling the provider', async () => {
|
|
await withServer({ TIMMY_ANALYZE_RATE_LIMIT: '2', TIMMY_ANALYZE_RATE_WINDOW_MS: '60000' }, async ({ base, provider }) => {
|
|
const payload = { consent: true, imageDataUrl: dataUrl('image/jpeg', cleanJpeg()) };
|
|
assert.equal((await postAnalyze(base, payload)).status, 200);
|
|
assert.equal((await postAnalyze(base, payload)).status, 200);
|
|
const refused = await postAnalyze(base, payload);
|
|
const body = await refused.json();
|
|
assert.equal(refused.status, 429);
|
|
assert.match(body.error, /too many photos|wait a minute/i);
|
|
assert.ok(Number(refused.headers.get('retry-after')) > 0, 'a refusal must say how long to wait');
|
|
assert.equal(provider.state.calls, 2, 'a refused request must not reach the provider');
|
|
});
|
|
});
|
|
|
|
test('the shipping server gives up on a body that never arrives', async () => {
|
|
await withServer({ TIMMY_BODY_TIMEOUT_MS: '250' }, async ({ base, provider }) => {
|
|
const port = Number(new URL(base).port);
|
|
const status = await new Promise((resolve, reject) => {
|
|
const socket = net.connect(port, '127.0.0.1', () => {
|
|
socket.write([
|
|
'POST /api/analyze HTTP/1.1',
|
|
'Host: 127.0.0.1',
|
|
'Content-Type: application/json',
|
|
'Content-Length: 400',
|
|
'', '',
|
|
].join('\r\n'));
|
|
});
|
|
socket.setTimeout(10_000, () => { socket.destroy(); reject(new Error('no response before the client timeout')); });
|
|
socket.on('data', chunk => {
|
|
const line = chunk.toString('utf8').split('\r\n')[0];
|
|
socket.destroy();
|
|
resolve(Number(line.split(' ')[1]));
|
|
});
|
|
socket.on('error', error => { if (error.code !== 'ECONNRESET') reject(error); });
|
|
});
|
|
assert.equal(status, 408);
|
|
assert.equal(provider.state.calls, 0, 'a stalled body must never reach the provider');
|
|
});
|
|
});
|