Skip to content

Commit fd2840e

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

5 files changed

Lines changed: 344 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: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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 ipv6 target and preserve query params', async () => {
38+
let receivedPath: string | undefined;
39+
40+
await targetServer.forGet('/api').thenCallback((req) => {
41+
receivedPath = req.path;
42+
return {
43+
statusCode: 200,
44+
body: req.url.includes('?') ? req.url.split('?')[1] : '',
45+
};
46+
});
47+
48+
const proxy = createProxyMiddleware({
49+
changeOrigin: true,
50+
target: `http://[::1]:${targetServer.port}`,
51+
});
52+
53+
const app = createApp(proxy);
54+
const response = await request(app).get('/api?foo=bar&baz=qux').expect(200);
55+
56+
expect(receivedPath).toBe('/api?foo=bar&baz=qux');
57+
expect(response.text).toBe('foo=bar&baz=qux');
58+
});
59+
60+
it('should proxy to ipv6 target and preserve search params with special characters', async () => {
61+
let receivedPath: string | undefined;
62+
63+
await targetServer.forGet('/api').thenCallback((req) => {
64+
receivedPath = req.path;
65+
return {
66+
statusCode: 200,
67+
body: req.url.includes('?') ? req.url.split('?')[1] : '',
68+
};
69+
});
70+
71+
const proxy = createProxyMiddleware({
72+
changeOrigin: true,
73+
target: `http://[::1]:${targetServer.port}`,
74+
});
75+
76+
const app = createApp(proxy);
77+
const response = await request(app).get('/api?q=hello%20world&page=1').expect(200);
78+
79+
expect(receivedPath).toBe('/api?q=hello%20world&page=1');
80+
expect(response.text).toBe('q=hello%20world&page=1');
81+
});
82+
83+
it('should forward requests to ipv6 target using forward option', async () => {
84+
let forwardedPath: string | undefined;
85+
86+
await targetServer.forPost('/api').thenCallback((req) => {
87+
forwardedPath = req.path;
88+
return { statusCode: 200 };
89+
});
90+
91+
const proxy = createProxyMiddleware({
92+
changeOrigin: true,
93+
target: `http://[::1]:${targetServer.port}`,
94+
forward: `http://[::1]:${targetServer.port}`,
95+
});
96+
97+
const app = createApp(proxy);
98+
await request(app).post('/api').expect(200);
99+
100+
expect(forwardedPath).toBe('/api');
101+
});
102+
103+
it('should proxy to ipv6 target resolved via router function (no static target)', async () => {
104+
await targetServer.forGet('/api').thenCallback((req) => ({
105+
statusCode: 200,
106+
body: req.path,
107+
}));
108+
109+
const proxy = createProxyMiddleware({
110+
changeOrigin: true,
111+
target: 'http://example.com', // dummy target, will be overridden by router
112+
router: () => `http://[::1]:${targetServer.port}`,
113+
});
114+
115+
const app = createApp(proxy);
116+
const response = await request(app).get('/api').expect(200);
117+
118+
expect(response.text).toBe('/api');
119+
});
120+
121+
it('should proxy to ipv6 target resolved via async router function', async () => {
122+
await targetServer.forGet('/api').thenCallback((req) => ({
123+
statusCode: 200,
124+
body: req.path,
125+
}));
126+
127+
const proxy = createProxyMiddleware({
128+
changeOrigin: true,
129+
target: 'http://example.com', // dummy target, will be overridden by router
130+
router: async () => `http://[::1]:${targetServer.port}`,
131+
});
132+
133+
const app = createApp(proxy);
134+
const response = await request(app).get('/api').expect(200);
135+
136+
expect(response.text).toBe('/api');
137+
});
138+
139+
it('should proxy to ipv6 target with base path and auth option', async () => {
140+
let receivedPath: string | undefined;
141+
let authorizationHeader: string | undefined;
142+
143+
await targetServer.forGet('/api').thenCallback((req) => {
144+
receivedPath = req.path;
145+
const authHeader = req.headers.authorization;
146+
authorizationHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader;
147+
148+
return {
149+
statusCode: 200,
150+
};
151+
});
152+
153+
const proxy = createProxyMiddleware({
154+
changeOrigin: true,
155+
target: `http://[::1]:${targetServer.port}/api`,
156+
auth: 'user:pass',
157+
});
158+
159+
const app = createApp(proxy);
160+
await request(app).get('/').expect(200);
161+
162+
expect(receivedPath).toBe('/api');
163+
expect(authorizationHeader).toBe('Basic dXNlcjpwYXNz'); // cspell:disable-line
164+
});
165+
});

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)