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).
133 lines
5.3 KiB
JavaScript
133 lines
5.3 KiB
JavaScript
// Inbound body-read timeout and stop-on-oversize contract.
|
|
//
|
|
// A client must not be able to hold a connection open by dribbling a body
|
|
// forever, nor make the server drain an enormous payload before rejecting it.
|
|
// The server must enforce a body-read timeout and stop/destroy oversized request
|
|
// processing as soon as the declared or observed size exceeds the limit, while
|
|
// preserving the configured base path and a sanitized 413.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
import net from 'node:net';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
|
const BASE_PORT = 4199;
|
|
const originFor = (port) => `http://127.0.0.1:${port}`;
|
|
const BODY_TIMEOUT_MS = 1500;
|
|
|
|
let nextPort = BASE_PORT;
|
|
let currentPort = BASE_PORT;
|
|
async function startServer(env = {}) {
|
|
const PORT = nextPort++;
|
|
currentPort = PORT;
|
|
const workdir = await mkdtemp(join(tmpdir(), 'timmy-body-test-'));
|
|
const child = spawn(process.execPath, ['server.mjs'], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
PORT: String(PORT),
|
|
TIMMY_VISION_ENABLED: 'true',
|
|
TIMMY_BODY_READ_TIMEOUT_MS: String(BODY_TIMEOUT_MS),
|
|
...env,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
const origin = originFor(PORT);
|
|
const deadline = Date.now() + 10_000;
|
|
while (Date.now() < deadline) {
|
|
if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}`);
|
|
try { const response = await fetch(`${origin}/api/healthz`); if (response.ok) break; } catch {}
|
|
await sleep(50);
|
|
}
|
|
return { child, workdir, origin };
|
|
}
|
|
|
|
test('a slow-dribbling request body is terminated by the body-read timeout', async () => {
|
|
const { child, workdir, origin } = await startServer();
|
|
try {
|
|
const socket = net.connect(currentPort, '127.0.0.1');
|
|
await new Promise((resolve) => socket.once('connect', resolve));
|
|
// Declare a large body and then dribble spaces far slower than the timeout.
|
|
socket.write(
|
|
`POST ${basePath()}/api/analyze HTTP/1.1\r\n` +
|
|
`Host: 127.0.0.1\r\nContent-Type: application/json\r\n` +
|
|
`Content-Length: ${8 * 1024 * 1024}\r\nConnection: close\r\n\r\n`,
|
|
);
|
|
const start = Date.now();
|
|
let written = 0;
|
|
const chunk = Buffer.alloc(64 * 1024, 0x20);
|
|
let ended = false;
|
|
let closeAt = 0;
|
|
const writer = setInterval(() => {
|
|
if (written >= 7 * 1024 * 1024 || socket.destroyed) { clearInterval(writer); return; }
|
|
try { socket.write(chunk); written += chunk.length; } catch { clearInterval(writer); }
|
|
}, 400);
|
|
socket.on('close', () => { ended = true; closeAt = Date.now(); });
|
|
// Swallow EPIPE: once the server tears down the socket, any in-flight write
|
|
// must not crash the test process.
|
|
socket.on('error', () => { clearInterval(writer); });
|
|
// Wait well past the timeout to see whether the server kills the slow stream.
|
|
await sleep(BODY_TIMEOUT_MS + 2500);
|
|
clearInterval(writer);
|
|
const elapsed = Date.now() - start;
|
|
socket.destroy();
|
|
assert.ok(ended, 'the server must close a slow-dribbling body before it finishes streaming');
|
|
assert.ok(closeAt - start < BODY_TIMEOUT_MS + 2000,
|
|
`a slow body was allowed to stream for ${closeAt - start}ms; the timeout is not enforced`);
|
|
} finally {
|
|
child.kill('SIGTERM');
|
|
await rm(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('an oversized request is rejected with a sanitized 413 and not drained', async () => {
|
|
const { child, workdir, origin } = await startServer();
|
|
try {
|
|
const response = await fetch(`${origin}${basePath()}/api/analyze`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
// No Content-Length: the server must stop once the stream exceeds the cap,
|
|
// not wait for the client to finish sending. `duplex: 'half'` is required
|
|
// by fetch when a request body is a streaming ReadableStream.
|
|
duplex: 'half',
|
|
body: new ReadableStream({
|
|
start(controller) {
|
|
const blob = new Uint8Array(10 * 1024 * 1024).fill(0x20);
|
|
controller.enqueue(blob);
|
|
// Keep the stream open so the server must stop it, not us.
|
|
setTimeout(() => controller.close(), 5000);
|
|
},
|
|
}),
|
|
});
|
|
assert.equal(response.status, 413);
|
|
const body = await response.text();
|
|
assert.match(body, /too large/i);
|
|
assert.doesNotMatch(body, /[A-Za-z0-9+/]{40,}/, 'no payload data in the 413 response');
|
|
} finally {
|
|
child.kill('SIGTERM');
|
|
await rm(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('the configured base path is preserved on the analyze route after hardening', async () => {
|
|
const { child, workdir, origin } = await startServer({ TIMMY_BASE_PATH: '/timmy-staging' });
|
|
try {
|
|
const response = await fetch(`${origin}/timmy-staging/api/healthz`);
|
|
assert.equal(response.status, 200);
|
|
const missing = await fetch(`${origin}/api/healthz`);
|
|
assert.equal(missing.status, 404);
|
|
} finally {
|
|
child.kill('SIGTERM');
|
|
await rm(workdir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function basePath() {
|
|
return process.env.TIMMY_BASE_PATH_TEST || '';
|
|
}
|