UNPKG

@specprotected/spec-proxy-akamai-worker

Version:

Akamai EdgeWorker library to facilitate communication with the Spec Proxy product.

350 lines (322 loc) 12.5 kB
/// <reference types="akamai-edgeworkers"/> /* * 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 { httpRequest, HttpResponse, RequestBody } from "http-request"; import * as Cookies from "cookies"; // 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"; // 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_TRUE_CLIENT_IP = "true-client-ip"; /* * The `AkamaiRequest` type is a wrapper type that allows * the Spec Proxy library to manipulate the incoming request. * Callers of functions in this library will receive an AkamaiRequest * as a return parameter which they should use as an argument to * Akamai's `httpRequest` method. */ export type AkamaiRequest = { host: string; url: string; // Note: relative url to the host! method: string; headers: EW.Headers; body: RequestBody | undefined; [others: string]: any; }; // 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 SpecTrust platform should process. */ percentageOfIPs?: number; } /** * This function is responsible for processing the `ResponseProviderRequest` * that the Akamai API provides clients during the * [Response Provider Event](https://techdocs.akamai.com/edgeworkers/docs/response-orchestration). * * Clients of this library need only create a `SpecConfiguration` object and pass * the arguments of the `responseProvider` function into this method along with * your configuration object. * * ```javascript * import { * specProxyProcess, * } from '@specprotected/spec-proxy-akamai-worker'; * * const specConfig = { inlineMode: false }; * * export async function responseProvider(request: EW.ResponseProviderRequest) { * return await specProxyProcess(request, specConfig); * } * ``` * * @param request - the incoming ResponseProviderRequest object * @param config - configuration object to adjust Spec Proxy behavior * @returns - response created from calling createResponse, akamai doesn't provide a type for this value */ export async function specProxyProcess( request: EW.ResponseProviderRequest, config: SpecConfiguration ): Promise<HttpResponse> { let specRequest = await specProxyProcessRequest(request, config); return await httpRequest(specRequest.url, specRequest); } /** * This function is responsible for processing the `ResponseProviderRequest` * that the Akamai API provides clients during the * [Response Provider Event](https://techdocs.akamai.com/edgeworkers/docs/response-orchestration). * * Clients of this library need only create a `SpecConfiguration` object and pass * the arguments of the `responseProvider` function into this method along with * your configuration object. * * Due to the nature of how some of our features work, you should use our * returned `AkamaiRequest` type instead of the `ResponseProviderRequest` * you pass in as an argument. For bodies that are streamed, we attach a * consumer to the stream to split it so we don't incur any performance * penalties when redirecting requests. This is a custom type from this library, * but should be able to pass any Akamai properties through to `httpRequest` * calls. * * * ```javascript * import { * specProxyProcessRequest, * } from '@specprotected/spec-proxy-akamai-worker'; * * const specConfig = { inlineMode: false }; * * export async function responseProvider(request: EW.ResponseProviderRequest) { * let request: AkamaiRequest = await spec.specProxyProcessRequest(request, specConfig); * let response = await httpRequest(request); * return createResponse(response.body, response); * } * ``` * * @param ogRequest - the incoming ResponseProviderRequest object * @param config - configuration object to adjust Spec Proxy behavior * @returns - the modified Request object, in this library's AkamaiRequest type */ export async function specProxyProcessRequest( ogRequest: EW.ResponseProviderRequest, config: SpecConfiguration = {} ): Promise<AkamaiRequest> { let request: AkamaiRequest = { headers: ogRequest.getHeaders(), ...ogRequest, }; // first check to see if we shouldn't process anything at all if (config.disableSpecProxy) { return request; } // check if we should handle this http request if (!shouldHandleRequest(request.headers, config)) { return request; } // write to spec proxy using its hostname let specUrl = `https://${request.host}.spec-internal.com${request.url}`; // use this flag to signal that Spec Proxy is responsible for forwarding the // request on to the customer's servers. if (config.inlineMode) { request.headers[SPEC_HEADER_FORWARD_ORIGIN] = [request.host]; } else { // clone the ReadableStream, if it exists. let body: RequestBody | undefined = undefined; if (isStream(request.body)) { [request.body, body] = request.body.tee(); } else { body = request.body; } // build a request with the new url and the new ReadableStream const specRequest: AkamaiRequest = { ...request, // note: not the relative url that Akamai provides originally // as we're sending it to our proxy url: specUrl, body, }; // Akamai doesn't provide a way for us to specify that we would like to // await this future in the background...it will abort it if we don't await await httpRequest(specRequest.url, specRequest); } // and give a request, either original or modified, back to the caller // so they can send it on return request; } /** * Function to isolate the ReadableStream type in the possible request * arguments. This type of body requires special handling. * * @param body the RequestBody from Akamai * @returns boolean and cast to EW.ReadableStreamEW type */ function isStream(body: RequestBody | undefined): body is EW.ReadableStreamEW { return body !== undefined && (body as EW.ReadableStreamEW).tee !== undefined; } /** * 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. * * ```javascript * import { * specProxyProcessRequest, * specProxyProcessResponse, * } from '@specprotected/spec-proxy-akamai-worker'; * * const specConfig = { inlineMode: false }; * * export async function responseProvider(request: EW.ResponseProviderRequest) { * let request: AkamaiRequest = await spec.specProxyProcessRequest(request, specConfig); * let response = spec.specProxyProcessResponse(request, await httpRequest(request)); * return createResponse(response.body, response); * } * ``` * * @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( request: EW.ResponseProviderRequest, response: HttpResponse, config: SpecConfiguration = {} ): HttpResponse { if ( config.disableSpecProxy || config.inlineMode === true || !shouldHandleRequest(request.getHeaders(), config) ) { return response; } let cookies = new Cookies.Cookies(request.getHeader(HEADER_COOKIE) || ""); // Note: falsy check because we set our cookie on undefined or "" values if (!cookies?.get(SPEC_COOKIE_ID)) { let specId = randomString(64); let setCookie = new Cookies.SetCookie({ name: SPEC_COOKIE_ID, value: specId, // Note: 10 years long, essentially a "very long time" maxAge: 320000000, // the || will turn the (empty string | null) into undefined domain: extractTopLevelDomain(request.getHeaders()) || undefined, // valid for all paths path: "/", }); let headers = response.getHeaders(); let setCookies = headers[HEADER_SET_COOKIE] ?? []; setCookies.push(setCookie.toHeader()); headers[HEADER_SET_COOKIE] = setCookies; } return response; } /** * 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( headers: EW.Headers, config: SpecConfiguration ): boolean { // 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[HEADER_TRUE_CLIENT_IP] ?? ["99"])[0] .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: EW.Headers): string | null { if (HEADER_HOST in headers && headers[HEADER_HOST]) { // truthy check is non-empty array let host = headers[HEADER_HOST][0]; // 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 = /^(.*?\.)?([^.]+\.(com|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) { host = matches[2]; } return host; } return null; } /** * Akamai EdgeWorkers are on es6 meaning they don't have the nice * crypto.randomUUID(). Instead we'll generate a random string instead of * including the library `uuid` so we can keep file size down, since there * are limits on that for EdgeWorkers. * * @param n - length of string to generate * @returns - a string of length n */ function randomString(n: number): string { let s = ""; while (n > 0) { let gen = Math.random() .toString(36) .slice(2, 2 + n); s += gen; n -= gen.length; } return s; }