Skip to content

Commit 0934d02

Browse files
authored
feat(ipv6): support literal IPv6 addresses in 'target' and 'forward' options (#1206)
1 parent 0665b8c commit 0934d02

5 files changed

Lines changed: 361 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- fix: prevent TypeError when ws enabled but server is undefined (#1163)
1717
- fix: applyPathRewrite logs old req.url instead of rewritten path (#1157)
1818
- feat(hono): support for hono with createHonoProxyMiddleware
19+
- feat(ipv6): support literal IPv6 addresses in `target` and `forward` options (ie. "http://[::1]:8000")
1920

2021
## [v3.0.5](https://github.com/chimurai/http-proxy-middleware/releases/tag/v3.0.5)
2122

src/http-proxy-middleware.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createPathRewriter } from './path-rewriter.js';
1313
import { getTarget } from './router.js';
1414
import type { Filter, Logger, Options, RequestHandler } from './types.js';
1515
import { getFunctionName } from './utils/function.js';
16+
import { normalizeIPv6LiteralTargets } from './utils/ipv6.js';
1617

1718
export class HttpProxyMiddleware<
1819
TReq extends http.IncomingMessage = http.IncomingMessage,
@@ -188,6 +189,7 @@ export class HttpProxyMiddleware<
188189
// 1. option.router
189190
// 2. option.pathRewrite
190191
await this.applyRouter(req, newProxyOptions);
192+
normalizeIPv6LiteralTargets(newProxyOptions);
191193
await this.applyPathRewrite(req, this.pathRewriter);
192194

193195
return newProxyOptions;

src/utils/ipv6.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type * as http from 'node:http';
2+
3+
import { Debug } from '../debug.js';
4+
import type { Options } from '../types.js';
5+
6+
const debug = Debug.extend('ipv6');
7+
8+
/**
9+
* Normalize bracketed IPv6 URL targets into unbracketed host options.
10+
*
11+
* RFC 2732 defines the URL syntax for literal IPv6 addresses as bracketed
12+
* host references (for example `http://[::1]:8080/path` where host is
13+
* `[::1]`).
14+
*
15+
* `httpxy` resolves bracketed hostnames (for example `[::1]`) via DNS,
16+
* which can fail for IPv6 literals. This converts string/URL `target` and
17+
* `forward` values into object form with `hostname: ::1` (brackets removed)
18+
* so the address can be connected directly.
19+
*
20+
* Reference: RFC 2732, Section 2 (Literal IPv6 Address Format in URL's)
21+
* https://www.ietf.org/rfc/rfc2732.txt
22+
*
23+
* The provided options object is mutated in place.
24+
*/
25+
export function normalizeIPv6LiteralTargets<
26+
TReq extends http.IncomingMessage = http.IncomingMessage,
27+
TRes extends http.ServerResponse = http.ServerResponse,
28+
>(options: Options<TReq, TRes>): void {
29+
options.target = normalizeIPv6ProxyTarget(options.target, 'target');
30+
options.forward = normalizeIPv6ProxyTarget(options.forward, 'forward');
31+
}
32+
33+
function normalizeIPv6ProxyTarget(target: Options['target'], optionName: 'target' | 'forward') {
34+
const targetUrl = toTargetUrl(target);
35+
36+
if (targetUrl && isBracketedIPv6Hostname(targetUrl.hostname)) {
37+
debug('normalized IPv6 "%s" %s', optionName, target);
38+
39+
return {
40+
hostname: stripBrackets(targetUrl.hostname),
41+
pathname: targetUrl.pathname,
42+
port: targetUrl.port,
43+
protocol: targetUrl.protocol,
44+
search: targetUrl.search,
45+
};
46+
}
47+
48+
return target;
49+
}
50+
51+
function toTargetUrl(target: Options['target']): URL | undefined {
52+
if (typeof target === 'string') {
53+
return new URL(target);
54+
}
55+
56+
if (target instanceof URL) {
57+
return target;
58+
}
59+
60+
return undefined;
61+
}
62+
63+
function isBracketedIPv6Hostname(hostname: string): boolean {
64+
return hostname.startsWith('[') && hostname.endsWith(']');
65+
}
66+
67+
function stripBrackets(hostname: string): string {
68+
return hostname.replace(/^\[|\]$/g, '');
69+
}

test/e2e/ipv6.spec.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import type { Mockttp } from 'mockttp';
2+
import { getLocal } from 'mockttp';
3+
import request from 'supertest';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
6+
import { createApp, createProxyMiddleware } from './test-kit.js';
7+
8+
describe('ipv6 integration', () => {
9+
let targetServer: Mockttp;
10+
11+
beforeEach(async () => {
12+
targetServer = getLocal();
13+
await targetServer.start();
14+
});
15+
16+
afterEach(async () => {
17+
await targetServer.stop();
18+
});
19+
20+
it('should proxy to ipv6 target using bracket notation with port', async () => {
21+
await targetServer.forGet('/api').thenCallback((req) => ({
22+
statusCode: 200,
23+
body: req.path,
24+
}));
25+
26+
const proxy = createProxyMiddleware({
27+
changeOrigin: true,
28+
target: `http://[::1]:${targetServer.port}`,
29+
});
30+
31+
const app = createApp(proxy);
32+
const response = await request(app).get('/api').expect(200);
33+
34+
expect(response.text).toBe('/api');
35+
});
36+
37+
it('should proxy to unspecified ipv6 target using bracket notation with port', async () => {
38+
await targetServer.forGet('/api').thenCallback((req) => ({
39+
statusCode: 200,
40+
body: req.path,
41+
}));
42+
43+
const proxy = createProxyMiddleware({
44+
changeOrigin: true,
45+
target: `http://[::]:${targetServer.port}`,
46+
});
47+
48+
const app = createApp(proxy);
49+
const response = await request(app).get('/api').expect(200);
50+
51+
expect(response.text).toBe('/api');
52+
});
53+
54+
it('should proxy to ipv6 target and preserve query params', async () => {
55+
let receivedPath: string | undefined;
56+
57+
await targetServer.forGet('/api').thenCallback((req) => {
58+
receivedPath = req.path;
59+
return {
60+
statusCode: 200,
61+
body: req.url.includes('?') ? req.url.split('?')[1] : '',
62+
};
63+
});
64+
65+
const proxy = createProxyMiddleware({
66+
changeOrigin: true,
67+
target: `http://[::1]:${targetServer.port}`,
68+
});
69+
70+
const app = createApp(proxy);
71+
const response = await request(app).get('/api?foo=bar&baz=qux').expect(200);
72+
73+
expect(receivedPath).toBe('/api?foo=bar&baz=qux');
74+
expect(response.text).toBe('foo=bar&baz=qux');
75+
});
76+
77+
it('should proxy to ipv6 target and preserve search params with special characters', async () => {
78+
let receivedPath: string | undefined;
79+
80+
await targetServer.forGet('/api').thenCallback((req) => {
81+
receivedPath = req.path;
82+
return {
83+
statusCode: 200,
84+
body: req.url.includes('?') ? req.url.split('?')[1] : '',
85+
};
86+
});
87+
88+
const proxy = createProxyMiddleware({
89+
changeOrigin: true,
90+
target: `http://[::1]:${targetServer.port}`,
91+
});
92+
93+
const app = createApp(proxy);
94+
const response = await request(app).get('/api?q=hello%20world&page=1').expect(200);
95+
96+
expect(receivedPath).toBe('/api?q=hello%20world&page=1');
97+
expect(response.text).toBe('q=hello%20world&page=1');
98+
});
99+
100+
it('should forward requests to ipv6 target using forward option', async () => {
101+
let forwardedPath: string | undefined;
102+
103+
await targetServer.forPost('/api').thenCallback((req) => {
104+
forwardedPath = req.path;
105+
return { statusCode: 200 };
106+
});
107+
108+
const proxy = createProxyMiddleware({
109+
changeOrigin: true,
110+
target: `http://[::1]:${targetServer.port}`,
111+
forward: `http://[::1]:${targetServer.port}`,
112+
});
113+
114+
const app = createApp(proxy);
115+
await request(app).post('/api').expect(200);
116+
117+
expect(forwardedPath).toBe('/api');
118+
});
119+
120+
it('should proxy to ipv6 target resolved via router function (no static target)', async () => {
121+
await targetServer.forGet('/api').thenCallback((req) => ({
122+
statusCode: 200,
123+
body: req.path,
124+
}));
125+
126+
const proxy = createProxyMiddleware({
127+
changeOrigin: true,
128+
target: 'http://example.com', // dummy target, will be overridden by router
129+
router: () => `http://[::1]:${targetServer.port}`,
130+
});
131+
132+
const app = createApp(proxy);
133+
const response = await request(app).get('/api').expect(200);
134+
135+
expect(response.text).toBe('/api');
136+
});
137+
138+
it('should proxy to ipv6 target resolved via async router function', async () => {
139+
await targetServer.forGet('/api').thenCallback((req) => ({
140+
statusCode: 200,
141+
body: req.path,
142+
}));
143+
144+
const proxy = createProxyMiddleware({
145+
changeOrigin: true,
146+
target: 'http://example.com', // dummy target, will be overridden by router
147+
router: async () => `http://[::1]:${targetServer.port}`,
148+
});
149+
150+
const app = createApp(proxy);
151+
const response = await request(app).get('/api').expect(200);
152+
153+
expect(response.text).toBe('/api');
154+
});
155+
156+
it('should proxy to ipv6 target with base path and auth option', async () => {
157+
let receivedPath: string | undefined;
158+
let authorizationHeader: string | undefined;
159+
160+
await targetServer.forGet('/api').thenCallback((req) => {
161+
receivedPath = req.path;
162+
const authHeader = req.headers.authorization;
163+
authorizationHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader;
164+
165+
return {
166+
statusCode: 200,
167+
};
168+
});
169+
170+
const proxy = createProxyMiddleware({
171+
changeOrigin: true,
172+
target: `http://[::1]:${targetServer.port}/api`,
173+
auth: 'user:pass',
174+
});
175+
176+
const app = createApp(proxy);
177+
await request(app).get('/').expect(200);
178+
179+
expect(receivedPath).toBe('/api');
180+
expect(authorizationHeader).toBe('Basic dXNlcjpwYXNz'); // cspell:disable-line
181+
});
182+
});

test/unit/utils/ipv6.spec.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { Options } from '../../../src/types.js';
4+
import { normalizeIPv6LiteralTargets } from '../../../src/utils/ipv6.js';
5+
6+
describe('normalizeIPv6Targets()', () => {
7+
it('should mutate the same options object', () => {
8+
const options: Options = {
9+
target: 'http://[::1]:8888/api?foo=bar',
10+
};
11+
12+
const originalOptions = options;
13+
normalizeIPv6LiteralTargets(options);
14+
15+
expect(options).toBe(originalOptions);
16+
});
17+
18+
it('should normalize bracketed IPv6 target string without port into a target object', () => {
19+
const options: Options = {
20+
target: 'http://[::1]/api',
21+
};
22+
23+
normalizeIPv6LiteralTargets(options);
24+
25+
expect(options.target).toEqual({
26+
hostname: '::1',
27+
pathname: '/api',
28+
port: '',
29+
protocol: 'http:',
30+
search: '',
31+
});
32+
});
33+
34+
it('should normalize bracketed IPv6 target string into a target object', () => {
35+
const options: Options = {
36+
target: 'http://[::1]:8888/api?foo=bar',
37+
};
38+
39+
normalizeIPv6LiteralTargets(options);
40+
41+
expect(options.target).toEqual({
42+
hostname: '::1',
43+
pathname: '/api',
44+
port: '8888',
45+
protocol: 'http:',
46+
search: '?foo=bar',
47+
});
48+
});
49+
50+
it('should normalize bracketed IPv6 target URL into a target object', () => {
51+
const options: Options = {
52+
target: new URL('http://[::1]:8888/api'),
53+
};
54+
55+
normalizeIPv6LiteralTargets(options);
56+
57+
expect(options.target).toEqual({
58+
hostname: '::1',
59+
pathname: '/api',
60+
port: '8888',
61+
protocol: 'http:',
62+
search: '',
63+
});
64+
});
65+
66+
it('should normalize bracketed IPv6 forward string into a forward object', () => {
67+
const options: Options = {
68+
forward: 'http://[::1]:9999/',
69+
};
70+
71+
normalizeIPv6LiteralTargets(options);
72+
73+
expect(options.forward).toEqual({
74+
hostname: '::1',
75+
pathname: '/',
76+
port: '9999',
77+
protocol: 'http:',
78+
search: '',
79+
});
80+
});
81+
82+
it('should leave non-IPv6 string targets unchanged', () => {
83+
const options: Options = {
84+
target: 'http://127.0.0.1:8888/api',
85+
};
86+
87+
normalizeIPv6LiteralTargets(options);
88+
89+
expect(options.target).toBe('http://127.0.0.1:8888/api');
90+
});
91+
92+
it('should leave object targets unchanged', () => {
93+
const target: Options['target'] = {
94+
hostname: '::1',
95+
port: 8888,
96+
protocol: 'http:',
97+
};
98+
99+
const options: Options = {
100+
target,
101+
};
102+
103+
normalizeIPv6LiteralTargets(options);
104+
105+
expect(options.target).toBe(target);
106+
});
107+
});

0 commit comments

Comments
 (0)