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
26 changes: 0 additions & 26 deletions src/http/plugins/header-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,32 +30,6 @@ describe('header-validator plugin', () => {
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')
Expand Down
20 changes: 8 additions & 12 deletions src/http/plugins/header-validator.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import { ERRORS } from '@internal/errors'
import { hasInvalidHeaderValueChars } from '@internal/http/header'
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]/

interface HeaderValidatorOptions {
excludeUrls?: string[]
}
Expand All @@ -34,11 +27,14 @@ export const headerValidator = (options: HeaderValidatorOptions = {}) =>
continue
}
const value = headers[key]
if (typeof value === 'string' && INVALID_HEADER_CHAR_PATTERN.test(value)) {
throw ERRORS.InvalidHeaderChar(key, value)
if (typeof value === 'string') {
if (hasInvalidHeaderValueChars(value)) {
throw ERRORS.InvalidHeaderChar(key, value)
}
} else if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === 'string' && INVALID_HEADER_CHAR_PATTERN.test(item)) {
for (let j = 0; j < value.length; j++) {
const item = value[j]
if (typeof item === 'string' && hasInvalidHeaderValueChars(item)) {
throw ERRORS.InvalidHeaderChar(key, item)
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/http/routes/s3/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ export default async function routes(fastify: FastifyInstance) {
continue
}

if (headers[header]) {
reply.header(header, headers[header])
const value = headers[header]
if (value || (value === '' && header.startsWith('x-amz-meta-'))) {
reply.header(header, value)
}
}
}
Expand Down
58 changes: 58 additions & 0 deletions src/http/routes/s3/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,52 @@ describe('S3 route handler matching', () => {

expect(setAttribute).toHaveBeenCalledWith('http.operation', 'storage.s3.bucket.list')
})

it('forwards empty S3 metadata response headers', async () => {
const findObject = vi.fn().mockResolvedValue({
created_at: '2026-06-25T00:00:00.000Z',
metadata: {
eTag: '"etag"',
mimetype: 'text/plain',
size: '0',
},
updated_at: '2026-06-25T00:00:00.000Z',
user_metadata: {
empty: '',
},
})

await withMockedS3App(
async (app) => {
const response = await app.inject({
method: 'HEAD',
url: '/bucket/object.txt',
})

expect(response.statusCode).toBe(200)
expect(response.headers['x-amz-meta-empty']).toBe('')
expect(response.headers.expires).toBeUndefined()
expect(response.headers['cache-control']).toBeUndefined()
},
{
configureRequest: (request) => {
Object.assign(request, {
owner: 'owner-id',
signals: {
body: new AbortController(),
response: new AbortController(),
},
storage: {
from: vi.fn(() => ({
findObject,
})),
},
tenantId: 'tenant-id',
})
},
}
)
})
})

describe('S3 router type matching', () => {
Expand Down Expand Up @@ -586,6 +632,18 @@ describe('S3ProtocolHandler.parseMetadataHeaders', () => {
})
})

it('keeps empty string metadata values', () => {
const handler = createHandler()

expect(
handler.parseMetadataHeaders({
'x-amz-meta-empty': '',
})
).toEqual({
empty: '',
})
})

