From 1a63dc875c94f8e4e4a3c30f1d031b8c2fc37b6f Mon Sep 17 00:00:00 2001 From: Gareth Jones <3151613+G-Rath@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:51:18 +0000 Subject: [PATCH] fix: harden proxy-table matching to prevent routing bypass --- CHANGELOG.md | 4 ++++ cspell.json | 1 + src/router.ts | 37 ++++++++++++++++++++++++++++--------- test/e2e/router.spec.ts | 13 +++++++++++++ test/unit/router.spec.ts | 27 +++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4412d3e2..ff5ff021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## next + +- fix(router): harden proxy-table matching (exact host for host+path keys, prefix-only path matching) to prevent routing bypass + ## [v2.0.9](https://github.com/chimurai/http-proxy-middleware/releases/tag/v2.0.9) - fix(fixRequestBody): check readableLength diff --git a/cspell.json b/cspell.json index a962e9c2..fc52f077 100644 --- a/cspell.json +++ b/cspell.json @@ -33,6 +33,7 @@ "rawbody", "restream", "streamify", + "superstring", "vhosted", "websockets", "xfwd" diff --git a/src/router.ts b/src/router.ts index d2e63505..7b4d0881 100644 --- a/src/router.ts +++ b/src/router.ts @@ -17,18 +17,28 @@ export async function getTarget(req, config) { function getTargetFromProxyTable(req, table) { let result; - const host = req.headers.host; - const path = req.url; - - const hostAndPath = host + path; + const host = req.headers.host || ''; + const path = req.url || ''; for (const [key] of Object.entries(table)) { if (containsPath(key)) { - if (hostAndPath.indexOf(key) > -1) { - // match 'localhost:3000/api' - result = table[key]; - logger.debug('[HPM] Router table match: "%s"', key); - break; + if (isHostAndPathKey(key)) { + const [keyHost, keyPath] = splitHostAndPathKey(key); + + // SECURITY: host+path keys must match exact host + path prefix. + if (host === keyHost && path.startsWith(keyPath)) { + // match 'localhost:3000/api' + result = table[key]; + logger.debug('[HPM] Router table match: "%s"', key); + break; + } + } else { + if (path.startsWith(key)) { + // match '/api' + result = table[key]; + logger.debug('[HPM] Router table match: "%s"', key); + break; + } } } else { if (key === host) { @@ -46,3 +56,12 @@ function getTargetFromProxyTable(req, table) { function containsPath(v) { return v.indexOf('/') > -1; } + +function isHostAndPathKey(v) { + return containsPath(v) && !v.startsWith('/'); +} + +function splitHostAndPathKey(v) { + const firstSlash = v.indexOf('/'); + return [v.slice(0, firstSlash), v.slice(firstSlash)]; +} diff --git a/test/e2e/router.spec.ts b/test/e2e/router.spec.ts index e38b480e..80ea3bb6 100644 --- a/test/e2e/router.spec.ts +++ b/test/e2e/router.spec.ts @@ -1,3 +1,4 @@ +/* spellchecker: ignore evilbeta, evillocalhost */ import { createProxyMiddleware, createApp, createAppWithPath } from './test-kit'; import { ErrorRequestHandler } from 'express'; import * as request from 'supertest'; @@ -190,10 +191,22 @@ describe('E2E router', () => { expect(response.text).toBe('B'); }); + it('should not proxy host-only target when host is a crafted superstring', async () => { + const response = await agent.get('/api').set('host', 'evilbeta.localhost:6000').expect(200); + + expect(response.text).toBe('A'); + }); + it('should proxy with host & path config: "localhost:6000/api"', async () => { const response = await agent.get('/api').set('host', 'localhost:6000').expect(200); expect(response.text).toBe('C'); }); + + it('should not proxy to host+path target when host is a crafted superstring', async () => { + const response = await agent.get('/api').set('host', 'evillocalhost:6000').expect(200); + + expect(response.text).toBe('A'); + }); }); }); diff --git a/test/unit/router.spec.ts b/test/unit/router.spec.ts index 1b1f0fc6..8de767bc 100644 --- a/test/unit/router.spec.ts +++ b/test/unit/router.spec.ts @@ -1,3 +1,4 @@ +// spell-checker: ignore evilalpha, evilgamma import { getTarget } from '../../src/router'; describe('router unit test', () => { @@ -106,6 +107,12 @@ describe('router unit test', () => { result = getTarget(fakeReq, proxyOptionWithRouter); return expect(result).resolves.toBe('http://localhost:6002'); }); + + it('should not match host-only config when host contains key as substring', () => { + fakeReq.headers.host = 'evilalpha.localhost'; + result = getTarget(fakeReq, proxyOptionWithRouter); + return expect(result).resolves.toBeUndefined(); + }); }); describe('with host and host + path config', () => { @@ -128,6 +135,20 @@ describe('router unit test', () => { result = getTarget(fakeReq, proxyOptionWithRouter); return expect(result).resolves.toBe('http://localhost:6003'); }); + + it('should not match host+path config when host is a superstring', () => { + fakeReq.headers.host = 'evilgamma.localhost'; + fakeReq.url = '/api'; + result = getTarget(fakeReq, proxyOptionWithRouter); + return expect(result).resolves.toBeUndefined(); + }); + + it('should not match host+path config when host only contains host as a substring', () => { + fakeReq.headers.host = 'gamma.localhost.evil'; + fakeReq.url = '/api/books/123'; + result = getTarget(fakeReq, proxyOptionWithRouter); + return expect(result).resolves.toBeUndefined(); + }); }); describe('with just the path', () => { @@ -148,6 +169,12 @@ describe('router unit test', () => { result = getTarget(fakeReq, proxyOptionWithRouter); return expect(result).resolves.toBeUndefined(); }); + + it('should not match path config when key appears as non-prefix substring', () => { + fakeReq.url = '/prefix/rest'; + result = getTarget(fakeReq, proxyOptionWithRouter); + return expect(result).resolves.toBeUndefined(); + }); }); describe('matching order of router config', () => {