diff --git a/reverse-proxy/README.md b/reverse-proxy/README.md index d5a314a0..06055691 100644 --- a/reverse-proxy/README.md +++ b/reverse-proxy/README.md @@ -15,6 +15,12 @@ So, in general, the pattern is `PROXY_MAPPING_[redirect key]=[redirect target]`. | `/example` | https://example.com | | `/example/cats` | https://example.com/cats | + +## Washing responses +Some targets (looking at you, Alchemy) return the URL secrets in the response body. Since this is target-dependent, you need to add detection code in `getSensitiveStrings` if this applies to a new proxy target. + +The request interceptor automatically washes all response bodies that goes back to the client by replacing instances of all these sensitive strings with `[redacted]`. + ## Running Run in development mode: ```shell diff --git a/reverse-proxy/src/index.ts b/reverse-proxy/src/index.ts index 9e0fc4ac..7b20387f 100644 --- a/reverse-proxy/src/index.ts +++ b/reverse-proxy/src/index.ts @@ -1,24 +1,20 @@ import express from 'express'; import { ServerResponse } from 'http'; -import { createProxyMiddleware } from 'http-proxy-middleware'; +import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; +import { buildMappingFromEnv, getSensitiveStrings, redactSensitive } from './util.js'; const SENSIBLE_KEY_REGEX = '^\/[a-zA-Z0-9_-]+'; const rSensibleKey = RegExp(SENSIBLE_KEY_REGEX); -type Mapping = Record; - -const mapping: Mapping = Object.fromEntries( - Object.entries(process.env as { [s:string]: string } ) - .filter(([k, _]) => k.startsWith("PROXY_MAPPING_")) - .map(([k, v]) => [k.replace("PROXY_MAPPING_", ""), v]) - .map(([k, v]) => ["/" + k.toLowerCase(), v]) -); +const mapping = buildMappingFromEnv(); console.log( "Raw mapping configuration loaded:\n", JSON.stringify(mapping, undefined, 2) ); +const sensitiveStrings = getSensitiveStrings(mapping); + /** * Checks that a single mapping has a sensible key and that the * target is a valid URL. @@ -49,16 +45,21 @@ const createProxy = (target: string) => createProxyMiddleware({ pathRewrite: { [SENSIBLE_KEY_REGEX]: "" }, // Log each proxy call to console logger: console, + // Disable automatically sending response, handled by responseInterceptor + selfHandleResponse: true, on: { - error: (_err, _req, res) => { - if (res instanceof ServerResponse) { - res.writeHead(500, { - 'Content-Type': 'text/plain', - }); - res.end('Something went wrong while proxying the request'); - }; + error: (_err, _req, res) => (res as ServerResponse) + .writeHead(500, { 'Content-Type': 'text/plain' }) + .end('Something went wrong while proxying the request'), + proxyRes: responseInterceptor( + async (responseBuffer, proxyRes, _req, res) => { + res.statusCode = proxyRes.statusCode ?? 200; + return redactSensitive( + sensitiveStrings, + responseBuffer.toString("utf8") + ); + }), }, - } }); const app = express(); @@ -73,8 +74,8 @@ app.use("/healthcheck", (_req, res) => { app.use( (req, res) => { console.log(`Got request for unmapped path ${req.url}, responding 404.`); - return res.status(404).json(`No route found for ${req.url}`) - } + res.writeHead(404, `No route found for ${req.url}`).end(); + }, ); app.listen(5678); diff --git a/reverse-proxy/src/util.ts b/reverse-proxy/src/util.ts new file mode 100644 index 00000000..e9396276 --- /dev/null +++ b/reverse-proxy/src/util.ts @@ -0,0 +1,38 @@ +export type Mapping = Record; + +/** + * Build a route mapping from envvars prefixed `PROXY_MAPPING_` +*/ +export const buildMappingFromEnv = (): Mapping => Object.fromEntries( + Object.entries(process.env as { [s:string]: string } ) + .filter(([k, _]) => k.startsWith("PROXY_MAPPING_")) + .map(([k, v]) => [k.replace("PROXY_MAPPING_", ""), v]) + .map(([k, v]) => ["/" + k.toLowerCase(), v]) +); + +/** + * Find sensitive strings in mapped URL's +*/ +export const getSensitiveStrings = (mapping: Mapping) => { + const urls = Object.values(mapping); + + const alchemyTokens = urls + .filter(url => url.includes("alchemy.com")) + /* 32-char alphanum token with dashes and underscores */ + .map(url => url.match(/[a-zA-Z0-9_-]{32}/)) + /* Remove potential null match for tokenless url */ + .flatMap(maybeMatch => maybeMatch ? [maybeMatch] : []) + /* Get the match from RexExpMatchArray */ + .map(matchArr => matchArr[0]); + + return alchemyTokens; +}; + +export const redactSensitive = ( + sensitiveStrings: string[], + dirty: string +): string => + sensitiveStrings.reduce( + (acc, nextSecret) => acc.replaceAll(nextSecret, "[redacted]"), + dirty, // initial accumulator + );