@specprotected/spec-proxy-aws-edge-lambda
Version:
Spec Proxy integration with AWS Edge@Lambda
271 lines (251 loc) • 10.5 kB
text/typescript
/**
* 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;
}