Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/web/request-body-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** Accept empty, untyped API POSTs even when a reverse proxy frames them as chunked. */
import { errorCodes, type FastifyInstance, type FastifyRequest } from 'fastify';
import type { Readable } from 'node:stream';

export function installEmptyApiPostParser(app: FastifyInstance): void {
// Existing JSON, text and multipart parsers take precedence. The webview
// proxy installs its own parser in an encapsulated scope.
app.addContentTypeParser('*', async (req: FastifyRequest, payload: Readable) => {
// Fastify normally skips an unsupported parser on its 404 path. Preserve
// that behavior, including the unread stream used by referer fallbacks.
if (req.is404) return undefined;

if (
req.method !== 'POST' ||
!req.routeOptions.url?.startsWith('/api/') ||
req.headers['content-type'] !== undefined ||
(req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0')
) {
throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE();
}

// Wait for the terminating chunk to prove the body is empty. Reject on the
// first byte instead of buffering an unsupported body. Leave the socket
// alive so Fastify can send its 415 and close the connection on parser errors.
try {
for await (const chunk of payload.iterator({ destroyOnReturn: false })) {
if (Buffer.byteLength(chunk) > 0) throw new errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE();
}
} catch (error) {
// Match Fastify's body reader: an interrupted request is a client error.
const err = error as Error & { statusCode?: number };
if (typeof err.statusCode !== 'number' || err.statusCode < 400) err.statusCode = 400;
throw err;
}

// No synthetic JSON or header rewriting: required-body validation still
// sees the same undefined value as a direct Content-Length: 0 request.
return undefined;
});
}
4 changes: 4 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ import {
import { isMultiUserMode } from '../config/multiuser.js';
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { installEmptyApiPostParser } from './request-body-parser.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
import {
registerPushRoutes,
Expand Down Expand Up @@ -970,6 +971,9 @@ export class WebServer extends EventEmitter {
this.userIpFailures = authState.userIpFailures;
}

// Bodyless API POSTs may arrive as empty chunked streams through a proxy.
installEmptyApiPostParser(this.app);

// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);

Expand Down
50 changes: 50 additions & 0 deletions test/empty-api-post-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/** Verify parser installation through the real server, including API aliases and base paths. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { WebServer } from '../src/web/server.js';

describe('empty POST: production server wiring', () => {
let server: WebServer;
let app: FastifyInstance;
beforeAll(async () => {
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
await server.start();
app = (server as unknown as { app: FastifyInstance }).app;
});
afterAll(async () => {
await server?.stop();
});

it('accepts empty chunked POSTs to a real API through each supported prefix', async () => {
for (const prefix of ['/api', '/api/v1', '/codeman/api', '/codeman/api/v1']) {
const res = await app.inject({
method: 'POST',
url: `${prefix}/approvals/session/nonexistent/viewed`,
headers: { 'transfer-encoding': 'chunked' },
});
// The real handler ran and found no session, instead of the parser returning 415.
expect(res.statusCode, res.body).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
}
});

it('still rejects a nonempty untyped POST before reaching the same handler', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/approvals/session/nonexistent/viewed',
headers: { 'transfer-encoding': 'chunked' },
payload: '{}',
});
expect(res.statusCode).toBe(415);
});

it('preserves the API 404 envelope for an unknown empty chunked POST', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/nonexistent',
headers: { 'transfer-encoding': 'chunked' },
});
expect(res.statusCode).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
});
});
Loading