UNPKG

@specprotected/spec-proxy-service-worker

Version:

Server Worker API implementation for integrating with Spec Proxy from an Edge Worker

848 lines (763 loc) 27.3 kB
/* * This is the common Service Worker API library to support the Spec Proxy product. * If you are a user, you likely are looking for a platform-specific library like * spec-proxy-cloudflare-worker or spec-proxy-fastly-worker. */ import { parse as parseCookies, serialize as serializeSetCookie } from "cookie"; // path prefix to always route traffic to Spec Proxy const SPEC_PATH_PREFIX = "/spec_traffic"; // header that controls request forwarding for Spec Proxy const SPEC_HEADER_FORWARD_ORIGIN = "x-spec-forward-origin"; // cookie key for the Spec ID const SPEC_COOKIE_ID = "x-spec-id"; // cookie for the mirror mode key const SPEC_HEADER_CUSTOMER_KEY = "x-spec-customer-authorization"; // the Set-Cookie header const HEADER_SET_COOKIE = "set-cookie"; // the Cookie header const HEADER_COOKIE = "cookie"; // standard X-Forwarded-For header const HEADER_HOST = "host"; // standard X-Forwarded-For header const HEADER_X_FORWARDED_FOR = "x-forwarded-for"; // header that indicates successful processing by the Spec platform const HEADER_SPEC_ACTIVITY = "x-atvak-activity-count"; const SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH = "x-spec-req-prop-content-length"; const SPEC_MIRROR_HEADER_REQ_METHOD = "x-spec-req-prop-method"; const SPEC_MIRROR_HEADER_REQ_URI = "x-spec-req-prop-uri"; const SPEC_MIRROR_HEADER_RESP_STATUS = "x-spec-resp-prop-status"; const SPEC_MIRROR_HEADER_RESP_PREFIX = "x-spec-resp-head"; // Track the type of request that we're processing, allows libraries // to handle requests differently in different contexts. // Such as fastly cache overrides. export enum RequestType { Inline, Origin, Mirrored, } // Note: type RequestInfo = string | Request; export type PlatformRequest = new ( url: RequestInfo, info?: RequestInit, original?: Request, config?: SpecConfiguration, requestType?: RequestType, ) => Request; // Spec Proxy configuration object export interface SpecConfiguration { /** * When true, disable Spec Proxy, this library and all functionality is disabled */ disableSpecProxy?: boolean; /** * When true, the request returned by this function is modified * to make a request to Spec Proxy, which will result in Spec Proxy making * the request to the customer origin itself */ inlineMode?: boolean; /** * A number between 0 and 100 that identifies the percentage of IP traffic * the Spec platform should process. */ percentageOfIPs?: number; /** * An key provided by spec, which validates traffic as originating from * the customer when in mirror mode. */ customerKey?: string; /** * Disables routing traffic prefixed with /spec_traffic to Spec */ disableSpecTraffic?: boolean; /** * A prefix to add to the `.spec-internal.com` suffix this library adds to * the incoming request's hostname. * ex: domainOverride: "prefix" -> "prefix.spec-internal.com" */ domainOverride?: string; /** * When not in inline mode, HTTP exchanges (a request/response pair) * are is mirrored to Spec. By defualt, to save network bandwidth, * the exchange data is sent in a single, unified request. However, * certain use-cases may require request and response data to be * sent individually, at the time the request or response is * received. */ splitExchangeMirroring?: boolean; /** * Enable debug logging. */ debug?: boolean; localDev?: boolean; } /** * Common interface for extra-request context needed for request/response * processing. This interface is satisfied by the `event` object in * service worker modules, or can be constructed from the request and * the Context in ES Modules workers. * * Individual libraries (e.g. the Cloudflare worker library) provide * helper methods for construction. If constructing manually, be sure * to bind `.waitUntil` to the appropriate `this`, for example: * * ```ts * export function makeContext( * request: Request, * ctx: ExecutionContext, * ): spec.ExecutionContext { * return { * request, * waitUntil: ctx.waitUntil.bind(ctx), * } * } * ``` */ export interface ExecutionContext { request: Request; waitUntil(f: Promise<any>): void; } /** * This is one of two lower-level entrypoints into the Spec Proxy framework. * This function takes the Service Worker `event` object that's provided as an * argument to the 'fetch' event and a configuration object that describes how * the library should behave. * * Clients of this library should use the returned request as if it were the request * originally provided by your Edge Worker. * * The returned request should always be processed with `processSpecProxyResponse` * before being returned. * * Use this lower-level API if you need to do additional work between the * request and response, such as integrate additional libraries in your worker * function. * * ```javascript * import { * specProxyProcessRequest, * specProxyProcessResponse, * } from '@specprotected/spec-proxy-service-worker'; * * addEventListener('fetch', event => { * // if we don't catch the exception, fail open to original traffic path * event.passThroughOnException(); * * event.respondWith(handleEvent(event)); * }) * * const specConfig = { inlineMode: false }; * * async function handleEvent(event) { * let request = specProxyProcessRequest(event, specConfig); * * let response = await fetch(request.request); * * // do more work, if required * * // call the Spec Proxy response method to enact potential response changes * return specProxyProcessResponse(event, request, response, specConfig); * } * ``` * * @param event - an `ExecutionContext` * @param config - configuration object to adjust Spec Proxy behavior * @returns the modified Request object */ export function specProxyProcessRequest( event: ExecutionContext, config: SpecConfiguration = {}, requestConstructor: PlatformRequest = Request, ): RequestData { const originalUrl = new URL(event.request.url); // first check to see if we shouldn't process anything at all if ( config.disableSpecProxy || !shouldHandleRequest( new URL(event.request.url), event.request.headers, config, ) ) { return { specUrl: originalUrl, originalUrl, request: event.request, requestBodyForResponseProcessing: null, }; } if (config.debug) { console.log("specProxyProcessRequest: enabled"); } const request = event.request; const specUrl = makeSpecUrl(originalUrl, config, MessageType.Request); if (config.debug) { console.log(`specProxyProcessRequest: specUrl: ${specUrl}`); } const specHeaders = new Headers(request.headers); setCommonSpecHeaders(specHeaders, config); // use this flag to signal that Spec Proxy is responsible for forwarding the // request on to the customer's servers. if ( config.inlineMode || // or if we're sending some traffic to /spec_traffic (!config.disableSpecTraffic && isSpecTraffic(originalUrl)) ) { if (config.debug) { console.log(`specProxyProcessRequest: inline`); } setForwardingSpecHeaders(specHeaders, originalUrl, config); // Note: /spec_traffic ignores forwarding directives // specHeaders.set(SPEC_HEADER_FORWARD_ORIGIN, originalHost); return { specUrl, originalUrl, requestBodyForResponseProcessing: null, request: new requestConstructor( specUrl.toString(), { body: request.body, headers: specHeaders, method: request.method, redirect: request.redirect, }, request, config, RequestType.Inline, ), }; } else { // Otherwise, we'll mirror the request. if (config.debug) { console.log(`specProxyProcessRequest: mirror`); } // If we are mirroring AND sending split exchange data, go ahead and // send the mirror request. let requestBody = request.body; if (config.splitExchangeMirroring) { if (config.debug) { console.log(`specProxyProcessRequest: split mirroring`); } const [specRequest, teedBody] = makeMirrorRequestSplit( specUrl, specHeaders, request, requestConstructor, config, ); if (teedBody) { requestBody = teedBody; } if (config.debug) { console.log( `specProxyProcessRequest: sending split mirror request: ${specRequest.url}`, ); } // background the request to spec proxy, but ask service worker to wait // until it's finished even though we don't await it event.waitUntil(fetch(specRequest)); } // If the requestBody is either: // - the original request's body stream (if the request has a body, // we are mirroring, and exchangeMirroring is 'split') // - the teed body stream (if the request has a body, we are // mirroring, and exchangeMirroring is 'unified') // // Tee it, using one for the requestBody and another for response // processing. // // For split processing, this will be a tee of a tee, meaning we // have three copies of the request stream: // - Original request (`requestBody`) // - Mirrored request (sent off with `specRequest`) // - Response processing (`requestBodyForResponseProcessing`) // // For unified processing, there will just be two copies: // - Original request (`requestBody`) // - Response processing (`requestBodyForResponseProcessing`) let requestBodyForResponseProcessing = null; if (requestBody) { [requestBody, requestBodyForResponseProcessing] = requestBody.tee(); } // create the new request with the adjusted host if redirectHeader is set // and use the new teed ReadableStream body return { specUrl, originalUrl, requestBodyForResponseProcessing, request: new requestConstructor( request.url, { body: requestBody, headers: request.headers, method: request.method, redirect: request.redirect, }, request, config, RequestType.Origin, ), }; } } /** * Process the Response as it returns to the originator of the Request. This * function generally adds any details the SpecTrust platform requires to identify * site visitors, such as the Spec Cookie. * * Note: this function does not process the Body of a Response, so won't require * awaiting while reading the body stream, which enables efficient processing. * * @param event - worker ExecutionContext * @param request - the request object that was sent to customer servers * @param response - the response object that was returned from customer servers * @param config - the configuration object that defines how this library should behave * @returns - the modified response object */ export function specProxyProcessResponse( event: ExecutionContext, req: RequestData, response: Response, config: SpecConfiguration = {}, requestConstructor: PlatformRequest = Request, ): Response { const request = req.request; const url = new URL(request.url); if ( config.disableSpecProxy || // in inline mode, we expect the addition of cookies to be done at the proxy layer config.inlineMode === true || isSpecTraffic(url) || !shouldHandleRequest(url, request.headers, config) ) { return response; } if (config.debug) { console.log(`specProxyProcessResponse: enabled`); } let cookies: Record<string, string | undefined> = {}; if (request.headers) { cookies = parseCookies(request.headers.get(HEADER_COOKIE) || ""); } // Note: falsy check because we set our cookie on undefined or "" values if (!cookies[SPEC_COOKIE_ID]) { // the || will turn the (empty string | null) into undefined let domain = extractTopLevelDomain(request.headers) || undefined; // domain can't have a port in it, so if we're running locally, // where having a port in your host is useful, split it off. if (config.localDev && domain?.startsWith("localhost:")) { domain = domain.split(":")[0]; } let specId = crypto.randomUUID(); let setCookie = serializeSetCookie(SPEC_COOKIE_ID, specId, { // Note: 10 years long, essentially a "very long time" maxAge: 320000000, domain, path: "/", sameSite: "none", secure: true, }); // reconstruct Response to avoid immutability response = new Response(response.body, response); response.headers.append(HEADER_SET_COOKIE, setCookie); } const [mirrorRequest, responseBody] = makeMirrorExchangeRequest( req, response, config, requestConstructor, ); if (config.debug) { console.log( `specProxyProcessResponse: mirror exchange request: ` + `${mirrorRequest.url}: ${mirrorRequest.headers.get(SPEC_MIRROR_HEADER_REQ_URI)}` + `: ${mirrorRequest.headers.get("host")}`, ); } // background the request to spec proxy, but ask service worker to wait // until it's finished even though we don't await it event.waitUntil( fetch(mirrorRequest).then((resp) => { if (config.debug) { console.log( `specProxyProcessResponse: response from mirror server: ${resp.status}: ${resp.statusText}`, ); } }), ); response = new Response(responseBody, response); return response; } /** * This is the simplest method in the Spec Proxy Service Worker library. * If the edge worker you're writing only requires integration Spec Proxy and * no other additional libraries, this is the easiest way to use Spec Proxy. * * It only requires one method, so we'll show an example: * * ```javascript * import { specProxyProcess } from '@spectrust/server-worker-proxy'; * * addEventListener('fetch', event => { * // if we don't catch the exception, fail open to original traffic path * event.passThroughOnException(); * * event.respondWith(specProxyProcess(event, { * // set to false to mirror traffic to Spec Proxy instead of redirecting it * inlineMode: true * })); * }) * ``` * * @param event - the worker's ExecutionContext * @param config - configuration object to control Spec Proxy behavior * @returns - a Promise to resolve to the modified response from the customer server */ export async function specProxyProcess( event: ExecutionContext, config: SpecConfiguration, requestConstructor: PlatformRequest, ): Promise<Response> { let request = specProxyProcessRequest(event, config, requestConstructor); if (config.debug) { console.log( `specProxyProcess: sending request to downstream: ${request.request.url}`, ); } let response = await specMakeRequestWithFallback( event, request.request, config, requestConstructor, ); return specProxyProcessResponse(event, request, response, config); } /** * Makes a request with intelligent fallback behavior based on the configuration mode. * This function is responsible for handling the actual network request to either the * Spec Proxy service or directly to the customer origin, depending on the response * from the initial request and the configured mode. * * In inline mode, this function first attempts to make a request through the Spec Proxy * service. If the response contains the "x-activity-count" header, it indicates * successful processing by the Spec platform, and the response is returned after * removing the internal header. If this header is not present, it falls back to * making a direct request to the customer origin using the original request from * the FetchEvent. * * In mirror mode, there is no fallback mechanism since the request body has been teed * (duplicated) and the original request cannot be safely reused. The function simply * makes the request as provided. * * @param event - The FetchEvent originating from the Fetch API, used for fallback in inline mode * @param request - The potentially modified Request object to be sent to Spec Proxy * @param config - Configuration object that controls Spec Proxy behavior * @returns - A Promise that resolves to the Response from either Spec Proxy or the customer origin */ export async function specMakeRequestWithFallback( event: ExecutionContext, request: Request, config: SpecConfiguration, requestConstructor: PlatformRequest, ): Promise<Response> { if (config.inlineMode) { let [initialBody, fallbackBody]: [ ReadableStream | null, ReadableStream | null, ] = [null, null]; if (event.request.body) { [initialBody, fallbackBody] = event.request.body.tee(); } let response = await fetch( new requestConstructor(request, { body: initialBody, }), ); // This is the correct case, we received a header that the Spec platform sets. if (response.headers.get(HEADER_SPEC_ACTIVITY)) { fallbackBody?.cancel(); let newResponse = new Response(response.body, response); newResponse.headers.delete(HEADER_SPEC_ACTIVITY); return newResponse; } if (config.debug) { console.log("specMakeReqeustWithFallback: making fallback request"); } // This is the fallback case, we did not receive a header that the Spec platform sets. // Note that this is safe because in inline mode, we don't tee the body, so we can // use the request on the event directly. return await fetch( new requestConstructor(event.request, { body: fallbackBody }), ); } else { // In mirror mode, the request "is the original" except for the teed body, // so there's no way to fallback. return await fetch(request); } } /** * Function that determines if Spec Proxy should handle the incoming request. * This involves observing the configuration object and resolving whether or not * we should process this Request under the given configuration values. * * @param headers - Header map from the originating request * @param config - configuration object to control Spec Proxy behavior * @returns - true if we should process the request */ function shouldHandleRequest( url: URL, headers: Headers, config: SpecConfiguration, ): boolean { // If we're directing /spec_traffic to Spec, we should always do that! if (!config.disableSpecTraffic && url.pathname.startsWith(SPEC_PATH_PREFIX)) { return true; } // if we're not filtering out a percentage of IPs, or the filter is 100% // we should always handle traffic. if (config.percentageOfIPs === undefined || config.percentageOfIPs >= 100) { return true; } // early abort if it's impossible to match else if (config.percentageOfIPs <= 0) { return false; } // split up the ip address into octets, convert them to integers, and then sum them. // default the string to 99 so if, for some reason, there's a problem the traffic // doesn't go through unless it's at 100%. Note: 99 because there's 100 numbers in // [0, 99]! let ip_octet_sum = (headers.get(HEADER_X_FORWARDED_FOR) || "99") .split(".") .map((octet) => parseInt(octet)) .reduce((acc, n) => acc + n); // if we don't know what number this is...don't assume anything if (isNaN(ip_octet_sum)) { ip_octet_sum = 99; } // not `<=` because it's a percentage, e.g. "allow 1%" would allow // IP octect sums that result in `0`, which is 1 slice in the range [0, 99] return ip_octet_sum % 100 < config.percentageOfIPs; } /** * Extract the top-level (apex) domain from the Host header. * This will exclude the `.spec-internal.com` domain if it is present. * In the event that we do not match on the Host header for any reason, * the value of the header itself is returned. * * @param headers - Header map from the originating request * @returns - top-level domain if the Host header was present, otherwise null */ function extractTopLevelDomain(headers: Headers): string | null { let host = headers.get(HEADER_HOST); if (host) { // regex attempts to match as much as it can, lazily, then a sequence of non-"." characters, // a ".", then more non-"." to comprise the apex domain. if .spec-internal.com is present, // the final group will attempt to match it, removing it from the apex domain. const domain_extract = /^(.*?\.)?(?<domain>[^.]+\.(com|co|org|edu|net|int|gov|mil|uk|co\.uk|ac\.uk|gov\.uk|ltd\.uk|me\.uk|net\.uk|nhs\.uk|org\.uk|plc\.uk|police\.uk))(\.spec-internal\.com)?$/; let matches = domain_extract.exec(host); if (matches?.groups?.domain) { host = matches.groups.domain; } } return host; } enum MessageType { Request, Response, } function makeSpecUrl( originalUrl: URL, config: SpecConfiguration, messageType: MessageType, ): URL { // Note: this was just `.host` before, but for local development sometimes // ports will be included, using hostname avoids setting overwriting port values const originalHost = originalUrl.hostname; const specUrl = new URL(originalUrl); specUrl.protocol = config.localDev ? "http" : "https"; // write to spec proxy specUrl.hostname = config.localDev ? "localhost" : specUrl.hostname.endsWith(".spec-internal.com") ? specUrl.hostname : config.domainOverride ? `${config.domainOverride}.spec-internal.com` : `${originalHost}.spec-internal.com`; if (config.localDev) { specUrl.port = "5000"; } setSpecUrlPath(specUrl, config, messageType); return specUrl; } function setSpecUrlPath( specUrl: URL, config: SpecConfiguration, messageType: MessageType, ): void { // For inline mode or for spec_traffic endpoints, we forward // directly to the proxy. if (config.inlineMode || isSpecTraffic(specUrl)) { return; } // For mirror mode, we send traffic to a dedicated mirror endpoint. if (!config.splitExchangeMirroring) { specUrl.pathname = "/speclayer/api/mirror/exchanges"; } else if (messageType === MessageType.Request) { specUrl.pathname = "/speclayer/api/mirror/requests"; } else if (messageType === MessageType.Response) { specUrl.pathname = "/speclayer/api/mirror/responses"; } // We retain the path and query string in headers, and do not need // to send them to the Spec endpoint. specUrl.search = ""; } function isSpecTraffic(url: URL): boolean { return url.pathname.startsWith(SPEC_PATH_PREFIX); } function setCommonSpecHeaders( specHeaders: Headers, config: SpecConfiguration, ): void { // mark a timestamp on the header so we can tell when the request // was sent from the background in the CDN worker specHeaders.set("x-spec-origination-ts", new Date().toISOString()); if (config.customerKey) { specHeaders.set(SPEC_HEADER_CUSTOMER_KEY, config.customerKey); } } function setForwardingSpecHeaders( specHeaders: Headers, originalUrl: URL, config: SpecConfiguration, ): void { if (config.localDev && originalUrl.hostname === "localhost") { specHeaders.set(SPEC_HEADER_FORWARD_ORIGIN, `http://${originalUrl.host}`); } else { specHeaders.set(SPEC_HEADER_FORWARD_ORIGIN, originalUrl.hostname); } } function setMirrorRequestHeaders( specHeaders: Headers, originalRequest: Request, ): void { const contentLength = originalRequest.headers.get("content-length"); if (contentLength) { specHeaders.set(SPEC_MIRROR_HEADER_REQ_CONTENT_LENGTH, contentLength); } specHeaders.set(SPEC_MIRROR_HEADER_REQ_METHOD, originalRequest.method); specHeaders.set(SPEC_MIRROR_HEADER_REQ_URI, originalRequest.url); } function setMirrorResponseHeaders( specHeaders: Headers, originalResponse: Response, ): void { specHeaders.set( SPEC_MIRROR_HEADER_RESP_STATUS, originalResponse.status.toString(), ); // Remove any existing headers that start with our special prefix, // then convert response headers to new headers on the outgoing mirror // request. for (const key of specHeaders.keys()) { if (key.startsWith(SPEC_MIRROR_HEADER_RESP_PREFIX)) { specHeaders.delete(key); } } originalResponse.headers.forEach((val, name, _headers) => { specHeaders.append(`${SPEC_MIRROR_HEADER_RESP_PREFIX}-${name}`, val); }); } function makeMirrorRequestSplit( specUrl: URL, specHeaders: Headers, originalRequest: Request, requestConstructor: PlatformRequest, config: SpecConfiguration, ): [Request, ReadableStream | null] { setMirrorRequestHeaders(specHeaders, originalRequest); // We will register this request promise to be awaited in the // background by the Service Worker API and in order to do that // without reading the body, we need to duplicate the body stream. // // clone the ReadableStream, if it exists. let teedBody: [ReadableStream | null, ReadableStream | null] = [null, null]; if (originalRequest.body !== null && originalRequest.body !== undefined) { teedBody = originalRequest.body.tee(); } // build a request with the new url and the new ReadableStream const specRequest = new requestConstructor( specUrl.toString(), { body: teedBody[0], headers: specHeaders, method: "POST", redirect: originalRequest.redirect, }, originalRequest, config, RequestType.Mirrored, ); return [specRequest, teedBody[1]]; } function makeMirrorExchangeRequest( req: RequestData, originalResponse: Response, config: SpecConfiguration, requestConstructor: PlatformRequest, ): [Request, ReadableStream | null] { const originalRequest = req.request; const specHeaders = new Headers(originalRequest.headers); setCommonSpecHeaders(specHeaders, config); setMirrorRequestHeaders(specHeaders, originalRequest); setMirrorResponseHeaders(specHeaders, originalResponse); const [originalResponseBody, requestBody] = makeMirrorExchangeBodyUnified( req, originalResponse, ); const specUrl = makeSpecUrl(req.originalUrl, config, MessageType.Response); return [ new requestConstructor( specUrl.toString(), { body: requestBody, headers: specHeaders, method: "POST", }, originalRequest, config, RequestType.Mirrored, ), originalResponseBody, ]; } const DIVIDER = new Blob([ "-------SPEC-DIVIDER------- Wir betreten feuertrunken Himmlische, dein Heiligtum -------SPEC-DIVIDER-------\n", ]); function makeMirrorExchangeBodyUnified( req: RequestData, originalResponse: Response, ): [ReadableStream | null, ReadableStream] { const [originalResponseBody, teedResponseBody] = originalResponse.body ? originalResponse.body.tee() : [originalResponse.body, null]; const components: Array<ReadableStream> = [ req.requestBodyForResponseProcessing || new Blob([]).stream(), DIVIDER.stream(), teedResponseBody || new Blob([]).stream(), ]; const { readable: mirrorExchangeStream, writable } = new TransformStream(); components.reduce( (acc, i, idx, arr) => acc.then(() => i.pipeTo(writable, { preventClose: idx + 1 !== arr.length }), ), Promise.resolve(), ); return [originalResponseBody, mirrorExchangeStream]; } export type RequestData = { specUrl: URL; originalUrl: URL; request: Request; requestBodyForResponseProcessing: ReadableStream | null; };