From 69bd80b0e93e6f0d4858df78c96d0e2517fa1f50 Mon Sep 17 00:00:00 2001 From: Callum Loh Date: Wed, 12 Feb 2025 22:43:22 +0000 Subject: [PATCH] feat: allow setting a custom Surrogate-Control header Fixes #282 --- README.md | 22 ++++++++++++++++------ src/index.ts | 31 +++++++++++++++++++++---------- src/processIncludes.ts | 4 ++-- src/surrogate.ts | 6 +++--- test/esi.spec.ts | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 91be129..019df47 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ Library supports all instructions that the Ledge parser supports. Also supports - [contentTypes](#contenttypes) - [disableThirdPartyIncludes](#disablethirdpartyincludes) - [recursionLimit](#recursionlimit) - - [thirdPatyIncludesDomainWhitelist](#thirdpatyincludesdomainwhitelist) + - [surrogateControlHeader](#surrogatecontrolheader) + - [thirdPartyIncludesDomainWhitelist](#thirdpartyincludesdomainwhitelist) - [varsCookieBlacklist](#varscookieblacklist) - [Custom ESI Vars Function](#custom-esi-vars-function) - [Custom Fetch Function](#custom-fetch-function) @@ -73,7 +74,8 @@ export type ESIConfig = { contentTypes?: string[]; disableThirdPartyIncludes?: boolean; recursionLimit?: number; - thirdPatyIncludesDomainWhitelist?: string[]; + surrogateControlHeader?: string; + thirdPartyIncludesDomainWhitelist?: string[]; varsCookieBlacklist?: string[]; }; @@ -83,7 +85,8 @@ const defaultConfig = { contentTypes: ["text/html", "text/plain"], disableThirdPartyIncludes: false, recursionLimit: 10, - thirdPatyIncludesDomainWhitelist: [], + surrogateControlHeader: 'Surrogate-Control', + thirdPartyIncludesDomainWhitelist: [], varsCookieBlacklist: [] } ``` @@ -117,7 +120,7 @@ Whether or not to enable third party includes (includes from other domains). If set to false and an include points to another domain the include will be returned as a blank string -Also see thirdPatyIncludesDomainWhitelist for usage with this. +Also see thirdPartyIncludesDomainWhitelist for usage with this. @@ -126,10 +129,17 @@ Also see thirdPatyIncludesDomainWhitelist for usage with this. * *default*: `10` * *type*: `number` -Levels of recusion the parser is allowed to go do. Think includes that include themselves causing recusion +Levels of recursion the parser is allowed to go do. Think includes that include themselves causing recusion +#### surrogateControlHeader -#### thirdPatyIncludesDomainWhitelist +* *default*: `Surrogate Control` +* *type*: `string` + +Name of the header that the library will check for Surrogate-Control. We allow customisation as Cloudflare priorities Surrogate-Control over Cache-Control + + +#### thirdPartyIncludesDomainWhitelist * *default*: `[]` * *type*: `string[]` diff --git a/src/index.ts b/src/index.ts index eef21fc..c193b7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,8 @@ import { getheaderToken } from "./headerUtils"; * @property {number} [recursionLimit] - Levels of recursion the parser is allowed to include before bailing out * think includes that include themselves causing recursion * @default 10 + * @property {string} surrogateControlHeader - Name of the header that the library will check for Surrogate-Control + * We allow customisation as Cloudflare priorities Surrogate-Control over Cache-Control * @property {string[]} thirdPartyIncludesDomainWhitelist - If third party includes are disabled you can white list them by including domains here * @property {string[]} varsCookieBlacklist - Array of strings of cookies that will be blacklisted from being expanded in esi VARs. */ @@ -32,8 +34,9 @@ export type ESIConfig = { contentTypes?: string[]; disableThirdPartyIncludes?: boolean; recursionLimit?: number; - thirdPartyIncludesDomainWhitelist?: string[]; - varsCookieBlacklist?: string[]; + surrogateControlHeader?: string; + thirdPartyIncludesDomainWhitelist?: string[] | null; + varsCookieBlacklist?: string[] | null; }; export type ESIVars = { @@ -60,7 +63,7 @@ export type ESIEventData = { /** * {ESIConfig} for the current Request */ - config: ESIConfig; + config: FullESIConfig; /** * All headers of the current Request in {Object} * All headers are in uppercase and - is converted to _ @@ -107,12 +110,24 @@ export type fetchFunction = ( ctx: Request[], ) => Promise; export type postBodyFunction = () => void | Promise; +export type FullESIConfig = Required; const processorToken = "ESI"; const processorVersion = 1.0; +const defaultSurrogateControlHeader = "Surrogate-Control"; + +const defaultConfig: FullESIConfig = { + allowSurrogateDelegation: false, + contentTypes: ["text/html", "text/plain"], + disableThirdPartyIncludes: false, + recursionLimit: 10, + surrogateControlHeader: defaultSurrogateControlHeader, + thirdPartyIncludesDomainWhitelist: null, + varsCookieBlacklist: null, +}; export class esi { - options: ESIConfig; + options: FullESIConfig; esiFunction?: customESIVarsFunction; fetcher: fetchFunction; postBodyFunction?: postBodyFunction; @@ -125,10 +140,6 @@ export class esi { ctx: Request[] = [], postBodyFunction?: postBodyFunction, ) { - const defaultConfig = { - recursionLimit: 10, - contentTypes: ["text/html", "text/plain"], - }; this.options = { ...defaultConfig, ...options }; this.fetcher = fetcher; this.esiFunction = customESIFunction; @@ -221,7 +232,7 @@ export class esi { mutResponse.headers.delete("content-length"); // Remove surrogate-control - mutResponse.headers.delete("Surrogate-Control"); + mutResponse.headers.delete(this.options.surrogateControlHeader); // `streamBody` will free the request context when finished this.streamBody(eventData, response.body, writable); @@ -362,7 +373,7 @@ export class esi { } validSurrogateControl(response: Response): boolean { - const sControl = response.headers.get("Surrogate-Control"); + const sControl = response.headers.get(this.options.surrogateControlHeader); if (!sControl) { return false; } diff --git a/src/processIncludes.ts b/src/processIncludes.ts index d9f5de1..3388df0 100644 --- a/src/processIncludes.ts +++ b/src/processIncludes.ts @@ -1,5 +1,5 @@ import { replace_vars } from "./processESIVars"; -import esi, { customESIVarsFunction, ESIConfig, fetchFunction } from "."; +import esi, { customESIVarsFunction, FullESIConfig, fetchFunction } from "."; import { ESIEventData } from "."; const esi_include_pattern = //; @@ -169,7 +169,7 @@ function isIncludeOnSameDomain(requestURL: URL, srcURL: URL): boolean { * @param {string} host host to check against * @returns {boolean} true if domain is whitelisted. false if not */ -function thirdPartyWhitelisted(config: ESIConfig, host: string): boolean { +function thirdPartyWhitelisted(config: FullESIConfig, host: string): boolean { if (config.disableThirdPartyIncludes) { if (!config.thirdPartyIncludesDomainWhitelist) { return false; diff --git a/src/surrogate.ts b/src/surrogate.ts index 7500cc6..e8a463e 100644 --- a/src/surrogate.ts +++ b/src/surrogate.ts @@ -1,5 +1,5 @@ import { - ESIConfig, + FullESIConfig, getProcessorToken, getProcessorVersion, getProcessorVersionString, @@ -35,10 +35,10 @@ export function advertiseSurrogateControl(request: Request): Request { */ export function canDelegateToSurrogate( request: Request, - config: ESIConfig, + config: FullESIConfig, ): boolean { const surrogates = config.allowSurrogateDelegation; - if (surrogates === undefined || surrogates === false) return false; + if (surrogates === false) return false; const surrogateCapability = request.headers.get("Surrogate-Capability"); if (surrogateCapability) { diff --git a/test/esi.spec.ts b/test/esi.spec.ts index 09ae2a2..486ca77 100644 --- a/test/esi.spec.ts +++ b/test/esi.spec.ts @@ -1979,3 +1979,39 @@ test("TEST 52: Empty variables can be compared", async () => { expect(checkSurrogate(res)).toBeTruthy(); expect(await res.text()).toEqual(`x empty;y empty;`); }); + +test("TEST 53: When custom Surrogate-Control header is set ignore normal Surrogate-Control (dont remove default)", async () => { + const url = `/esi/test-53`; + config.surrogateControlHeader = "X-Custom-Surrogate-Control"; + parser = new esi(config); + routeHandler.add(url, function (req, res) { + res.writeHead(200, esiHead); + res.end(`START END`); + }); + const res = await makeRequest(url); + expect(res.ok).toBeTruthy(); + expect(checkSurrogate(res)).toBeFalsy(); + expect(await res.text()).toEqual( + `START END`, + ); +}); + +test("TEST 54: When custom Surrogate-Control header is set ESI Worker (remove default)", async () => { + const url = `/esi/test-54`; + config.surrogateControlHeader = "X-Custom-Surrogate-Control"; + parser = new esi(config); + routeHandler.add(url, function (req, res) { + res.writeHead(200, { + "Content-Type": "text/html", + "X-Custom-Surrogate-Control": `content="ESI/1.0"`, + }); + res.end(`START END`); + }); + routeHandler.add(`${url}/test`, function (req, res) { + res.end("Hello"); + }); + const res = await makeRequest(url); + expect(res.ok).toBeTruthy(); + expect(checkSurrogate(res)).toBeTruthy(); + expect(await res.text()).toEqual(`START Hello END`); +});