timmy-talking-turd/tests/body-timeout.test.js
Timmy 517c8dbac3
Some checks failed
Quality gates / quality (pull_request) Failing after 2m39s
Harden image ingress: pinned runtime, header-bomb rejection, concurrency ceiling, bounded rate limiter, canonical data-URL/polyglot contract, 503-on-unavailable, body-read timeout
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).
2026-08-22 23:22:42 +00:00

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