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:
neon
2026-06-17 05:07:44 +00:00
parent 0cddaf050c
commit 7b48d49351
3 changed files with 179 additions and 16 deletions
+130 -1
View File
@@ -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
View File
@@ -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) => {