/** Regression coverage for empty API POSTs reframed as chunked by a reverse proxy. */ import { afterEach, describe, expect, it, vi } from 'vitest'; import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyServerOptions } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyMultipart from '@fastify/multipart'; import { connect } from 'node:net'; import { Readable } from 'node:stream'; import { installEmptyApiPostParser } from '../src/web/request-body-parser.js'; import { registerAuthMiddleware, registerHostGuard } from '../src/web/middleware/auth.js'; import { registerMuxRoutes } from '../src/web/routes/mux-routes.js'; import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js'; import { createMockRouteContext } from './mocks/index.js'; import { writeWebviews } from '../src/webview-store.js'; import { webviewCapabilities } from '../src/webview-capabilities.js'; import { getDataDir } from '../src/config/instance.js'; const apps: FastifyInstance[] = []; function createApp(options: FastifyServerOptions = {}): FastifyInstance { const app = Fastify({ logger: false, ...options }); installEmptyApiPostParser(app); apps.push(app); return app; } afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); webviewCapabilities.revokeWebview('empty-post-preview'); }); const emptyChunked = { 'transfer-encoding': 'chunked' }; describe('empty API POST parser', () => { it.each([{}, { 'content-length': '0' }, emptyChunked])( 'preserves an absent body and the original headers (%j)', async (headers) => { const app = createApp(); app.post('/api/action', async (req) => ({ absent: req.body === undefined, contentType: req.headers['content-type'] ?? null, contentLength: req.headers['content-length'] ?? null, transferEncoding: req.headers['transfer-encoding'] ?? null, })); const res = await app.inject({ method: 'POST', url: '/api/action', headers }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ absent: true, contentType: null, contentLength: 'content-length' in headers ? '0' : null, transferEncoding: 'transfer-encoding' in headers ? 'chunked' : null, }); } ); it.each([' ', '{}', '{"prompt":"hello"}', 'x'.repeat(1024)])( 'rejects nonempty untyped chunked input (%#)', async (payload) => { const app = createApp(); const handler = vi.fn(async () => ({})); app.post('/api/action', handler); const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked, payload }); expect(res.statusCode).toBe(415); expect(res.json().code).toBe('FST_ERR_CTP_INVALID_MEDIA_TYPE'); expect(handler).not.toHaveBeenCalled(); } ); it('rejects a positive Content-Length without reading the body', async () => { const app = createApp(); let streamRead = false; app.addHook( 'preParsing', async () => new Readable({ read() { streamRead = true; }, }) ); app.post('/api/action', async () => ({})); const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-length': '1' } }); expect(res.statusCode).toBe(415); expect(streamRead).toBe(false); }); it('waits for the end of an empty stream before executing the handler', async () => { const app = createApp(); let started!: () => void; const reading = new Promise((resolve) => { started = resolve; }); const stream = new Readable({ read() { started(); }, }); app.addHook('preParsing', async () => stream); const handler = vi.fn(async (req: FastifyRequest) => ({ absent: req.body === undefined })); app.post('/api/action', handler); const response = app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked }); await reading; expect(handler).not.toHaveBeenCalled(); stream.push(null); const res = await response; expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ absent: true }); }); it('rejects on the first byte without waiting for an unsupported stream to end', async () => { const app = createApp({ bodyLimit: 16 }); const stream = new Readable({ read() { this.push(Buffer.alloc(32)); this._read = () => {}; }, }); app.addHook('preParsing', async () => stream); app.post('/api/action', async () => ({})); try { const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked }); expect(res.statusCode).toBe(415); expect(res.headers.connection).toBe('close'); expect(stream.readableEnded).toBe(false); } finally { stream.destroy(); } }); it.each(['application/octet-stream', 'application/xml', 'invalid/type/extra', ''])( 'preserves rejection of an explicit type %j', async (contentType) => { const app = createApp(); app.post('/api/action', async () => ({})); const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-type': contentType } }); expect(res.statusCode).toBe(415); } ); it('preserves JSON parsing, required-body validation, text and body limits', async () => { const app = createApp({ bodyLimit: 64 }); app.post('/api/json', async (req) => ({ body: req.body })); app.post( '/api/required', { schema: { body: { type: 'object', required: ['prompt'], properties: { prompt: { type: 'string' } } } } }, async (req) => req.body ); const missing = await app.inject({ method: 'POST', url: '/api/required', headers: emptyChunked }); expect(missing.statusCode).toBe(400); expect(missing.json().code).toBe('FST_ERR_VALIDATION'); const valid = await app.inject({ method: 'POST', url: '/api/required', payload: { prompt: 'hello' } }); expect(valid.statusCode).toBe(200); expect(valid.json()).toEqual({ prompt: 'hello' }); for (const [payload, code] of [ ['', 'FST_ERR_CTP_EMPTY_JSON_BODY'], ['{broken', 'FST_ERR_CTP_INVALID_JSON_BODY'], ]) { const res = await app.inject({ method: 'POST', url: '/api/json', headers: { 'content-type': 'application/json' }, payload, }); expect(res.statusCode).toBe(400); expect(res.json().code).toBe(code); } const text = await app.inject({ method: 'POST', url: '/api/json', headers: { 'content-type': 'text/plain' }, payload: '{"admin":true}', }); expect(text.json()).toEqual({ body: '{"admin":true}' }); const oversized = await app.inject({ method: 'POST', url: '/api/json', payload: { value: 'x'.repeat(64) } }); expect(oversized.statusCode).toBe(413); }); it('leaves other methods and non-API routes outside the compatibility rule', async () => { const app = createApp(); app.put('/api/action', async () => ({})); app.patch('/api/action', async () => ({})); app.post('/outside-api', async () => ({})); for (const [method, url] of [ ['PUT', '/api/action'], ['PATCH', '/api/action'], ['POST', '/outside-api'], ] as const) { const res = await app.inject({ method, url, headers: emptyChunked }); expect(res.statusCode).toBe(415); } }); it('preserves unknown routes and leaves their body stream unread for fallback handlers', async () => { const app = createApp(); app.setNotFoundHandler(async (req, reply) => { const chunks: Buffer[] = []; for await (const chunk of req.raw) chunks.push(chunk); return reply.code(404).send({ body: Buffer.concat(chunks).toString(), absent: req.body === undefined }); }); const res = await app.inject({ method: 'POST', url: '/api/unknown', headers: emptyChunked, payload: 'untyped-data', }); expect(res.statusCode).toBe(404); expect(res.json()).toEqual({ body: 'untyped-data', absent: true }); }); it('keeps multipart uploads and an encapsulated raw-stream proxy parser intact', async () => { const app = createApp(); await app.register(fastifyMultipart); app.post('/api/upload', async (req) => { const file = await req.file(); return { name: file?.filename, data: (await file?.toBuffer())?.toString('base64') }; }); app.register(async (scope) => { scope.removeAllContentTypeParsers(); scope.addContentTypeParser('*', (_req, stream, done) => done(null, stream)); scope.post('/webview/test/*', async (req) => { const chunks: Buffer[] = []; for await (const chunk of req.body as Readable) chunks.push(chunk); return { data: Buffer.concat(chunks).toString('base64') }; }); }); const bytes = Buffer.from([0, 255, 128, 13, 10, 65]); const boundary = 'empty-post-boundary'; const multipart = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="sample.bin"\r\nContent-Type: application/octet-stream\r\n\r\n` ), bytes, Buffer.from(`\r\n--${boundary}--\r\n`), ]); const upload = await app.inject({ method: 'POST', url: '/api/upload', headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: multipart, }); expect(upload.statusCode).toBe(200); expect(upload.json()).toEqual({ name: 'sample.bin', data: bytes.toString('base64') }); const proxy = await app.inject({ method: 'POST', url: '/webview/test/upload', headers: { 'content-type': 'application/octet-stream' }, payload: bytes, }); expect(proxy.statusCode).toBe(200); expect(proxy.json()).toEqual({ data: bytes.toString('base64') }); }); it('exercises real mux operations and the real webview open route without a synthetic JSON body', async () => { const app = createApp(); const ctx = createMockRouteContext(); registerMuxRoutes(app, ctx as never); registerWebviewRoutes(app, ctx as never); await writeWebviews(getDataDir(), [ { id: 'empty-post-preview', name: 'Preview', url: 'http://127.0.0.1:18716/', embedMode: 'proxy', trusted: false, createdAt: 1, }, ]); const stats = await app.inject({ method: 'POST', url: '/api/mux-sessions/stats/start', headers: emptyChunked }); expect(stats.statusCode).toBe(200); expect(ctx.mux.startStatsCollection).toHaveBeenCalledOnce(); const preview = await app.inject({ method: 'POST', url: '/api/webviews/empty-post-preview/open', headers: emptyChunked, }); expect(preview.statusCode).toBe(200); expect(preview.json().data.embedUrl).toMatch(/^\/webview\/.+\/$/); }); it('preserves Host/Origin guards and authentication before parsing', async () => { vi.stubEnv('CODEMAN_USERNAME', 'bodyless-test-user'); vi.stubEnv('CODEMAN_PASSWORD', 'isolated-test-password'); const app = createApp(); await app.register(fastifyCookie); registerHostGuard(app, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null })); const auth = registerAuthMiddleware(app, false); app.post('/api/action', async () => ({ ok: true })); const authorization = 'Basic ' + Buffer.from('bodyless-test-user:isolated-test-password').toString('base64'); const headers = { host: '127.0.0.1', ...emptyChunked }; try { expect((await app.inject({ method: 'POST', url: '/api/action', headers })).statusCode).toBe(401); expect( ( await app.inject({ method: 'POST', url: '/api/action', headers: { ...headers, authorization, origin: 'https://evil.example' }, }) ).statusCode ).toBe(403); expect( ( await app.inject({ method: 'POST', url: '/api/action', headers: { ...headers, authorization, host: 'evil.example' }, }) ).statusCode ).toBe(403); expect( (await app.inject({ method: 'POST', url: '/api/action', headers: { ...headers, authorization } })).statusCode ).toBe(200); } finally { for (const map of Object.values(auth)) map?.dispose(); vi.unstubAllEnvs(); } }); }); function sendRaw(port: number, frames: string[]): Promise { return new Promise((resolve, reject) => { const socket = connect({ host: '127.0.0.1', port }); const chunks: Buffer[] = []; socket.setTimeout(2000, () => socket.destroy(new Error('Raw request timed out'))); socket.on('error', reject); socket.on('data', (chunk) => chunks.push(chunk)); socket.on('end', () => resolve(Buffer.concat(chunks).toString())); socket.on('connect', () => socket.write(frames.join(''))); }); } describe('real HTTP/1.1 framing', () => { it('consumes the complete empty chunk before a second request on the same connection', async () => { const app = createApp(); const seen: unknown[] = []; app.post('/api/action', async (req) => { seen.push(req.body); return { ok: true }; }); await app.listen({ host: '127.0.0.1', port: 0 }); const port = (app.server.address() as { port: number }).port; const res = await sendRaw(port, [ 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX-Test-Trailer: done\r\n\r\n', 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n', ]); expect(res.match(/HTTP\/1.1 200 OK/g)).toHaveLength(2); expect(seen).toEqual([undefined, undefined]); }); it('returns 415 after the first nonempty chunk, without needing a terminating chunk', async () => { const app = createApp(); const handler = vi.fn(async () => ({})); app.post('/api/action', handler); await app.listen({ host: '127.0.0.1', port: 0 }); const port = (app.server.address() as { port: number }).port; const res = await sendRaw(port, [ 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n', ]); expect(res).toMatch(/^HTTP\/1.1 415 /); expect(handler).not.toHaveBeenCalled(); }); it('leaves conflicting length and transfer headers to the HTTP parser', async () => { const app = createApp(); const handler = vi.fn(async () => ({})); app.post('/api/action', handler); await app.listen({ host: '127.0.0.1', port: 0 }); const port = (app.server.address() as { port: number }).port; const res = await sendRaw(port, [ 'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n', ]); expect(res).toMatch(/^HTTP\/1.1 400 /); expect(handler).not.toHaveBeenCalled(); }); it('does not execute the handler after a client aborts an unfinished chunked request', async () => { const app = createApp(); const handler = vi.fn(async () => ({})); const onError = vi.fn(); app.addHook('onError', async (_req, _reply, error) => { onError(error.statusCode); }); app.post('/api/action', handler); await app.listen({ host: '127.0.0.1', port: 0 }); const port = (app.server.address() as { port: number }).port; await new Promise((resolve) => { const socket = connect({ host: '127.0.0.1', port }); socket.on('connect', () => socket.end('POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n') ); socket.resume(); socket.on('close', () => resolve()); }); await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(400)); expect(handler).not.toHaveBeenCalled(); }); });