mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(server): accept empty requests with unsupported media types
Inject an empty JSON object for requests that have an unsupported content type but zero content length. Fastify defaults to rejecting these with a 415 error. Closes #348
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBooleanEnv, parseTrustProxy, buildCspDirectives } from './bootstrap.utils';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import {
|
||||
parseBooleanEnv,
|
||||
parseTrustProxy,
|
||||
buildCspDirectives,
|
||||
buildEmptyJsonBodyStream,
|
||||
registerEmptyBodyContentTypeParser,
|
||||
shouldInjectEmptyJsonBody,
|
||||
} from './bootstrap.utils';
|
||||
|
||||
describe('parseBooleanEnv', () => {
|
||||
it('returns fallback when value is undefined', () => {
|
||||
@@ -228,3 +236,124 @@ describe('buildCspDirectives', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty request body bootstrap handling', () => {
|
||||
it('identifies empty JSON body requests that need parser-safe replacement', () => {
|
||||
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'application/json', 'content-length': '0' })).toBe(true);
|
||||
expect(shouldInjectEmptyJsonBody('DELETE', { 'content-type': 'application/json' })).toBe(true);
|
||||
expect(shouldInjectEmptyJsonBody('PATCH', { 'content-type': 'application/json; charset=utf-8', 'content-length': '0' })).toBe(true);
|
||||
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'Application/JSON', 'content-length': ' 0 ' })).toBe(true);
|
||||
|
||||
expect(shouldInjectEmptyJsonBody('GET', { 'content-type': 'application/json', 'content-length': '0' })).toBe(false);
|
||||
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'text/plain', 'content-length': '0' })).toBe(false);
|
||||
expect(shouldInjectEmptyJsonBody('POST', { 'content-type': 'application/json', 'content-length': '9' })).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a parser-safe empty JSON stream with a matching content length', async () => {
|
||||
const headers = { 'content-length': '0' };
|
||||
const stream = buildEmptyJsonBodyStream(headers);
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
|
||||
expect(headers['content-length']).toBe('2');
|
||||
expect(Buffer.concat(chunks).toString('utf8')).toBe('{}');
|
||||
});
|
||||
|
||||
it('accepts empty unsupported content types as an empty object', async () => {
|
||||
await withParserApp(async (app) => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/body',
|
||||
headers: {
|
||||
'content-type': 'application/octet-stream',
|
||||
'content-length': '0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ body: {} });
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts empty chunked requests without a content type as an empty object', async () => {
|
||||
await withParserApp(async (app) => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/body',
|
||||
headers: {
|
||||
'transfer-encoding': 'chunked',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ body: {} });
|
||||
});
|
||||
});
|
||||
|
||||
it('still rejects non-empty unsupported content types', async () => {
|
||||
await withParserApp(async (app) => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/body',
|
||||
headers: {
|
||||
'content-type': 'application/octet-stream',
|
||||
},
|
||||
payload: 'not json',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(415);
|
||||
expect(response.json()).toMatchObject({
|
||||
message: 'Unsupported Media Type',
|
||||
statusCode: 415,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('still rejects non-empty bodies without a content type', async () => {
|
||||
await withParserApp(async (app) => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/body',
|
||||
payload: 'not json',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(415);
|
||||
expect(response.json()).toMatchObject({
|
||||
message: 'Unsupported Media Type',
|
||||
statusCode: 415,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not interfere with Fastify JSON parsing', async () => {
|
||||
await withParserApp(async (app) => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/body',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ body: { ok: true } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withParserApp(run: (app: FastifyInstance) => Promise<void>): Promise<void> {
|
||||
const app = Fastify({ logger: false });
|
||||
registerEmptyBodyContentTypeParser(app);
|
||||
app.post('/body', (request) => ({ body: request.body ?? null }));
|
||||
|
||||
try {
|
||||
await app.ready();
|
||||
await run(app);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { UnsupportedMediaTypeException } from '@nestjs/common';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { IncomingHttpHeaders } from 'http';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
const DEFAULT_TRUST_PROXY = 'loopback,linklocal,uniquelocal';
|
||||
const EMPTY_JSON_BODY = '{}';
|
||||
const BODY_METHODS = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
|
||||
|
||||
export function parseTrustProxy(value: string | undefined): string | boolean | number {
|
||||
const raw = value?.trim();
|
||||
@@ -52,3 +59,33 @@ export function buildCspDirectives(options: CspOptions = {}) {
|
||||
upgradeInsecureRequests: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldInjectEmptyJsonBody(method: string, headers: IncomingHttpHeaders): boolean {
|
||||
const contentType = getHeaderValue(headers['content-type'])?.toLowerCase();
|
||||
if (!BODY_METHODS.has(method.toUpperCase()) || !contentType?.startsWith('application/json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentLength = getHeaderValue(headers['content-length'])?.trim();
|
||||
return contentLength === undefined || contentLength === '0';
|
||||
}
|
||||
|
||||
export function buildEmptyJsonBodyStream(headers: IncomingHttpHeaders): Readable {
|
||||
headers['content-length'] = String(Buffer.byteLength(EMPTY_JSON_BODY));
|
||||
return Readable.from([EMPTY_JSON_BODY]);
|
||||
}
|
||||
|
||||
export function registerEmptyBodyContentTypeParser(fastify: FastifyInstance): void {
|
||||
fastify.addContentTypeParser<string>('*', { parseAs: 'string' }, (request, body, done) => {
|
||||
if (BODY_METHODS.has(request.method.toUpperCase()) && body.length === 0) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
|
||||
done(new UnsupportedMediaTypeException('Unsupported Media Type'));
|
||||
});
|
||||
}
|
||||
|
||||
function getHeaderValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
+12
-15
@@ -6,13 +6,19 @@ import { Logger } from 'nestjs-pino';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyHelmet from '@fastify/helmet';
|
||||
import fastifyCompress from '@fastify/compress';
|
||||
import { parseBooleanEnv, parseTrustProxy, buildCspDirectives } from './common/utils/bootstrap.utils';
|
||||
import {
|
||||
parseBooleanEnv,
|
||||
parseTrustProxy,
|
||||
buildCspDirectives,
|
||||
buildEmptyJsonBodyStream,
|
||||
registerEmptyBodyContentTypeParser,
|
||||
shouldInjectEmptyJsonBody,
|
||||
} from './common/utils/bootstrap.utils';
|
||||
|
||||
const MAX_COVER_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
@@ -25,25 +31,16 @@ async function bootstrap() {
|
||||
|
||||
const fastify = adapter.getInstance();
|
||||
|
||||
// Kobo devices send Content-Type: application/json with empty bodies on GET/DELETE.
|
||||
// Fastify's default JSON parser rejects empty bodies, so we inject '{}' before parsing.
|
||||
fastify.addHook('preParsing', (request, _reply, payload, done) => {
|
||||
const ct = request.headers['content-type'] ?? '';
|
||||
const isJson = ct.startsWith('application/json');
|
||||
const isEmpty = request.headers['content-length'] === '0' || request.headers['content-length'] === undefined;
|
||||
if (isJson && isEmpty) {
|
||||
// Kobo sends Content-Length: 0 with application/json on DELETE; align header with injected body.
|
||||
if (request.method === 'DELETE' && request.headers['content-length'] === '0') {
|
||||
request.headers['content-length'] = '2';
|
||||
}
|
||||
const fake = new Readable();
|
||||
fake.push('{}');
|
||||
fake.push(null);
|
||||
done(null, fake);
|
||||
if (shouldInjectEmptyJsonBody(request.method, request.headers)) {
|
||||
done(null, buildEmptyJsonBodyStream(request.headers));
|
||||
return;
|
||||
}
|
||||
done(null, payload);
|
||||
});
|
||||
// Reverse proxies can forward empty mutating requests with chunked transfer or an unsupported content type.
|
||||
registerEmptyBodyContentTypeParser(fastify);
|
||||
|
||||
// Echo pino-http's request ID so clients can correlate errors with server logs.
|
||||
fastify.addHook('onSend', (_request, reply, _payload, done) => {
|
||||
|
||||
Reference in New Issue
Block a user