@specprotected/spec-proxy-akamai-worker
Version:
Akamai EdgeWorker library to facilitate communication with the Spec Proxy product.
288 lines (287 loc) • 12.5 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
/// <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 } from "http-request";
import { logger } from "log";
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";
/**
* 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 function specProxyProcess(request, config) {
return __awaiter(this, void 0, void 0, function* () {
let specRequest = yield specProxyProcessRequest(request, config);
return yield 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 function specProxyProcessRequest(ogRequest, config = {}) {
return __awaiter(this, void 0, void 0, function* () {
let request = Object.assign({ 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}`;
let specUrl = `https://another-akamai-worker.spec-worker.in${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 = 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 = Object.assign(Object.assign({}, request), {
// note: not the relative url that Akamai provides originally
// as we're sending it to our proxy
url: specUrl, body });
logger.log("spec url", specRequest.url);
// 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
yield 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) {
return body !== undefined && body.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, response, config = {}) {
var _a;
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 === null || cookies === void 0 ? void 0 : 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 = (_a = headers[HEADER_SET_COOKIE]) !== null && _a !== void 0 ? _a : [];
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, config) {
var _a;
// 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 = ((_a = headers[HEADER_TRUE_CLIENT_IP]) !== null && _a !== void 0 ? _a : ["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) {
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) {
let s = "";
while (n > 0) {
let gen = Math.random()
.toString(36)
.slice(2, 2 + n);
s += gen;
n -= gen.length;
}
return s;
}