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
1 change: 1 addition & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => {
)
app.register(plugins.tracing)
app.register(plugins.logRequest({ excludeUrls: excludedRoutesFromMonitoring }))
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' })
Expand Down
48 changes: 48 additions & 0 deletions src/http/plugins/header-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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]/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, we have this regex for s3


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 = (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
}

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' }
)
1 change: 1 addition & 0 deletions src/http/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './apikey'
export * from './db'
export * from './header-validator'
export * from './iceberg'
export * from './jwt'
export * from './log-request'
Expand Down
13 changes: 13 additions & 0 deletions src/http/routes/object/getObjectInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -17,8 +18,14 @@ const getObjectParamsSchema = {
required: ['bucketName', '*'],
} as const

const getObjectInfoQuerySchema = {
type: 'object',
properties: transformationOptionsSchema,
} as const

interface getObjectRequestInterface extends AuthenticatedRangeRequest {
Params: FromSchema<typeof getObjectParamsSchema>
Querystring: FromSchema<typeof getObjectInfoQuerySchema>
}

type GetObjectInfoRequest = FastifyRequest<getObjectRequestInterface>
Expand Down Expand Up @@ -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'],
Expand All @@ -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'],
Expand All @@ -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' } },
Expand All @@ -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' } },
Expand All @@ -167,6 +178,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) {
{
schema: {
params: getObjectParamsSchema,
querystring: getObjectInfoQuerySchema,
summary,
description: 'Object Info',
tags: ['object'],
Expand All @@ -187,6 +199,7 @@ export async function authenticatedRoutes(fastify: FastifyInstance) {
{
schema: {
params: getObjectParamsSchema,
querystring: getObjectInfoQuerySchema,
summary,
description: 'Head object info',
tags: ['object'],
Expand Down
8 changes: 8 additions & 0 deletions src/internal/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 32 additions & 0 deletions src/test/render-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
99 changes: 99 additions & 0 deletions src/test/validators.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,104 @@
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')
})

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', () => {
describe('invalid inputs', () => {
it('should throw error for empty string', () => {
Expand Down