diff --git a/src/lib/headers.spec.ts b/src/lib/headers.spec.ts index a359b107..4b03fa5d 100644 --- a/src/lib/headers.spec.ts +++ b/src/lib/headers.spec.ts @@ -7,6 +7,7 @@ import { excludeHopByHopHeaders, excludeOriginSecurityContextHeaders, excludeUnusedHeaders, + sortHeaders, } from './headers' describe('appendVaryHeader', () => { @@ -159,3 +160,39 @@ describe('excludeUnusedHeaders', () => { }) }) }) + +describe('sortHeaders', () => { + it('returns empty object unchanged', () => { + expect(sortHeaders({})).toStrictEqual({}) + }) + + it('sorts keys lexicographically', () => { + const result = sortHeaders({ + 'x-rendering-proxy': 'value', + 'content-type': 'text/html', + 'accept-ranges': 'bytes', + }) + expect(Object.keys(result)).toStrictEqual([ + 'accept-ranges', + 'content-type', + 'x-rendering-proxy', + ]) + expect(result).toStrictEqual({ + 'accept-ranges': 'bytes', + 'content-type': 'text/html', + 'x-rendering-proxy': 'value', + }) + }) + + it('preserves all header values', () => { + const input = { b: '2', a: '1', c: '3' } + const result = sortHeaders(input) + expect(result).toStrictEqual({ a: '1', b: '2', c: '3' }) + }) + + it('does not mutate the input object', () => { + const input = { z: 'last', a: 'first' } + sortHeaders(input) + expect(Object.keys(input)).toStrictEqual(['z', 'a']) + }) +}) diff --git a/src/lib/headers.ts b/src/lib/headers.ts index 2d8d4fe9..696ec8ed 100644 --- a/src/lib/headers.ts +++ b/src/lib/headers.ts @@ -180,3 +180,18 @@ export function excludeUnusedHeaders(headers: Headers): Headers { excludeContentDependentHeaders(excludeHopByHopHeaders(headers)), ) } + +/** + * Return a new headers object with keys sorted lexicographically. + * Eliminates non-determinism introduced by browser enumeration order or + * upstream server header order. + * @param headers {Headers} + * @returns headers {Headers} + */ +export function sortHeaders(headers: Headers): Headers { + const result: Headers = {} + for (const key of Object.keys(headers).sort()) { + result[key] = headers[key] + } + return result +} diff --git a/src/server/index.ts b/src/server/index.ts index 2aaf518e..0bae280d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,11 @@ import type { Browser } from 'playwright' import { runWithDefer } from 'with-defer' import { getBrowser, type SelectableBrowsers } from '../browser' -import { appendVaryHeader, excludeUnusedHeaders } from '../lib/headers' +import { + appendVaryHeader, + excludeUnusedHeaders, + sortHeaders, +} from '../lib/headers' import { isAbsoluteURL } from '../lib/url' import { waitForProcessExit } from '../lib/wait_for_exit' import { getRenderedContent } from '../render' @@ -64,13 +68,15 @@ async function renderToResponse( request: Parameters[1], ): Promise { const renderedContent = await getRenderedContent(browser, request) - const headers = appendVaryHeader( - { - ...excludeUnusedHeaders(renderedContent.headers), - 'x-rendering-proxy': JSON.stringify(renderedContent.evaluateResults), - 'x-rendering-proxy-version': String(PROTOCOL_VERSION), - }, - 'x-rendering-proxy', + const headers = sortHeaders( + appendVaryHeader( + { + ...excludeUnusedHeaders(renderedContent.headers), + 'x-rendering-proxy': JSON.stringify(renderedContent.evaluateResults), + 'x-rendering-proxy-version': String(PROTOCOL_VERSION), + }, + 'x-rendering-proxy', + ), ) res.writeHead(renderedContent.status, headers) res.end(renderedContent.body, 'binary')