diff --git a/src/headers.ts b/src/headers.ts index 71eaef5..1f32a4d 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -1,15 +1,14 @@ import type { IncomingMessage } from 'node:http' -import type { Http2ServerRequest } from 'node:http2' +import { Http2ServerRequest } from 'node:http2' type IncomingHeadersSource = Pick & { - headers?: Record + headers?: IncomingMessage['headers'] } -const incomingHeadersKey = Symbol('incomingHeaders') -type IncomingHeadersInit = { [incomingHeadersKey]: IncomingHeadersSource } -// Node keeps only the first occurrence of these headers in `incoming.headers`, -// while WHATWG Headers combines repeated values. Fall back to rawHeaders when -// one of them is actually repeated. +// Node's HTTP/1 parser already joins ordinary repeated headers with the same +// separators as WHATWG Headers, so its parsed object is a safe fast path for +// those names. It discards repeats of this fixed set by default, however, and +// HTTP/2 has different collapsing rules; resolve those cases from rawHeaders. // https://nodejs.org/api/http.html#messageheaders // https://github.com/nodejs/node/blob/v26.7.0/lib/_http_incoming.js // https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2 @@ -39,14 +38,37 @@ const nonJoinedHeaders = new Set([ // https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2 const validHeaderName = /^[!#$%&'*+\-.^_`|~\dA-Za-z]+$/ +const isHttpWhitespace = (code: number): boolean => + code === 0x09 || code === 0x0a || code === 0x0d || code === 0x20 + +const normalizeHeaderValue = (value: string): string => { + if ( + !isHttpWhitespace(value.charCodeAt(0)) && + !isHttpWhitespace(value.charCodeAt(value.length - 1)) + ) { + return value + } + let start = 0 + let end = value.length + while (start < end && isHttpWhitespace(value.charCodeAt(start))) { + start++ + } + while (end > start && isHttpWhitespace(value.charCodeAt(end - 1))) { + end-- + } + return value.slice(start, end) +} + +const forbiddenHeaderValue = /[\0\r\n]/ + export const GlobalHeaders = globalThis.Headers export type GlobalHeaders = InstanceType const materializeHeaders = ( - incoming: Pick + rawHeaders: string[], + HeadersCtor: typeof GlobalHeaders = GlobalHeaders ): GlobalHeaders => { - const headers = new GlobalHeaders() - const rawHeaders = incoming.rawHeaders + const headers = new HeadersCtor() for (let i = 0; i < rawHeaders.length; i += 2) { const name = rawHeaders[i] if (!name.startsWith(':')) { @@ -56,32 +78,27 @@ const materializeHeaders = ( return headers } -export class Headers { +export class RequestHeaders { #incoming: IncomingHeadersSource + #rawHeaders?: string[] #headers?: GlobalHeaders + #invalidValue?: boolean - constructor(init?: HeadersInit | IncomingHeadersInit) { - if (init && typeof init === 'object' && incomingHeadersKey in init) { - this.#incoming = init[incomingHeadersKey] - } else { - // When installed as global.Headers, ordinary `new Headers(init)` calls - // still need native constructor semantics. Only incoming Node headers - // have a source that can be read lazily. - this.#incoming = { rawHeaders: [] } - this.#headers = new GlobalHeaders(init as HeadersInit | undefined) + constructor(incoming: IncomingHeadersSource) { + this.#incoming = incoming + if (incoming instanceof Http2ServerRequest) { + this.#rawHeaders = incoming.rawHeaders.slice() } } - // Native Headers created before the global replacement must remain - // `instanceof Headers`. This also covers this facade because its prototype - // inherits from GlobalHeaders.prototype. - static [Symbol.hasInstance](value: unknown): boolean { - return value instanceof GlobalHeaders + get #lazyRawHeaders(): string[] { + return (this.#rawHeaders ??= this.#incoming.rawHeaders.slice()) } get #native(): GlobalHeaders { if (!this.#headers) { - this.#headers = materializeHeaders(this.#incoming) + this.#headers = materializeHeaders(this.#lazyRawHeaders) + this.#rawHeaders = undefined } return this.#headers } @@ -90,8 +107,54 @@ export class Headers { if (typeof name !== 'string') { return } - const lowerName = name.toLowerCase() - return validHeaderName.test(name) && lowerName !== '__proto__' ? lowerName : undefined + if (!validHeaderName.test(name)) { + throw new TypeError(`Invalid header name: ${name}`) + } + return name.toLowerCase() + } + + // The HTTP/1 fast path trusts Node's parser-produced headers object. Mutating + // it through the incoming binding is outside this optimization's contract; + // detecting such changes would require scanning or copying every header. + #lookupHttp1(lowerName: string): string | null | undefined { + const headers = + this.#incoming instanceof Http2ServerRequest ? undefined : this.#incoming.headers + if ( + !headers || + nonJoinedHeaders.has(lowerName) || + lowerName === 'set-cookie' || + lowerName === '__proto__' + ) { + return + } + + if (!Object.hasOwn(headers, lowerName)) { + return null + } + const rawValue = headers[lowerName] + if (typeof rawValue === 'string') { + const value = normalizeHeaderValue(rawValue) + return forbiddenHeaderValue.test(value) ? undefined : value + } + return + } + + #lookup(rawHeaders: string[], lowerName: string): string | null | undefined { + const separator = lowerName === 'cookie' ? '; ' : ', ' + let value: string | null = null + for (let i = 0; i < rawHeaders.length; i += 2) { + const rawName = rawHeaders[i] + if (rawName.length === lowerName.length && rawName.toLowerCase() === lowerName) { + const rawValue = normalizeHeaderValue(rawHeaders[i + 1]) + if (forbiddenHeaderValue.test(rawValue)) { + this.#invalidValue = true + return + } + value = value === null ? rawValue : value + separator + rawValue + } + } + + return value } append(name: string, value: string): void { @@ -103,49 +166,33 @@ export class Headers { } get(name: string): string | null { - if (this.#headers) { - return this.#native.get(name) - } - const lowerName = this.#normalizedName(name) - if (!lowerName) { - return this.#native.get(name) - } - - const value = this.#incoming.headers?.[lowerName] - if (typeof value === 'string') { - if (nonJoinedHeaders.has(lowerName)) { - let found = false - for (let i = 0; i < this.#incoming.rawHeaders.length; i += 2) { - const rawName = this.#incoming.rawHeaders[i] - if (rawName.length === lowerName.length && rawName.toLowerCase() === lowerName) { - if (found) { - return this.#native.get(name) - } - found = true - } - } + if (lowerName && !this.#headers && !this.#invalidValue) { + const http1Value = this.#lookupHttp1(lowerName) + if (http1Value !== undefined) { + return http1Value + } + const value = this.#lookup(this.#lazyRawHeaders, lowerName) + if (value !== undefined) { + return value } - return value - } - if (Array.isArray(value)) { - return value.join(', ') } - return this.#incoming.headers ? null : this.#native.get(name) + return this.#native.get(name) } has(name: string): boolean { - if (this.#headers) { - return this.#native.has(name) - } - const lowerName = this.#normalizedName(name) - if (!lowerName) { - return this.#native.has(name) + if (lowerName && !this.#headers && !this.#invalidValue) { + const http1Value = this.#lookupHttp1(lowerName) + if (http1Value !== undefined) { + return http1Value !== null + } + const value = this.#lookup(this.#lazyRawHeaders, lowerName) + if (value !== undefined) { + return value !== null + } } - return this.#incoming.headers - ? Object.hasOwn(this.#incoming.headers, lowerName) - : this.#native.has(name) + return this.#native.has(name) } set(name: string, value: string): void { @@ -153,14 +200,7 @@ export class Headers { } getSetCookie(): string[] { - if (this.#headers) { - return this.#headers.getSetCookie() - } - const value = this.#incoming.headers?.['set-cookie'] - if (Array.isArray(value)) { - return value.slice() - } - return value ? [value] : this.#incoming.headers ? [] : this.#native.getSetCookie() + return this.#native.getSetCookie() } keys(): HeadersIterator { @@ -189,20 +229,21 @@ export class Headers { } } -Object.defineProperty(Headers.prototype, Symbol.for('nodejs.util.inspect.custom'), { - value: function (this: Headers, depth: number, options: object, inspectFn: Function) { +Object.defineProperty(RequestHeaders.prototype, Symbol.for('nodejs.util.inspect.custom'), { + value: function (this: RequestHeaders, depth: number, options: object, inspectFn: Function) { const props = Object.fromEntries(this) return `Headers (lightweight) ${inspectFn(props, { ...options, depth: depth == null ? null : depth - 1 })}` }, }) -// Match the native constructor hierarchy so static properties are inherited. -Object.setPrototypeOf(Headers, GlobalHeaders) -// Match the native instance hierarchy so both facades and native instances -// satisfy the expected Headers instanceof checks. -Object.setPrototypeOf(Headers.prototype, GlobalHeaders.prototype) +// Keep request headers compatible with the captured Headers constructor so +// `request.headers instanceof Headers` remains true without Symbol.hasInstance +// or replacing the global constructor. +Object.setPrototypeOf(RequestHeaders.prototype, GlobalHeaders.prototype) +// Preserve the previous live-global behavior when a consumer installs a +// Headers polyfill after this module has initialized. export const newHeadersFromIncoming = (incoming: IncomingHeadersSource): GlobalHeaders => - global.Headers === Headers - ? (new Headers({ [incomingHeadersKey]: incoming }) as unknown as GlobalHeaders) - : materializeHeaders(incoming) + globalThis.Headers === GlobalHeaders + ? (new RequestHeaders(incoming) as unknown as GlobalHeaders) + : materializeHeaders(incoming.rawHeaders, globalThis.Headers) diff --git a/src/listener.ts b/src/listener.ts index bdd8cb9..c8c2763 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -2,7 +2,6 @@ import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node: import { Http2ServerRequest, constants as h2constants } from 'node:http2' import type { Http2ServerResponse } from 'node:http2' import type { Writable } from 'node:stream' -import { Headers as LightweightHeaders } from './headers' import type { IncomingMessageWithWrapBodyStream } from './request' import { abortRequest, @@ -365,9 +364,6 @@ export const getRequestListener = ( ) => { const autoCleanupIncoming = options.autoCleanupIncoming ?? true if (options.overrideGlobalObjects !== false && global.Request !== LightweightRequest) { - Object.defineProperty(global, 'Headers', { - value: LightweightHeaders, - }) Object.defineProperty(global, 'Request', { value: LightweightRequest, }) diff --git a/test/headers.test.ts b/test/headers.test.ts index 75add51..1f0740a 100644 --- a/test/headers.test.ts +++ b/test/headers.test.ts @@ -1,43 +1,48 @@ import type { IncomingMessage } from 'node:http' +import { Http2ServerRequest } from 'node:http2' +import type { ServerHttp2Stream } from 'node:http2' +import { Duplex } from 'node:stream' import { inspect } from 'node:util' -import { - GlobalHeaders, - Headers as LightweightHeaders, - newHeadersFromIncoming, -} from '../src/headers' +import { GlobalHeaders, RequestHeaders, newHeadersFromIncoming } from '../src/headers' import { newRequest, Request as LightweightRequest } from '../src/request' // Compatibility cases adapted from srvx's Node header suite: // https://github.com/h3js/srvx/blob/4052594e76d5ead2cc4c7cf8f7fa6d5ea9558a0a/test/node-headers.test.ts -const incoming = ( +const incoming = (rawHeaders: string[], headers: Record): IncomingMessage => + ({ rawHeaders, headers }) as IncomingMessage + +const incomingHttp2 = ( rawHeaders: string[], headers: Record -): IncomingMessage => ({ rawHeaders, headers }) as IncomingMessage - -const lightweightHeaders = (request: IncomingMessage): GlobalHeaders => { - Object.defineProperty(global, 'Headers', { - value: LightweightHeaders, - writable: true, - }) - return newHeadersFromIncoming(request) -} - -describe('Headers', () => { - beforeEach(() => { - Object.defineProperty(global, 'Headers', { - value: GlobalHeaders, - writable: true, - }) - }) - - afterEach(() => { - Object.defineProperty(global, 'Headers', { - value: GlobalHeaders, - writable: true, - }) - }) - +): Http2ServerRequest => + new Http2ServerRequest(new Duplex() as ServerHttp2Stream, headers, {}, rawHeaders) + +const lightweightHeaders = (request: IncomingMessage | Http2ServerRequest): GlobalHeaders => + newHeadersFromIncoming(request) + +const nonJoinedHeaderNames = [ + 'age', + 'authorization', + 'content-length', + 'content-type', + 'etag', + 'expires', + 'from', + 'host', + 'if-modified-since', + 'if-unmodified-since', + 'last-modified', + 'location', + 'max-forwards', + 'proxy-authorization', + 'referer', + 'retry-after', + 'server', + 'user-agent', +] + +describe('RequestHeaders', () => { it('reads common headers without iterating rawHeaders', () => { let rawHeadersReads = 0 const request = { @@ -54,40 +59,99 @@ describe('Headers', () => { expect(headers.get('X-Test')).toBe('value') expect(headers.has('x-test')).toBe(true) expect(rawHeadersReads).toBe(0) + expect(() => headers.get('bad name')).toThrow(TypeError) + expect(() => headers.has(':path')).toThrow(TypeError) + expect(rawHeadersReads).toBe(0) expect([...headers]).toEqual([['x-test', 'value']]) expect(rawHeadersReads).toBe(1) }) - it('combines repeated headers consistently before and after iteration', () => { - const rawHeaders = [ - 'authorization', - 'Bearer AAA', - 'authorization', - 'Bearer BBB', - 'content-type', - 'text/plain', - 'content-type', - 'application/json', - ] - const collapsed = { authorization: 'Bearer AAA', 'content-type': 'text/plain' } - - const before = lightweightHeaders(incoming(rawHeaders, collapsed)) - expect(before.get('authorization')).toBe('Bearer AAA, Bearer BBB') - expect(before.get('content-type')).toBe('text/plain, application/json') - expect(before.has('authorization')).toBe(true) - - const after = lightweightHeaders(incoming(rawHeaders, collapsed)) - void [...after] - expect(after.get('authorization')).toBe('Bearer AAA, Bearer BBB') - expect(after.get('content-type')).toBe('text/plain, application/json') + it.each(nonJoinedHeaderNames)( + 'combines repeated %s values consistently before and after iteration', + (name) => { + const rawHeaders = [name, 'one', name, 'two'] + const collapsed = { [name]: 'one' } + + const before = lightweightHeaders(incoming(rawHeaders, collapsed)) + expect(before.get(name)).toBe('one, two') + expect(before.has(name)).toBe(true) + + const after = lightweightHeaders(incoming(rawHeaders, collapsed)) + void [...after] + expect(after.get(name)).toBe('one, two') + } + ) + + it('combines headers collapsed by the HTTP/2 parser', () => { + const headers = lightweightHeaders( + incomingHttp2(['if-none-match', '"a"', 'if-none-match', '"b"'], { + 'if-none-match': '"a"', + }) + ) + + expect(headers.get('if-none-match')).toBe('"a", "b"') + void [...headers] + expect(headers.get('if-none-match')).toBe('"a", "b"') + }) + + it('uses an immutable raw-header snapshot for HTTP/2', () => { + const rawHeaders = ['x-secret', 'topsecret', 'host', 'localhost'] + const request = incomingHttp2(rawHeaders, { + 'x-secret': 'topsecret', + host: 'localhost', + }) + const headers = lightweightHeaders(request) + + delete request.headers['x-secret'] + request.headers['x-added'] = 'value' + rawHeaders[1] = 'changed' + rawHeaders.push('x-added', 'value') + + expect(headers.get('x-secret')).toBe('topsecret') + expect(headers.has('x-added')).toBe(false) + void [...headers] + expect(headers.get('x-secret')).toBe('topsecret') + expect(headers.has('x-added')).toBe(false) + }) + + it('ignores non-string values in synthesized parsed headers', () => { + const headers = lightweightHeaders( + incoming(['content-length', '123'], { 'content-length': 123 }) + ) + + expect(headers.get('content-length')).toBe('123') + expect(headers.has('content-length')).toBe(true) + void [...headers] + expect(headers.get('content-length')).toBe('123') + }) + + it('normalizes raw values consistently before and after materialization', () => { + const rawHeaders = ['x-token', ' abc\t', 'x-tab', '\tv v\t', 'x-interior', 'a b'] + const headers = lightweightHeaders(incomingHttp2(rawHeaders, {})) + + expect(headers.get('x-token')).toBe('abc') + expect(headers.get('x-tab')).toBe('v v') + expect(headers.get('x-interior')).toBe('a b') + void [...headers] + expect(headers.get('x-token')).toBe('abc') + expect(headers.get('x-tab')).toBe('v v') + expect(headers.get('x-interior')).toBe('a b') + }) + + it('fails closed on values rejected by native Headers', () => { + const rawHeaders = ['x-evil', 'ok', 'x-evil', 'bad\0value'] + + expect(() => lightweightHeaders(incomingHttp2(rawHeaders, {})).get('x-evil')).toThrow(TypeError) + expect(() => lightweightHeaders(incomingHttp2(rawHeaders, {})).has('x-evil')).toThrow(TypeError) + expect(() => [...lightweightHeaders(incomingHttp2(rawHeaders, {}))]).toThrow(TypeError) }) it('preserves cookie and set-cookie representations', () => { const headers = lightweightHeaders( incoming(['cookie', 'a=1', 'cookie', 'b=2', 'set-cookie', 'a=1', 'set-cookie', 'b=2'], { cookie: 'a=1; b=2', - 'set-cookie': ['a=1', 'b=2'], + 'set-cookie': ['ignored'], }) ) @@ -130,38 +194,48 @@ describe('Headers', () => { expect(inspect(headers)).toContain("Headers (lightweight) { 'x-test': 'value' }") }) - it('supports the standard Headers constructor when installed globally', () => { - Object.defineProperty(global, 'Headers', { - value: LightweightHeaders, - writable: true, - }) - + it('leaves the standard Headers constructor unchanged', () => { const headers = new Headers({ 'x-test': 'one' }) headers.append('x-test', 'two') + expect(global.Headers).toBe(GlobalHeaders) + expect(Object.getPrototypeOf(headers)).toBe(GlobalHeaders.prototype) expect(headers.get('x-test')).toBe('one, two') expect(new Headers(headers).get('x-test')).toBe('one, two') expect(new Headers({ rawHeaders: 'ordinary value' }).get('rawHeaders')).toBe('ordinary value') }) - it('selects the implementation from the active global', () => { + it('uses the internal implementation without replacing the global constructor', () => { const request = incoming(['x-test', 'value'], { 'x-test': 'value' }) - const native = newHeadersFromIncoming(request) - expect(Object.getPrototypeOf(native)).toBe(GlobalHeaders.prototype) + const headers = newHeadersFromIncoming(request) + + expect(global.Headers).toBe(GlobalHeaders) + expect(Object.getPrototypeOf(headers)).toBe(RequestHeaders.prototype) + expect(headers).toBeInstanceOf(GlobalHeaders) + expect(new GlobalHeaders(headers).get('x-test')).toBe('value') + }) - Object.defineProperty(global, 'Headers', { - value: LightweightHeaders, + it('uses the live global Headers constructor when it changes after module initialization', () => { + class PolyfillHeaders extends GlobalHeaders {} + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Headers') + Object.defineProperty(globalThis, 'Headers', { + value: PolyfillHeaders, + configurable: true, writable: true, }) - const lightweight = newHeadersFromIncoming(request) - expect(Object.getPrototypeOf(lightweight)).toBe(LightweightHeaders.prototype) + + try { + const headers = newHeadersFromIncoming(incoming(['x-test', 'value'], { 'x-test': 'value' })) + + expect(headers).toBeInstanceOf(PolyfillHeaders) + expect(Object.getPrototypeOf(headers)).toBe(PolyfillHeaders.prototype) + expect(headers.get('x-test')).toBe('value') + } finally { + Object.defineProperty(globalThis, 'Headers', descriptor!) + } }) it('can initialize and clone a native Request after header mutation', () => { - Object.defineProperty(global, 'Headers', { - value: LightweightHeaders, - writable: true, - }) const request = newRequest({ method: 'GET', url: '/', diff --git a/test/listener.test.ts b/test/listener.test.ts index 58d2284..14a7690 100644 --- a/test/listener.test.ts +++ b/test/listener.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { Readable } from 'node:stream' -import { GlobalHeaders, Headers as LightweightHeaders } from '../src/headers' +import { GlobalHeaders } from '../src/headers' import { getRequestListener } from '../src/listener' import { GlobalRequest, Request as LightweightRequest, RequestError } from '../src/request' import { GlobalResponse, Response as LightweightResponse } from '../src/response' @@ -648,7 +648,7 @@ describe('overrideGlobalObjects', () => { describe('default', () => { it('Should be overridden', () => { getRequestListener(fetchCallback) - expect(global.Headers).toBe(LightweightHeaders) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) }) @@ -659,7 +659,7 @@ describe('overrideGlobalObjects', () => { getRequestListener(fetchCallback, { overrideGlobalObjects: true, }) - expect(global.Headers).toBe(LightweightHeaders) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) }) diff --git a/test/server.test.ts b/test/server.test.ts index 44375c2..387353d 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -8,7 +8,7 @@ import fs from 'node:fs' import { createServer as createHttp2Server } from 'node:http2' import { createServer as createHTTPSServer } from 'node:https' import { gunzipSync, inflateSync } from 'node:zlib' -import { GlobalHeaders, Headers as LightweightHeaders } from '../src/headers' +import { GlobalHeaders } from '../src/headers' import { GlobalRequest, Request as LightweightRequest, getAbortController } from '../src/request' import { GlobalResponse, Response as LightweightResponse } from '../src/response' import { createAdaptorServer, serve } from '../src/server' @@ -1122,7 +1122,7 @@ describe('overrideGlobalObjects', () => { describe('default', () => { it('Should be overridden', () => { createAdaptorServer(app) - expect(global.Headers).toBe(LightweightHeaders) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) }) @@ -1131,7 +1131,7 @@ describe('overrideGlobalObjects', () => { describe('overrideGlobalObjects: true', () => { it('Should be overridden', () => { createAdaptorServer({ overrideGlobalObjects: true, fetch: app.fetch }) - expect(global.Headers).toBe(LightweightHeaders) + expect(global.Headers).toBe(GlobalHeaders) expect(global.Request).toBe(LightweightRequest) expect(global.Response).toBe(LightweightResponse) })