@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
text/typescript
/*
* 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;
};