diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f181014..dd0b9f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - fix: prevent TypeError when ws enabled but server is undefined (#1163) - fix: applyPathRewrite logs old req.url instead of rewritten path (#1157) - feat(hono): support for hono with createHonoProxyMiddleware +- feat(ipv6): support literal IPv6 addresses in `target` and `forward` options (ie. "http://[::1]:8000") ## [v3.0.5](https://github.com/chimurai/http-proxy-middleware/releases/tag/v3.0.5) diff --git a/src/http-proxy-middleware.ts b/src/http-proxy-middleware.ts index 252bcd6a..48a01454 100644 --- a/src/http-proxy-middleware.ts +++ b/src/http-proxy-middleware.ts @@ -13,6 +13,7 @@ import { createPathRewriter } from './path-rewriter.js'; import { getTarget } from './router.js'; import type { Filter, Logger, Options, RequestHandler } from './types.js'; import { getFunctionName } from './utils/function.js'; +import { normalizeIPv6LiteralTargets } from './utils/ipv6.js'; export class HttpProxyMiddleware< TReq extends http.IncomingMessage = http.IncomingMessage, @@ -188,6 +189,7 @@ export class HttpProxyMiddleware< // 1. option.router // 2. option.pathRewrite await this.applyRouter(req, newProxyOptions); + normalizeIPv6LiteralTargets(newProxyOptions); await this.applyPathRewrite(req, this.pathRewriter); return newProxyOptions; diff --git a/src/utils/ipv6.ts b/src/utils/ipv6.ts new file mode 100644 index 00000000..4d492834 --- /dev/null +++ b/src/utils/ipv6.ts @@ -0,0 +1,69 @@ +import type * as http from 'node:http'; + +import { Debug } from '../debug.js'; +import type { Options } from '../types.js'; + +const debug = Debug.extend('ipv6'); + +/** + * Normalize bracketed IPv6 URL targets into unbracketed host options. + * + * RFC 2732 defines the URL syntax for literal IPv6 addresses as bracketed + * host references (for example `http://[::1]:8080/path` where host is + * `[::1]`). + * + * `httpxy` resolves bracketed hostnames (for example `[::1]`) via DNS, + * which can fail for IPv6 literals. This converts string/URL `target` and + * `forward` values into object form with `hostname: ::1` (brackets removed) + * so the address can be connected directly. + * + * Reference: RFC 2732, Section 2 (Literal IPv6 Address Format in URL's) + * https://www.ietf.org/rfc/rfc2732.txt + * + * The provided options object is mutated in place. + */ +export function normalizeIPv6LiteralTargets< + TReq extends http.IncomingMessage = http.IncomingMessage, + TRes extends http.ServerResponse = http.ServerResponse, +>(options: Options): void { + options.target = normalizeIPv6ProxyTarget(options.target, 'target'); + options.forward = normalizeIPv6ProxyTarget(options.forward, 'forward'); +} + +function normalizeIPv6ProxyTarget(target: Options['target'], optionName: 'target' | 'forward') { + const targetUrl = toTargetUrl(target); + + if (targetUrl && isBracketedIPv6Hostname(targetUrl.hostname)) { + debug('normalized IPv6 "%s" %s', optionName, target); + + return { + hostname: stripBrackets(targetUrl.hostname), + pathname: targetUrl.pathname, + port: targetUrl.port, + protocol: targetUrl.protocol, + search: targetUrl.search, + }; + } + + return target; +} + +function toTargetUrl(target: Options['target']): URL | undefined { + if (typeof target === 'string') { + return new URL(target); + } + + if (target instanceof URL) { + return target; + } + + return undefined; +} + +function isBracketedIPv6Hostname(hostname: string): boolean { + return hostname.startsWith('[') && hostname.endsWith(']'); +} + +function stripBrackets(hostname: string): string { + return hostname.replace(/^\[|\]$/g, ''); +} diff --git a/test/e2e/ipv6.spec.ts b/test/e2e/ipv6.spec.ts new file mode 100644 index 00000000..7754c621 --- /dev/null +++ b/test/e2e/ipv6.spec.ts @@ -0,0 +1,182 @@ +import type { Mockttp } from 'mockttp'; +import { getLocal } from 'mockttp'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createApp, createProxyMiddleware } from './test-kit.js'; + +describe('ipv6 integration', () => { + let targetServer: Mockttp; + + beforeEach(async () => { + targetServer = getLocal(); + await targetServer.start(); + }); + + afterEach(async () => { + await targetServer.stop(); + }); + + it('should proxy to ipv6 target using bracket notation with port', async () => { + await targetServer.forGet('/api').thenCallback((req) => ({ + statusCode: 200, + body: req.path, + })); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: `http://[::1]:${targetServer.port}`, + }); + + const app = createApp(proxy); + const response = await request(app).get('/api').expect(200); + + expect(response.text).toBe('/api'); + }); + + it('should proxy to unspecified ipv6 target using bracket notation with port', async () => { + await targetServer.forGet('/api').thenCallback((req) => ({ + statusCode: 200, + body: req.path, + })); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: `http://[::]:${targetServer.port}`, + }); + + const app = createApp(proxy); + const response = await request(app).get('/api').expect(200); + + expect(response.text).toBe('/api'); + }); + + it('should proxy to ipv6 target and preserve query params', async () => { + let receivedPath: string | undefined; + + await targetServer.forGet('/api').thenCallback((req) => { + receivedPath = req.path; + return { + statusCode: 200, + body: req.url.includes('?') ? req.url.split('?')[1] : '', + }; + }); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: `http://[::1]:${targetServer.port}`, + }); + + const app = createApp(proxy); + const response = await request(app).get('/api?foo=bar&baz=qux').expect(200); + + expect(receivedPath).toBe('/api?foo=bar&baz=qux'); + expect(response.text).toBe('foo=bar&baz=qux'); + }); + + it('should proxy to ipv6 target and preserve search params with special characters', async () => { + let receivedPath: string | undefined; + + await targetServer.forGet('/api').thenCallback((req) => { + receivedPath = req.path; + return { + statusCode: 200, + body: req.url.includes('?') ? req.url.split('?')[1] : '', + }; + }); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: `http://[::1]:${targetServer.port}`, + }); + + const app = createApp(proxy); + const response = await request(app).get('/api?q=hello%20world&page=1').expect(200); + + expect(receivedPath).toBe('/api?q=hello%20world&page=1'); + expect(response.text).toBe('q=hello%20world&page=1'); + }); + + it('should forward requests to ipv6 target using forward option', async () => { + let forwardedPath: string | undefined; + + await targetServer.forPost('/api').thenCallback((req) => { + forwardedPath = req.path; + return { statusCode: 200 }; + }); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: `http://[::1]:${targetServer.port}`, + forward: `http://[::1]:${targetServer.port}`, + }); + + const app = createApp(proxy); + await request(app).post('/api').expect(200); + + expect(forwardedPath).toBe('/api'); + }); + + it('should proxy to ipv6 target resolved via router function (no static target)', async () => { + await targetServer.forGet('/api').thenCallback((req) => ({ + statusCode: 200, + body: req.path, + })); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: 'http://example.com', // dummy target, will be overridden by router + router: () => `http://[::1]:${targetServer.port}`, + }); + + const app = createApp(proxy); + const response = await request(app).get('/api').expect(200); + + expect(response.text).toBe('/api'); + }); + + it('should proxy to ipv6 target resolved via async router function', async () => { + await targetServer.forGet('/api').thenCallback((req) => ({ + statusCode: 200, + body: req.path, + })); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: 'http://example.com', // dummy target, will be overridden by router + router: async () => `http://[::1]:${targetServer.port}`, + }); + + const app = createApp(proxy); + const response = await request(app).get('/api').expect(200); + + expect(response.text).toBe('/api'); + }); + + it('should proxy to ipv6 target with base path and auth option', async () => { + let receivedPath: string | undefined; + let authorizationHeader: string | undefined; + + await targetServer.forGet('/api').thenCallback((req) => { + receivedPath = req.path; + const authHeader = req.headers.authorization; + authorizationHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader; + + return { + statusCode: 200, + }; + }); + + const proxy = createProxyMiddleware({ + changeOrigin: true, + target: `http://[::1]:${targetServer.port}/api`, + auth: 'user:pass', + }); + + const app = createApp(proxy); + await request(app).get('/').expect(200); + + expect(receivedPath).toBe('/api'); + expect(authorizationHeader).toBe('Basic dXNlcjpwYXNz'); // cspell:disable-line + }); +}); diff --git a/test/unit/utils/ipv6.spec.ts b/test/unit/utils/ipv6.spec.ts new file mode 100644 index 00000000..f256eca3 --- /dev/null +++ b/test/unit/utils/ipv6.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import type { Options } from '../../../src/types.js'; +import { normalizeIPv6LiteralTargets } from '../../../src/utils/ipv6.js'; + +describe('normalizeIPv6Targets()', () => { + it('should mutate the same options object', () => { + const options: Options = { + target: 'http://[::1]:8888/api?foo=bar', + }; + + const originalOptions = options; + normalizeIPv6LiteralTargets(options); + + expect(options).toBe(originalOptions); + }); + + it('should normalize bracketed IPv6 target string without port into a target object', () => { + const options: Options = { + target: 'http://[::1]/api', + }; + + normalizeIPv6LiteralTargets(options); + + expect(options.target).toEqual({ + hostname: '::1', + pathname: '/api', + port: '', + protocol: 'http:', + search: '', + }); + }); + + it('should normalize bracketed IPv6 target string into a target object', () => { + const options: Options = { + target: 'http://[::1]:8888/api?foo=bar', + }; + + normalizeIPv6LiteralTargets(options); + + expect(options.target).toEqual({ + hostname: '::1', + pathname: '/api', + port: '8888', + protocol: 'http:', + search: '?foo=bar', + }); + }); + + it('should normalize bracketed IPv6 target URL into a target object', () => { + const options: Options = { + target: new URL('http://[::1]:8888/api'), + }; + + normalizeIPv6LiteralTargets(options); + + expect(options.target).toEqual({ + hostname: '::1', + pathname: '/api', + port: '8888', + protocol: 'http:', + search: '', + }); + }); + + it('should normalize bracketed IPv6 forward string into a forward object', () => { + const options: Options = { + forward: 'http://[::1]:9999/', + }; + + normalizeIPv6LiteralTargets(options); + + expect(options.forward).toEqual({ + hostname: '::1', + pathname: '/', + port: '9999', + protocol: 'http:', + search: '', + }); + }); + + it('should leave non-IPv6 string targets unchanged', () => { + const options: Options = { + target: 'http://127.0.0.1:8888/api', + }; + + normalizeIPv6LiteralTargets(options); + + expect(options.target).toBe('http://127.0.0.1:8888/api'); + }); + + it('should leave object targets unchanged', () => { + const target: Options['target'] = { + hostname: '::1', + port: 8888, + protocol: 'http:', + }; + + const options: Options = { + target, + }; + + normalizeIPv6LiteralTargets(options); + + expect(options.target).toBe(target); + }); +});