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