diff --git a/README.md b/README.md index 9275b035..53dd92c5 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ x-rendering-proxy-version: 1 | `evaluates` | `string[]` | `[]` | JavaScript snippets to evaluate before capturing the DOM. | | `timeout` | `number` (ms) | none | Navigation timeout in milliseconds. | +Requests with a non-object `X-Rendering-Proxy` JSON payload or with fields of the wrong type are rejected with `400 Bad Request`. + **Response header** `X-Rendering-Proxy` — JSON array of `EvaluateResult`: ```ts diff --git a/src/server/index.spec.ts b/src/server/index.spec.ts index ce38d963..73e02bbe 100644 --- a/src/server/index.spec.ts +++ b/src/server/index.spec.ts @@ -51,6 +51,24 @@ describe('parseIncomingRenderRequest', () => { }) }) + it('returns badRequest when x-rendering-proxy is valid JSON but not an object', () => { + const req = makeRequest('/http://example.com', { + 'x-rendering-proxy': '1234', + }) + expect(parseIncomingRenderRequest(req)).toStrictEqual({ + type: 'badRequest', + }) + }) + + it('returns badRequest when x-rendering-proxy fields have invalid types', () => { + const req = makeRequest('/http://example.com', { + 'x-rendering-proxy': '{"evaluates": 1234, "waitUntil": 3456}', + }) + expect(parseIncomingRenderRequest(req)).toStrictEqual({ + type: 'badRequest', + }) + }) + it('returns render type when x-rendering-proxy header is valid JSON', () => { const req = makeRequest('/http://example.com', { 'x-rendering-proxy': '{"waitUntil":"load"}', diff --git a/src/server/index.ts b/src/server/index.ts index a4c2fc77..1637d738 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -99,7 +99,9 @@ async function respondToIncomingRequest( }, badRequest: async () => { res.writeHead(400) - res.end('Bad Request: x-rendering-proxy header contains invalid JSON') + res.end( + 'Bad Request: x-rendering-proxy header must be a valid JSON object', + ) }, render: async () => { if (parsedRequest.type !== 'render') { diff --git a/src/server/request_options.spec.ts b/src/server/request_options.spec.ts index d3ed3c24..97b8dae2 100644 --- a/src/server/request_options.spec.ts +++ b/src/server/request_options.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { parseRenderingProxyHeader } from './request_options' describe('parseRenderingProxyHeader', () => { - it('returns default when header is absent, empty, or contains valid JSON with wrong types', async () => { + it('returns default when header is absent or empty', async () => { expect(parseRenderingProxyHeader(undefined)).toStrictEqual({ waitUntil: 'load', evaluates: [], @@ -19,23 +19,9 @@ describe('parseRenderingProxyHeader', () => { evaluates: [], timeout: undefined, }) - // Valid JSON but not an object — unknown fields normalize to defaults - expect(parseRenderingProxyHeader('1234')).toStrictEqual({ - waitUntil: 'load', - evaluates: [], - timeout: undefined, - }) - // Valid JSON with wrong field types — normalize to defaults - expect( - parseRenderingProxyHeader('{"evaluates": 1234, "waitUntil": 3456}'), - ).toStrictEqual({ - waitUntil: 'load', - evaluates: [], - timeout: undefined, - }) }) - it('throws SyntaxError for syntactically invalid JSON in a non-empty header', async () => { + it('throws for syntactically invalid JSON in a non-empty header', async () => { // Callers (e.g. the HTTP server) must catch and return 400 Bad Request expect(() => parseRenderingProxyHeader('{')).toThrow(SyntaxError) expect(() => parseRenderingProxyHeader('[unclosed')).toThrow(SyntaxError) @@ -44,6 +30,20 @@ describe('parseRenderingProxyHeader', () => { ) }) + it('throws when the header is valid JSON but not a JSON object', async () => { + expect(() => parseRenderingProxyHeader('1234')).toThrow(TypeError) + expect(() => parseRenderingProxyHeader('["script"]')).toThrow(TypeError) + }) + + it('throws when field types do not match the protocol schema', async () => { + expect(() => + parseRenderingProxyHeader('{"evaluates": 1234, "waitUntil": 3456}'), + ).toThrow(TypeError) + expect(() => parseRenderingProxyHeader('{"timeout": "fast"}')).toThrow( + TypeError, + ) + }) + it('parses evaluates', async () => { expect(parseRenderingProxyHeader('{"evaluates": []}')).toStrictEqual({ waitUntil: 'load', @@ -109,11 +109,6 @@ describe('parseRenderingProxyHeader', () => { evaluates: [], timeout: undefined, }) - expect(parseRenderingProxyHeader('{"timeout": "fast"}')).toStrictEqual({ - waitUntil: 'load', - evaluates: [], - timeout: undefined, - }) }) it('truncates evaluates to at most 10 entries', async () => { diff --git a/src/server/request_options.ts b/src/server/request_options.ts index 30714c69..cca58aad 100644 --- a/src/server/request_options.ts +++ b/src/server/request_options.ts @@ -9,6 +9,12 @@ interface RequestOption { timeout: number | undefined } +type RequestOptionInput = { + waitUntil?: unknown + evaluates?: unknown + timeout?: unknown +} + export function parseRenderingProxyHeader( headerValue: undefined | string | string[], ): RequestOption { @@ -26,6 +32,10 @@ function tryParseOptions(text: string): unknown { return JSON.parse(text) } +function isRequestOptionInput(value: unknown): value is RequestOptionInput { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function normalizeWaitUntil(value: unknown): LifecycleEvent { if (lifeCycleEvents.includes(value as LifecycleEvent)) { return value as LifecycleEvent @@ -52,8 +62,43 @@ function normalizeTimeout(value: unknown): number | undefined { return typeof value === 'number' && value > 0 ? value : undefined } +function validateWaitUntil(value: RequestOptionInput): void { + if ('waitUntil' in value && typeof value.waitUntil !== 'string') { + throw new TypeError('X-Rendering-Proxy.waitUntil must be a string') + } +} + +function validateEvaluates(value: RequestOptionInput): void { + if ( + 'evaluates' in value && + (!Array.isArray(value.evaluates) || + !value.evaluates.every((item) => typeof item === 'string')) + ) { + throw new TypeError('X-Rendering-Proxy.evaluates must be a string array') + } +} + +function validateTimeout(value: RequestOptionInput): void { + if ('timeout' in value && typeof value.timeout !== 'number') { + throw new TypeError('X-Rendering-Proxy.timeout must be a number') + } +} + +function validateOptionsShape(value: unknown): RequestOptionInput | null { + if (value === null) { + return null + } + if (!isRequestOptionInput(value)) { + throw new TypeError('X-Rendering-Proxy must be a JSON object') + } + validateWaitUntil(value) + validateEvaluates(value) + validateTimeout(value) + return value +} + function parseOptions(text: string): RequestOption { - const baseParsed = tryParseOptions(text) as Partial | null + const baseParsed = validateOptionsShape(tryParseOptions(text)) return { waitUntil: normalizeWaitUntil(baseParsed?.waitUntil),