From bad6b0caea017e72a8748ea095be0889948d4a33 Mon Sep 17 00:00:00 2001 From: Lenny Date: Thu, 19 Mar 2026 11:43:23 +0700 Subject: [PATCH 1/2] fix: validate response headers before sending, prevent invalid transform input --- src/app.ts | 1 + src/http/plugins/header-validator.ts | 34 ++++++++++++ src/http/plugins/index.ts | 1 + src/http/routes/object/getObjectInfo.ts | 13 +++++ src/internal/errors/codes.ts | 8 +++ src/test/render-routes.test.ts | 32 ++++++++++++ src/test/validators.test.ts | 69 +++++++++++++++++++++++++ 7 files changed, 158 insertions(+) create mode 100644 src/http/plugins/header-validator.ts diff --git a/src/app.ts b/src/app.ts index 205c2c51..783b25fa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -69,6 +69,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { ) app.register(plugins.tracing) app.register(plugins.logRequest({ excludeUrls: excludedRoutesFromMonitoring })) + app.register(plugins.headerValidator) app.register(routes.tus, { prefix: 'upload/resumable' }) app.register(routes.bucket, { prefix: 'bucket' }) app.register(routes.object, { prefix: 'object' }) diff --git a/src/http/plugins/header-validator.ts b/src/http/plugins/header-validator.ts new file mode 100644 index 00000000..cf6ca098 --- /dev/null +++ b/src/http/plugins/header-validator.ts @@ -0,0 +1,34 @@ +import { ERRORS } from '@internal/errors' +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import fastifyPlugin from 'fastify-plugin' + +/** + * Matches invalid HTTP header characters per RFC 7230 field-vchar specification. + * Valid: TAB (0x09), visible ASCII (0x20-0x7E), obs-text (0x80-0xFF). + * Invalid: control characters (0x00-0x1F except TAB) and DEL (0x7F). + * @see https://tools.ietf.org/html/rfc7230#section-3.2 + */ +const INVALID_HEADER_CHAR_PATTERN = /[^\t\x20-\x7e\x80-\xff]/ + +/** + * Validates response headers before they're sent to prevent ERR_INVALID_CHAR crashes. + * + * Node.js throws ERR_INVALID_CHAR during writeHead() if headers contain control characters. + * This hook validates headers in onSend (before writeHead) and throws InvalidHeaderChar error + */ +export const headerValidator = fastifyPlugin( + async function headerValidatorPlugin(fastify: FastifyInstance) { + fastify.addHook('onSend', async (_request: FastifyRequest, reply: FastifyReply, payload) => { + const headers = reply.getHeaders() + + for (const [key, value] of Object.entries(headers)) { + if (typeof value === 'string' && INVALID_HEADER_CHAR_PATTERN.test(value)) { + throw ERRORS.InvalidHeaderChar(key, value) + } + } + + return payload + }) + }, + { name: 'header-validator' } +) diff --git a/src/http/plugins/index.ts b/src/http/plugins/index.ts index cf0e81a5..3b090d3d 100644 --- a/src/http/plugins/index.ts +++ b/src/http/plugins/index.ts @@ -1,5 +1,6 @@ export * from './apikey' export * from './db' +export * from './header-validator' export * from './iceberg' export * from './jwt' export * from './log-request' diff --git a/src/http/routes/object/getObjectInfo.ts b/src/http/routes/object/getObjectInfo.ts index d779cdee..6680a836 100644 --- a/src/http/routes/object/getObjectInfo.ts +++ b/src/http/routes/object/getObjectInfo.ts @@ -3,6 +3,7 @@ import { Obj } from '@storage/schemas' import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { FromSchema } from 'json-schema-to-ts' import { getConfig } from '../../../config' +import { transformationOptionsSchema } from '../../schemas/transformations' import { AuthenticatedRangeRequest } from '../../types' import { ROUTE_OPERATIONS } from '../operations' @@ -17,8 +18,14 @@ const getObjectParamsSchema = { required: ['bucketName', '*'], } as const +const getObjectInfoQuerySchema = { + type: 'object', + properties: transformationOptionsSchema, +} as const + interface getObjectRequestInterface extends AuthenticatedRangeRequest { Params: FromSchema + Querystring: FromSchema } type GetObjectInfoRequest = FastifyRequest @@ -87,6 +94,7 @@ export async function publicRoutes(fastify: FastifyInstance) { { schema: { params: getObjectParamsSchema, + querystring: getObjectInfoQuerySchema, summary: 'Get object info', description: 'returns object info', tags: ['object'], @@ -107,6 +115,7 @@ export async function publicRoutes(fastify: FastifyInstance) { exposeHeadRoute: false, schema: { params: getObjectParamsSchema, + querystring: getObjectInfoQuerySchema, summary: 'Get object info', description: 'returns object info', tags: ['object'], @@ -129,6 +138,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) { { schema: { params: getObjectParamsSchema, + querystring: getObjectInfoQuerySchema, headers: { $ref: 'authSchema#' }, summary, response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, @@ -148,6 +158,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) { { schema: { params: getObjectParamsSchema, + querystring: getObjectInfoQuerySchema, headers: { $ref: 'authSchema#' }, summary, response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, @@ -167,6 +178,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) { { schema: { params: getObjectParamsSchema, + querystring: getObjectInfoQuerySchema, summary, description: 'Object Info', tags: ['object'], @@ -187,6 +199,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) { { schema: { params: getObjectParamsSchema, + querystring: getObjectInfoQuerySchema, summary, description: 'Head object info', tags: ['object'], diff --git a/src/internal/errors/codes.ts b/src/internal/errors/codes.ts index 0abdb1a6..27408376 100644 --- a/src/internal/errors/codes.ts +++ b/src/internal/errors/codes.ts @@ -260,6 +260,14 @@ export const ERRORS = { message: `Invalid X-Robots-Tag header: ${message}`, }), + InvalidHeaderChar: (headerName: string, headerValue: string) => + new StorageBackendError({ + error: 'invalid_header_char', + code: ErrorCode.InvalidRequest, + httpStatusCode: 400, + message: `Invalid character in response header "${headerName}": ${headerValue.substring(0, 50)}`, + }), + InvalidRange: () => new StorageBackendError({ error: 'invalid_range', diff --git a/src/test/render-routes.test.ts b/src/test/render-routes.test.ts index 408226b9..02f633e0 100644 --- a/src/test/render-routes.test.ts +++ b/src/test/render-routes.test.ts @@ -194,4 +194,36 @@ describe('image rendering routes', () => { const body = response.json<{ error: string }>() expect(body.error).toBe('InvalidSignature') }) + + describe('transformation parameter validation', () => { + it('rejects format parameter with newline character in info route', async () => { + const response = await appInstance.inject({ + method: 'GET', + url: '/object/info/public/public-bucket-2/favicon.ico?format=avif%0Amalicious', + }) + + expect(response.statusCode).toBe(400) + const body = response.json<{ error: string; message: string }>() + expect(body.message).toContain('format') + expect(body.message).toContain('must be equal to one of the allowed values') + }) + + it('rejects resize parameter with newline character in HEAD route', async () => { + const response = await appInstance.inject({ + method: 'HEAD', + url: '/object/public/public-bucket-2/favicon.ico?resize=cover%0Amalicious', + }) + + expect(response.statusCode).toBe(400) + }) + + it('accepts valid transformation parameters in info route', async () => { + const response = await appInstance.inject({ + method: 'GET', + url: '/object/info/public/public-bucket-2/favicon.ico?width=100&height=200', + }) + + expect(response.statusCode).toBe(200) + }) + }) }) diff --git a/src/test/validators.test.ts b/src/test/validators.test.ts index cda5cb1c..e354c310 100644 --- a/src/test/validators.test.ts +++ b/src/test/validators.test.ts @@ -1,5 +1,74 @@ +import Fastify, { FastifyInstance } from 'fastify' +import { setErrorHandler } from '../http/error-handler' +import { headerValidator } from '../http/plugins/header-validator' import { validateXRobotsTag } from '../storage/validators/x-robots-tag' +describe('header-validator plugin', () => { + let app: FastifyInstance + + beforeEach(async () => { + app = Fastify() + await app.register(headerValidator) + setErrorHandler(app) + }) + + afterEach(async () => { + await app.close() + }) + + it('should reject response with newline in header value', async () => { + app.get('/test', async (_request, reply) => { + reply.header('x-test', 'value\nwith\nnewlines') + return { ok: true } + }) + + const response = await app.inject({ method: 'GET', url: '/test' }) + + expect(response.statusCode).toBe(400) + const body = response.json() + expect(body.message).toContain('Invalid character in response header') + expect(body.message).toContain('x-test') + }) + + it('should reject response with carriage return in header value', async () => { + app.get('/test', async (_request, reply) => { + reply.header('x-custom', 'value\rwith\rCR') + return { ok: true } + }) + + const response = await app.inject({ method: 'GET', url: '/test' }) + + expect(response.statusCode).toBe(400) + const body = response.json() + expect(body.error).toBe('Bad Request') + expect(body.message).toContain('Invalid character in response header') + }) + + it('should allow valid header values with TAB character', async () => { + app.get('/test', async (_request, reply) => { + reply.header('x-custom', 'value\twith\ttabs') + return { ok: true } + }) + + const response = await app.inject({ method: 'GET', url: '/test' }) + + expect(response.statusCode).toBe(200) + expect(response.headers['x-custom']).toBe('value\twith\ttabs') + }) + + it('should allow normal ASCII header values', async () => { + app.get('/test', async (_request, reply) => { + reply.header('x-transformations', 'width:100,height:200,resize:cover') + return { ok: true } + }) + + const response = await app.inject({ method: 'GET', url: '/test' }) + + expect(response.statusCode).toBe(200) + expect(response.headers['x-transformations']).toBe('width:100,height:200,resize:cover') + }) +}) + describe('validateXRobotsTag', () => { describe('invalid inputs', () => { it('should throw error for empty string', () => { From ead80fa0403af86e1ad47216146934de0dbeb5f8 Mon Sep 17 00:00:00 2001 From: Lenny Date: Fri, 20 Mar 2026 11:56:32 +0700 Subject: [PATCH 2/2] handle header arrays and exclude urls --- src/app.ts | 2 +- src/http/plugins/header-validator.ts | 40 +++++++++++++++++++--------- src/test/validators.test.ts | 32 +++++++++++++++++++++- 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/app.ts b/src/app.ts index 783b25fa..cab12ffb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -69,7 +69,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { ) app.register(plugins.tracing) app.register(plugins.logRequest({ excludeUrls: excludedRoutesFromMonitoring })) - app.register(plugins.headerValidator) + app.register(plugins.headerValidator({ excludeUrls: excludedRoutesFromMonitoring })) app.register(routes.tus, { prefix: 'upload/resumable' }) app.register(routes.bucket, { prefix: 'bucket' }) app.register(routes.object, { prefix: 'object' }) diff --git a/src/http/plugins/header-validator.ts b/src/http/plugins/header-validator.ts index cf6ca098..93c030ac 100644 --- a/src/http/plugins/header-validator.ts +++ b/src/http/plugins/header-validator.ts @@ -10,25 +10,39 @@ import fastifyPlugin from 'fastify-plugin' */ const INVALID_HEADER_CHAR_PATTERN = /[^\t\x20-\x7e\x80-\xff]/ +interface HeaderValidatorOptions { + excludeUrls?: string[] +} + /** * Validates response headers before they're sent to prevent ERR_INVALID_CHAR crashes. * * Node.js throws ERR_INVALID_CHAR during writeHead() if headers contain control characters. * This hook validates headers in onSend (before writeHead) and throws InvalidHeaderChar error */ -export const headerValidator = fastifyPlugin( - async function headerValidatorPlugin(fastify: FastifyInstance) { - fastify.addHook('onSend', async (_request: FastifyRequest, reply: FastifyReply, payload) => { - const headers = reply.getHeaders() +export const headerValidator = (options: HeaderValidatorOptions = {}) => + fastifyPlugin( + async function headerValidatorPlugin(fastify: FastifyInstance) { + fastify.addHook('onSend', async (request: FastifyRequest, reply: FastifyReply, payload) => { + if (options.excludeUrls?.includes(request.url.toLowerCase())) { + return payload + } - for (const [key, value] of Object.entries(headers)) { - if (typeof value === 'string' && INVALID_HEADER_CHAR_PATTERN.test(value)) { - throw ERRORS.InvalidHeaderChar(key, value) + const headers = Object.entries(reply.getHeaders()) + for (const [key, value] of headers) { + if (typeof value === 'string' && INVALID_HEADER_CHAR_PATTERN.test(value)) { + throw ERRORS.InvalidHeaderChar(key, value) + } else if (Array.isArray(value)) { + for (let item of value) { + if (typeof item === 'string' && INVALID_HEADER_CHAR_PATTERN.test(item)) { + throw ERRORS.InvalidHeaderChar(key, item) + } + } + } } - } - return payload - }) - }, - { name: 'header-validator' } -) + return payload + }) + }, + { name: 'header-validator' } + ) diff --git a/src/test/validators.test.ts b/src/test/validators.test.ts index e354c310..8fac0932 100644 --- a/src/test/validators.test.ts +++ b/src/test/validators.test.ts @@ -8,7 +8,7 @@ describe('header-validator plugin', () => { beforeEach(async () => { app = Fastify() - await app.register(headerValidator) + await app.register(headerValidator()) setErrorHandler(app) }) @@ -67,6 +67,36 @@ describe('header-validator plugin', () => { expect(response.statusCode).toBe(200) expect(response.headers['x-transformations']).toBe('width:100,height:200,resize:cover') }) + + it('should reject response with newline in array header value', async () => { + app.get('/test', async (_request, reply) => { + reply.header('x-test', ['blah', 'stuff', 'value\nwith\nnewlines', 'other']) + return { ok: true } + }) + + const response = await app.inject({ method: 'GET', url: '/test' }) + + expect(response.statusCode).toBe(400) + const body = response.json() + expect(body.message).toContain('Invalid character in response header') + expect(body.message).toContain('x-test') + }) + + it('should allow normal ASCII array header values', async () => { + app.get('/test', async (_request, reply) => { + reply.header('x-transformations', ['width:100,height:200,resize:cover', 'blah', 'blah']) + return { ok: true } + }) + + const response = await app.inject({ method: 'GET', url: '/test' }) + + expect(response.statusCode).toBe(200) + expect(response.headers['x-transformations']).toEqual([ + 'width:100,height:200,resize:cover', + 'blah', + 'blah', + ]) + }) }) describe('validateXRobotsTag', () => {