Skip to content
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"rawbody",
"restream",
"streamify",
"superstring",
"vhosted",
"websockets",
"xfwd"
Expand Down
37 changes: 28 additions & 9 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)];
}
13 changes: 13 additions & 0 deletions test/e2e/router.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* spellchecker: ignore evilbeta, evillocalhost */
import { createProxyMiddleware, createApp, createAppWithPath } from './test-kit';
import { ErrorRequestHandler } from 'express';
import * as request from 'supertest';
Expand Down Expand Up @@ -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');
});
});
});
27 changes: 27 additions & 0 deletions test/unit/router.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// spell-checker: ignore evilalpha, evilgamma
import { getTarget } from '../../src/router';

describe('router unit test', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
Loading