mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
/** 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<void>((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<string> {
|
||
|
|
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<void>((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();
|
||
|
|
});
|
||
|
|
});
|