UNPKG

@specprotected/spec-proxy-aws-edge-lambda

Version:

Spec Proxy integration with AWS Edge@Lambda

271 lines (251 loc) 10.5 kB
/** * This is the AWS Edge@Lambda library to support Spec Proxy integrations. * This module was intended to be used to send traffic from a CloudFront * distribution to an instance of Spec Proxy. */ import { CloudFrontRequestEvent, CloudFrontRequestResult, CloudFrontResponseEvent, CloudFrontResponseResult, CloudFrontHeaders } from "aws-lambda"; import { parse as parseCookies, serialize as serializeSetCookie } from "cookie"; import fetch, { Headers, Request } from "node-fetch"; import { v4 as uuid } from "uuid"; import { URL } from "url"; import { Readable } from "stream"; // Here we have some constants used for configuring proxy // 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"; // spec-internal plug const SPEC_INTERNAL = "spec-internal.com"; // This configuration interface is used to configure the proxy worker // and should be imported into the edge@lambda code 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; /** * An key provided by spec, which validates traffic as originating from * the customer when in mirror mode. */ customerKey?: string; } /** * This function should be used on "origin-request" lambdas to either process * traffic inline or send a copy to proxy while continuing to origin. It works by * hijacking the request origin and sending traffic to proxy. * * @param event - the CloudFront event object created on request * @param config - the configuration object that defines how this library should behave * @returns - the modified CloudFront request object */ export async function specProxyProcessRequest( event: CloudFrontRequestEvent, config: SpecConfiguration = {}, ): Promise<CloudFrontRequestResult> { const request = event.Records[0].cf.request; // this is the host as it comes in from CF const host = request.headers[HEADER_HOST][0].value; const headers = request.headers; const uri = request.uri; const query = request.querystring ? `?${request.querystring}` : ""; // build the client url from request const url = new URL(`https://${host}${uri}${query}`); // instantiate some headers for spec processing // grab the body and create a new buffer from it in base64 const bodyData = request.body?.data ? Buffer.from(request.body.data, 'base64').toString('utf-8') : undefined; // Check to see if we want to process request if (config.disableSpecProxy) { return request; } // save it in case we're in Inline mode let originalHost = url.hostname; // for worker configurations, spec configures a domain w/ "spec-internal.com" // appended to the protected URL. let specUrl = new URL(`https://${host}.${SPEC_INTERNAL}`); // check if we should or are able to handle this http request if (!shouldHandleRequest(headers, config)) { return request; } // For all requests destined for spec proxy, add the customer key to the headers if provided. if (config.customerKey) { request.headers[`${SPEC_HEADER_CUSTOMER_KEY}`] = [{ key: `${SPEC_HEADER_CUSTOMER_KEY}`, value: config.customerKey }] } // Handle inline/mirror traffic split based on config if (config.inlineMode) { // we are inline, so we want to override the origin. request.origin = { custom: { domainName: specUrl.hostname, protocol: "https", port: 443, path: "", sslProtocols: ['TLSv1.2'], readTimeout: 5, keepaliveTimeout: 5, customHeaders: {} } } // modify the existing host header and add the forward origin header for inline mode request.headers['host'] = [{ key: 'host', value: specUrl.hostname }]; request.headers[`${SPEC_HEADER_FORWARD_ORIGIN}`] = [{ key: `${SPEC_HEADER_FORWARD_ORIGIN}`, value: originalHost }] return request; } else { // we are in mirror mode, so we want to ship a copy to proxy // and then return request // // Pack it up for Proxy const bodyBuffer = bodyData ? Buffer.from(bodyData) : null; // since we are mirroring we actually do have to convert headers from CloudFront style // to the standard style that fetch expects. const specHeaders: Headers = new Headers; for (const key in headers) { specHeaders.append(key, headers[key][0].value) } const specRequest = new Request( specUrl.toString(), { body: bodyBuffer ? Readable.from(bodyBuffer) : null, headers: specHeaders, method: request.method, } ) // send it to proxy await fetch(specRequest); // continue to origin return request; } } /** * Intended to be used as an "origin-response" edge lambda. The primary role of this function * is to add the spec cookie to the response request. Required for inline processing. * * 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 - the CloudFront 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: CloudFrontResponseEvent, config: SpecConfiguration = {} ): CloudFrontResponseResult { const response = event.Records[0].cf.response; const headers = response.headers; const cookieHeader = headers.hasOwnProperty(HEADER_COOKIE) ? headers[HEADER_COOKIE][0].value : ""; if ( config.disableSpecProxy || config.inlineMode === true || !shouldHandleRequest(headers, config) ) { return response; } let cookies: { [key: string]: string } = {}; if (cookieHeader.length) { cookies = parseCookies(cookieHeader); } // Note: falsy check because we set our cookie on undefined or "" values if (!cookies[SPEC_COOKIE_ID]) { let specId = uuid(); let setCookie = serializeSetCookie(SPEC_COOKIE_ID, specId, { // Note: 10 years long, essentially a "very long time" maxAge: 320000000, // the || will turn the (empty string | null) into undefined domain: extractTopLevelDomain(headers) || undefined, // valid for all paths path: "/", }); response.headers[`${HEADER_SET_COOKIE}`] = [{ key: `${HEADER_SET_COOKIE}`, value: setCookie }] } return response; } // HELPER FUNCTIONS /** * 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: CloudFrontHeaders, 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]! const headerVal: string = headers.hasOwnProperty(HEADER_X_FORWARDED_FOR) ? headers[HEADER_X_FORWARDED_FOR][0].value : "99"; let ip_octet_sum = (headerVal) .split(".") .map((octet) => parseInt(octet)) .reduce((acc, n) => acc + n, 0); // 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: CloudFrontHeaders): string | null { let host = headers.hasOwnProperty(HEADER_HOST) ? headers[HEADER_HOST][0].value : ""; if (host.length) { // 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; }