Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

reverse-proxy: add support for washing secrets in response bodies #286

Merged
merged 1 commit into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions reverse-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 20 additions & 19 deletions reverse-proxy/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

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.
Expand Down Expand Up @@ -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();
Expand All @@ -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);
38 changes: 38 additions & 0 deletions reverse-proxy/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export type Mapping = Record<string, string>;

/**
* 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
);