it('returns undefined when there are no metadata headers', () => {
const handler = createHandler()

Expand Down
95 changes: 95 additions & 0 deletions src/internal/http/header.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
hasInvalidHeaderValueChars,
isValidHeader,
MAX_HEADER_NAME_LENGTH,
MAX_HEADER_VALUE_LENGTH,
} from './header'

describe('hasInvalidHeaderValueChars', () => {
it.each([
['empty value', ''],
['visible ASCII', 'width:100,height:200,resize:cover'],
['horizontal tab', 'value\twith\ttabs'],
['obs-text upper byte range', `value${String.fromCharCode(0x80)}${String.fromCharCode(0xff)}`],
])('allows %s', (_name, value) => {
expect(hasInvalidHeaderValueChars(value)).toBe(false)
})

it.each([
['NUL', '\x00'],
['unit separator', '\x1f'],
['line feed', '\n'],
['carriage return', '\r'],
['DEL', '\x7f'],
['code point above one byte', '\u0100'],
])('rejects %s', (_name, value) => {
expect(hasInvalidHeaderValueChars(`value${value}`)).toBe(true)
})
})

describe('isValidHeader', () => {
it('accepts a typical header name and value', () => {
expect(isValidHeader('content-type', 'application/json')).toBe(true)
})

it('accepts an empty header value', () => {
expect(isValidHeader('x-custom', '')).toBe(true)
})

it('accepts all token chars permitted by RFC7230 section 3.2.6', () => {
expect(isValidHeader("!#$%&'*+-.^_`|~09AZaz", 'v')).toBe(true)
})

it('rejects header names containing characters outside the token set', () => {
expect(isValidHeader('bad name', 'v')).toBe(false)
expect(isValidHeader('bad:name', 'v')).toBe(false)
expect(isValidHeader('bad(name)', 'v')).toBe(false)
expect(isValidHeader('badåname', 'v')).toBe(false)
})

it('rejects an empty header name', () => {
expect(isValidHeader('', 'v')).toBe(false)
})

it('rejects header names exceeding the max length', () => {
const oversizedName = 'a'.repeat(MAX_HEADER_NAME_LENGTH + 1)
expect(isValidHeader(oversizedName, 'value')).toBe(false)
})

it('accepts header names exactly at the max length', () => {
const maxName = 'a'.repeat(MAX_HEADER_NAME_LENGTH)
expect(isValidHeader(maxName, 'value')).toBe(true)
})

it('rejects header values containing control characters', () => {
expect(isValidHeader('x-custom', 'bad\x00value')).toBe(false)
expect(isValidHeader('x-custom', 'bad\nvalue')).toBe(false)
})

it('rejects header values containing CRLF', () => {
expect(isValidHeader('x-custom', 'innocent\r\nX-Injected: 1')).toBe(false)
})

it('rejects header values exceeding the max byte length', () => {
const oversizedValue = 'a'.repeat(MAX_HEADER_VALUE_LENGTH + 1)
expect(isValidHeader('x-custom', oversizedValue)).toBe(false)
})

it('accepts header values exactly at the max byte length', () => {
const maxValue = 'a'.repeat(MAX_HEADER_VALUE_LENGTH)
expect(isValidHeader('x-custom', maxValue)).toBe(true)
})

it('counts obs-text values by UTF-8 byte length', () => {
expect(isValidHeader('x-custom', String.fromCharCode(0x80).repeat(4096))).toBe(true)
expect(isValidHeader('x-custom', String.fromCharCode(0x80).repeat(4097))).toBe(false)
})

it('accepts an array of values when all are valid', () => {
expect(isValidHeader('x-custom', ['one', 'two', 'three'])).toBe(true)
})

it('rejects an array of values when any are invalid', () => {
expect(isValidHeader('x-custom', ['ok', 'bad\x00value'])).toBe(false)
})
})
91 changes: 91 additions & 0 deletions src/internal/http/header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export const MAX_HEADER_NAME_LENGTH = 1024 * 8 // 8KB
export const MAX_HEADER_VALUE_LENGTH = 1024 * 8 // 8KB

/**
* Checks whether a character code is valid in an HTTP token per RFC 7230.
* Header names are tokens: alphanumeric plus !#$%&'*+-.^_`|~.
* @see https://tools.ietf.org/html/rfc7230#section-3.2.6
*/
const isHttpTokenCharCode = (c: number): boolean =>
(c >= 0x30 && c <= 0x39) ||
(c >= 0x41 && c <= 0x5a) ||
(c >= 0x61 && c <= 0x7a) ||
c === 0x21 ||
(c >= 0x23 && c <= 0x27) ||
c === 0x2a ||
c === 0x2b ||
c === 0x2d ||
c === 0x2e ||
c === 0x5e ||
c === 0x5f ||
c === 0x60 ||
c === 0x7c ||
c === 0x7e

/**
* Checks if a string contains invalid HTTP header characters per RFC 7230.
* Valid: TAB (0x09), visible ASCII (0x20-0x7E), obs-text (0x80-0xFF).
* Invalid: control characters (0x00-0x1F except TAB), DEL (0x7F), and >0xFF.
* Uses charCodeAt for lower overhead than regex on short header values.
* @see https://tools.ietf.org/html/rfc7230#section-3.2
*/
const isInvalidHeaderValueCharCode = (c: number): boolean =>
c > 0xff || (c < 0x20 && c !== 0x09) || c === 0x7f

export function hasInvalidHeaderValueChars(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (isInvalidHeaderValueCharCode(value.charCodeAt(i))) {
return true
}
}
return false
}

export function isValidHeaderName(name: string): boolean {
if (name.length === 0 || name.length > MAX_HEADER_NAME_LENGTH) {
return false
}

for (let i = 0; i < name.length; i++) {
if (!isHttpTokenCharCode(name.charCodeAt(i))) {
return false
}
}

return true
}

export function isValidHeaderValue(value: string): boolean {
let byteLength = 0

for (let i = 0; i < value.length; i++) {
const c = value.charCodeAt(i)
if (isInvalidHeaderValueCharCode(c)) {
return false
}

byteLength += c < 0x80 ? 1 : 2
if (byteLength > MAX_HEADER_VALUE_LENGTH) {
return false
}
}

return true
}

export function isValidHeader(name: string, value: string | string[]): boolean {
if (!isValidHeaderName(name)) {
return false
}

if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (!isValidHeaderValue(value[i])) {
return false
}
}
return true
}

return isValidHeaderValue(value)
}
1 change: 1 addition & 0 deletions src/internal/http/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './agent'
export * from './header'
Loading