diff --git a/README.md b/README.md index 76eadd88..69d3c170 100644 --- a/README.md +++ b/README.md @@ -941,15 +941,24 @@ These are the available config options for making requests. Only the `url` is re // Use `false` to disable proxies, ignoring environment variables. // `auth` indicates that HTTP Basic auth should be used to connect to the proxy, and // supplies credentials. - // This will set a `Proxy-Authorization` header, overwriting any existing - // `Proxy-Authorization` custom headers you have set using `headers`. + // For `http://` targets, axios sends the request to the proxy in + // forward-proxy mode and stamps `Proxy-Authorization` onto the request + // headers (overwriting any user-supplied `Proxy-Authorization` header). + // For `https://` targets, axios establishes a CONNECT tunnel through the + // proxy and performs TLS end-to-end with the origin; `Proxy-Authorization` + // is sent on the CONNECT request only, never on the wrapped TLS request, + // so the proxy never sees the URL, headers, or body. Supply a custom + // `httpsAgent` to opt out of automatic CONNECT tunneling. // If the proxy server uses HTTPS, then you must set the protocol to `https`. // A user-supplied `Host` header in `headers` is preserved when forwarding // through a proxy (case-insensitive match on `host`/`Host`/`HOST`); this // lets you target a virtual host that differs from the request URL — for // example, hitting `127.0.0.1:4000` while having the proxy treat the // request as `example.com`. If no `Host` header is supplied, axios - // defaults it to the request URL's `hostname:port` as before. + // defaults it to the request URL's `hostname:port` as before. The Host + // header is only set in forward-proxy mode (HTTP targets); for HTTPS + // tunneling the Host header is sent inside the TLS connection, not seen + // by the proxy. proxy: { protocol: 'https', host: '127.0.0.1', diff --git a/THREATMODEL.md b/THREATMODEL.md index 24a0121e..52d0d272 100644 --- a/THREATMODEL.md +++ b/THREATMODEL.md @@ -201,7 +201,7 @@ The runtime model is general by design - axios is a transport library and cannot | **Description** | Attacker controls the process environment (compromised CI step, container escape, `.env` injection) and sets `HTTPS_PROXY=http://evil.com:8080`. All axios traffic is now MITM'd. | | **Likelihood** | Low (requires prior foothold) | | **Impact** | High | -| **Mitigations** | • `config.proxy: false` disables environment-based proxy detection entirely.
• `NO_PROXY` is honored (`lib/helpers/shouldBypassProxy.js`), with recent hardening for CIDR ranges, IPv6 literals, and wildcard patterns to close parser-differential edge cases.
• HTTPS through an HTTP proxy still validates the origin's cert (CONNECT tunnel) - the proxy sees SNI but not plaintext. | +| **Mitigations** | • `config.proxy: false` disables environment-based proxy detection entirely.
• `NO_PROXY` is honored (`lib/helpers/shouldBypassProxy.js`), with recent hardening for CIDR ranges, IPv6 literals, and wildcard patterns to close parser-differential edge cases.
• HTTPS through any proxy uses CONNECT tunneling via `https-proxy-agent` so the origin's cert is validated end-to-end and the proxy sees only SNI, never the URL, headers, or body. `Proxy-Authorization` is sent on the CONNECT request only, never on the wrapped TLS-protected request. | | **Residual risk** | Low for HTTPS. **High for plain HTTP** - the proxy sees and can modify everything. | --- @@ -292,7 +292,7 @@ This is the model that protects **what gets published as `axios` on npm**. A suc | **Maintainer GitHub accounts** | Transitively grants the above. | | **Maintainer workstation secrets** | SSH keys (→ GitHub push), `~/.npmrc` token if present (→ direct publish), GPG keys (→ signed commits), cloud creds (→ lateral movement). | | **Build determinism** | If `dist/` doesn't match `lib/`, a backdoor can hide in the minified bundle. | -| **Runtime dependency integrity** | `follow-redirects`, `form-data`, `proxy-from-env` ship inside every axios install. | +| **Runtime dependency integrity** | `follow-redirects`, `form-data`, `proxy-from-env`, `https-proxy-agent` ship inside every axios install. | ### 3.3 Trust Boundaries @@ -389,8 +389,8 @@ This is the model that protects **what gets published as `axios` on npm**. A suc | | | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Description** | `follow-redirects`, `form-data`, or `proxy-from-env` ships a malicious version. Unlike T-S2, this code ends up **in the published axios bundle / runtime**, not just on maintainer machines. Every axios consumer runs it. | -| **Likelihood** | Low (only 3 deps; all are mature, narrowly-scoped, and watched) | +| **Description** | `follow-redirects`, `form-data`, `proxy-from-env`, or `https-proxy-agent` ships a malicious version. Unlike T-S2, this code ends up **in the published axios bundle / runtime**, not just on maintainer machines. Every axios consumer runs it. | +| **Likelihood** | Low (only 4 deps; all are mature, narrowly-scoped, and watched) | | **Impact** | Critical | | **Mitigations** | • Three runtime deps total - minimal by design.
• `^` ranges in `package.json` mean consumers may get newer patch versions than the lockfile pins - this is intentional (consumers get security fixes) but means a malicious patch release of `follow-redirects` propagates without an axios release.
• `follow-redirects` is security-conscious and well-maintained; we track its advisories closely (multiple past axios releases were just `follow-redirects` bumps).
• Dependabot is configured (`.github/dependabot.yml`) for both npm and GitHub Actions, running weekly with grouped updates for production and development dependencies. | | **Gaps** | • No vendoring/inlining considered. The deps are small enough that vendoring is plausible but would forfeit upstream security fixes. Current judgment: not worth it. | diff --git a/lib/adapters/http.js b/lib/adapters/http.js index d1d4c39e..3e0f4f3a 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -3,6 +3,7 @@ import settle from '../core/settle.js'; import buildFullPath from '../core/buildFullPath.js'; import buildURL from '../helpers/buildURL.js'; import { getProxyForUrl } from 'proxy-from-env'; +import HttpsProxyAgent from 'https-proxy-agent'; import http from 'http'; import https from 'https'; import http2 from 'http2'; @@ -68,6 +69,45 @@ function setFormDataHeaders(headers, formHeaders, policy) { const kAxiosSocketListener = Symbol('axios.http.socketListener'); const kAxiosCurrentReq = Symbol('axios.http.currentReq'); +// Tags HttpsProxyAgent instances installed by setProxy() so the redirect path +// can strip them without clobbering a user-supplied agent that happens to be +// an HttpsProxyAgent. +const kAxiosInstalledTunnel = Symbol('axios.http.installedTunnel'); + +// Cache of CONNECT-tunneling agents keyed by proxy config so repeat requests +// through the same proxy reuse a single agent (and its socket pool). The +// keyspace is bounded by the set of distinct proxy configs the process uses, +// so unbounded growth is not a concern in practice. +const tunnelingAgentCache = new Map(); +const tunnelingAgentCacheUser = new WeakMap(); + +function getTunnelingAgent(agentOptions, userHttpsAgent) { + const key = + agentOptions.protocol + + '//' + + agentOptions.hostname + + ':' + + (agentOptions.port || '') + + '#' + + (agentOptions.auth || ''); + const cache = userHttpsAgent + ? (tunnelingAgentCacheUser.get(userHttpsAgent) || + tunnelingAgentCacheUser.set(userHttpsAgent, new Map()).get(userHttpsAgent)) + : tunnelingAgentCache; + let agent = cache.get(key); + if (agent) return agent; + // Forward the user's TLS options (custom CA, rejectUnauthorized, client cert, + // etc.) into the tunneling agent so they apply to the origin TLS upgrade + // performed after CONNECT. Our proxy fields take precedence on conflict. + const merged = userHttpsAgent && userHttpsAgent.options + ? { ...userHttpsAgent.options, ...agentOptions } + : agentOptions; + agent = new HttpsProxyAgent(merged); + agent[kAxiosInstalledTunnel] = true; + cache.set(key, agent); + return agent; +} + const supportedProtocols = platform.protocols.map((protocol) => { return protocol + ':'; }); @@ -225,7 +265,7 @@ function dispatchBeforeRedirect(options, responseDetails, requestDetails) { * * @returns {http.ClientRequestArgs} */ -function setProxy(options, configProxy, location, isRedirect) { +function setProxy(options, configProxy, location, isRedirect, configHttpsAgent) { let proxy = configProxy; if (!proxy && proxy !== false) { const proxyUrl = getProxyForUrl(location); @@ -246,6 +286,13 @@ function setProxy(options, configProxy, location, isRedirect) { } } } + // Strip any tunneling agent we installed for the previous hop so a redirect + // that drops the proxy or crosses an HTTPS↔HTTP boundary doesn't reuse a + // stale one. Match on our Symbol marker so a user-supplied HttpsProxyAgent + // (which won't carry the marker) is left alone. + if (isRedirect && options.agent && options.agent[kAxiosInstalledTunnel]) { + options.agent = undefined; + } if (proxy) { // Read proxy fields without traversing the prototype chain. URL instances expose // username/password/hostname/host/port/protocol via getters on URL.prototype (so @@ -282,40 +329,96 @@ function setProxy(options, configProxy, location, isRedirect) { } else if (authIsObject) { throw new AxiosError('Invalid proxy authorization', AxiosError.ERR_BAD_OPTION, { proxy }); } - - const base64 = Buffer.from(proxyAuth, 'utf8').toString('base64'); - - options.headers['Proxy-Authorization'] = 'Basic ' + base64; } - // Preserve a user-supplied Host header (case-insensitive) so callers can override - // the value forwarded to the proxy; otherwise default to the request URL's host. - let hasUserHostHeader = false; - for (const name of Object.keys(options.headers)) { - if (name.toLowerCase() === 'host') { - hasUserHostHeader = true; - break; + const targetIsHttps = isHttps.test(options.protocol); + + if (targetIsHttps) { + // CONNECT-tunneling path for HTTPS targets. Preserves end-to-end TLS to + // the origin so the proxy cannot inspect the URL, headers, or body — the + // behavior already promised by THREATMODEL.md (T-R9). HttpsProxyAgent + // sends Proxy-Authorization on the CONNECT request only, never on the + // wrapped TLS request, which is why we don't stamp it onto + // options.headers here. If the user already supplied an HttpsProxyAgent, + // they own tunneling end-to-end and we leave them alone; otherwise we + // install our own tunneling agent and forward their TLS options (if any) + // so a custom httpsAgent for cert pinning / rejectUnauthorized still + // applies to the origin TLS upgrade. + if (!(configHttpsAgent instanceof HttpsProxyAgent)) { + const proxyHost = readProxyField('hostname') || readProxyField('host'); + const proxyPort = readProxyField('port'); + const rawProxyProtocol = readProxyField('protocol'); + const normalizedProtocol = rawProxyProtocol + ? rawProxyProtocol.includes(':') + ? rawProxyProtocol + : `${rawProxyProtocol}:` + : 'http:'; + // Bracket IPv6 literals for URL parsing; URL.hostname strips the + // brackets again on read so the agent receives the raw form. + const proxyHostForURL = + proxyHost && proxyHost.includes(':') && !proxyHost.startsWith('[') + ? `[${proxyHost}]` + : proxyHost; + const proxyURL = new URL( + `${normalizedProtocol}//${proxyHostForURL}${proxyPort ? ':' + proxyPort : ''}` + ); + const agentOptions = { + protocol: proxyURL.protocol, + hostname: proxyURL.hostname.replace(/^\[|\]$/g, ''), + port: proxyURL.port, + auth: proxyAuth && typeof proxyAuth === 'string' ? proxyAuth : undefined, + }; + if (proxyURL.protocol === 'https:') { + agentOptions.ALPNProtocols = ['http/1.1']; + } + const tunnelingAgent = getTunnelingAgent(agentOptions, configHttpsAgent); + // Set both: `options.agent` is consumed by the native https.request path + // (config.maxRedirects === 0); `options.agents.https` is consumed by + // follow-redirects, which ignores `options.agent` when `options.agents` + // is present. + options.agent = tunnelingAgent; + if (options.agents) { + options.agents.https = tunnelingAgent; + } + } + } else { + // Forward-proxy mode for plaintext HTTP targets. The request line carries + // the absolute URL and the proxy sees everything — acceptable for plain + // HTTP since the wire was already plaintext. + if (proxyAuth) { + const base64 = Buffer.from(proxyAuth, 'utf8').toString('base64'); + options.headers['Proxy-Authorization'] = 'Basic ' + base64; + } + + // Preserve a user-supplied Host header (case-insensitive) so callers can override + // the value forwarded to the proxy; otherwise default to the request URL's host. + let hasUserHostHeader = false; + for (const name of Object.keys(options.headers)) { + if (name.toLowerCase() === 'host') { + hasUserHostHeader = true; + break; + } + } + if (!hasUserHostHeader) { + options.headers.host = options.hostname + (options.port ? ':' + options.port : ''); + } + const proxyHost = readProxyField('hostname') || readProxyField('host'); + options.hostname = proxyHost; + // Replace 'host' since options is not a URL object + options.host = proxyHost; + options.port = readProxyField('port'); + options.path = location; + const proxyProtocol = readProxyField('protocol'); + if (proxyProtocol) { + options.protocol = proxyProtocol.includes(':') ? proxyProtocol : `${proxyProtocol}:`; } - } - if (!hasUserHostHeader) { - options.headers.host = options.hostname + (options.port ? ':' + options.port : ''); - } - const proxyHost = readProxyField('hostname') || readProxyField('host'); - options.hostname = proxyHost; - // Replace 'host' since options is not a URL object - options.host = proxyHost; - options.port = readProxyField('port'); - options.path = location; - const proxyProtocol = readProxyField('protocol'); - if (proxyProtocol) { - options.protocol = proxyProtocol.includes(':') ? proxyProtocol : `${proxyProtocol}:`; } } options.beforeRedirects.proxy = function beforeRedirect(redirectOptions) { // Configure proxy for redirected request, passing the original config proxy to apply // the exact same logic as if the redirected request was performed by axios directly. - setProxy(redirectOptions, configProxy, redirectOptions.href, true); + setProxy(redirectOptions, configProxy, redirectOptions.href, true, configHttpsAgent); }; } @@ -817,13 +920,19 @@ export default isHttpAdapterSupported && setProxy( options, config.proxy, - protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path + protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path, + false, + config.httpsAgent ); } let transport; let isNativeTransport = false; const isHttpsRequest = isHttps.test(options.protocol); - options.agent = isHttpsRequest ? config.httpsAgent : config.httpAgent; + // Don't clobber a CONNECT-tunneling agent installed by setProxy() for an + // HTTPS target. + if (options.agent == null) { + options.agent = isHttpsRequest ? config.httpsAgent : config.httpAgent; + } if (isHttp2) { transport = http2Transport; diff --git a/package-lock.json b/package-lock.json index 6be9f968..bc85e2a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" }, "devDependencies": { @@ -3537,6 +3538,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -4591,7 +4604,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6205,6 +6217,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -7461,7 +7486,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/multer": { diff --git a/package.json b/package.json index b9aa7fdd..7a7a8c56 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" }, "devDependencies": { @@ -180,4 +181,4 @@ "lint-staged": { "*.{js,cjs,mjs,ts,json,md,yml,yaml}": "prettier --write" } -} \ No newline at end of file +} diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index 3e623365..56e2e934 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -11,6 +11,7 @@ import { import axios from '../../../index.js'; import AxiosError from '../../../lib/core/AxiosError.js'; import { __setProxy } from '../../../lib/adapters/http.js'; +import HttpsProxyAgent from 'https-proxy-agent'; import http from 'http'; import https from 'https'; import net from 'net'; @@ -1622,6 +1623,7 @@ describe('supports http with nodejs', () => { { port: SERVER_PORT } ); + let connectAttempts = 0; const proxy = await startHTTPServer( (request, response) => { const parsed = new URL(request.url); @@ -1646,6 +1648,10 @@ describe('supports http with nodejs', () => { }, { port: PROXY_PORT } ); + proxy.on('connect', (req, sock) => { + connectAttempts += 1; + sock.end(); + }); try { const response = await axios.get(`http://localhost:${server.address().port}/`, { @@ -1656,6 +1662,7 @@ describe('supports http with nodejs', () => { }); assert.strictEqual(Number(response.data), 123456789, 'should pass through proxy'); + assert.strictEqual(connectAttempts, 0, 'HTTP targets must use forward-proxy mode, not CONNECT'); } finally { await stopHTTPServer(server); await stopHTTPServer(proxy); @@ -1681,74 +1688,216 @@ describe('supports http with nodejs', () => { }); const server = await new Promise((resolve, reject) => { - const httpsServer = https - .createServer( - tlsOptions, - (req, res) => { - res.setHeader('Content-Type', 'text/html; charset=UTF-8'); - res.end('12345'); - }, - { port: SERVER_PORT } - ) - .listen(SERVER_PORT, () => resolve(httpsServer)); - + const httpsServer = https.createServer(tlsOptions, (req, res) => { + res.setHeader('Content-Type', 'text/html; charset=UTF-8'); + res.end('12345'); + }); + httpsServer.listen(0, '127.0.0.1', () => resolve(httpsServer)); httpsServer.on('error', reject); }); + let plaintextRequests = 0; + const connectTargets = []; + const upstreamSockets = []; const proxy = await new Promise((resolve, reject) => { - const httpsProxy = https - .createServer( - tlsOptions, - (request, response) => { - const targetUrl = new URL(request.url); - const opts = { - host: targetUrl.hostname, - port: targetUrl.port, - path: `${targetUrl.pathname}${targetUrl.search}`, - protocol: targetUrl.protocol, - rejectUnauthorized: false, - }; + const httpsProxy = https.createServer(tlsOptions, () => { + plaintextRequests += 1; + }); - const proxyRequest = https.get(opts, (res) => { - let body = ''; - - res.on('data', (data) => { - body += data; - }); - - res.on('end', () => { - response.setHeader('Content-Type', 'text/html; charset=UTF-8'); - response.end(body + '6789'); - }); - }); - - proxyRequest.on('error', () => { - response.statusCode = 502; - response.end(); - }); - }, - { port: PROXY_PORT } - ) - .listen(PROXY_PORT, () => resolve(httpsProxy)); + httpsProxy.on('connect', (req, clientSocket, head) => { + connectTargets.push(req.url); + const [targetHost, targetPort] = req.url.split(':'); + const upstream = net.connect(Number(targetPort), targetHost, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head && head.length) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstreamSockets.push(upstream); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + }); + httpsProxy.listen(0, '127.0.0.1', () => resolve(httpsProxy)); httpsProxy.on('error', reject); }); + const originalReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + const tunnelingAgent = new HttpsProxyAgent({ + protocol: 'https:', + host: '127.0.0.1', + port: proxy.address().port, + ALPNProtocols: ['http/1.1'], + rejectUnauthorized: false, + }); try { - const response = await axios.get(`https://localhost:${server.address().port}/`, { - proxy: { - host: 'localhost', - port: proxy.address().port, - protocol: 'https:', - }, - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), + const response = await axios.get(`https://127.0.0.1:${server.address().port}/`, { + httpsAgent: tunnelingAgent, }); - assert.strictEqual(Number(response.data), 123456789, 'should pass through proxy'); + // axios may auto-parse the body as JSON; compare as number to tolerate either form. + assert.strictEqual(Number(response.data), 12345, 'origin body should be received unmodified'); + assert.strictEqual(plaintextRequests, 0, 'proxy must not see plaintext requests'); + assert.strictEqual(connectTargets.length, 1, 'proxy should see exactly one CONNECT'); + assert.ok( + connectTargets[0].startsWith(`127.0.0.1:${server.address().port}`), + `CONNECT should target the origin: ${connectTargets[0]}` + ); } finally { - await Promise.all([closeServer(server), closeServer(proxy)]); + if (originalReject === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalReject; + } + tunnelingAgent.destroy(); + // Tear down everything synchronously. server.close() on tls.Server can hang + // when CONNECT-tunneled sockets have been pumped through, even after + // closeAllConnections — destroy the underlying handles directly so the + // test doesn't wait on a graceful shutdown. + for (const s of upstreamSockets) s.destroy(); + server.closeAllConnections?.(); + proxy.closeAllConnections?.(); + server.close(); + proxy.close(); + server.unref?.(); + proxy.unref?.(); + } + }); + + it('should CONNECT-tunnel HTTPS targets through an HTTP proxy by default (issue #6320)', async () => { + const tlsOptions = { + key: fs.readFileSync(path.join(adaptersTestsDir, 'key.pem')), + cert: fs.readFileSync(path.join(adaptersTestsDir, 'cert.pem')), + }; + + const origin = await new Promise((resolve, reject) => { + const s = https.createServer(tlsOptions, (req, res) => { + if (req.headers['proxy-authorization']) { + // Proxy-Authorization MUST NOT reach the origin under tunneling. + res.writeHead(500); + res.end('LEAKED:' + req.headers['proxy-authorization']); + return; + } + res.setHeader('Content-Type', 'text/html; charset=UTF-8'); + res.end('secret-body-12345'); + }); + s.listen(0, '127.0.0.1', () => resolve(s)); + s.on('error', reject); + }); + + const captured = { plaintext: 0, connectTargets: [], connectAuth: [] }; + const upstreamSockets = []; + const proxy = await new Promise((resolve, reject) => { + const p = http.createServer((req) => { + // Plaintext arrival = tunneling regression. Capture URL/headers so + // assertions below can show what leaked. + captured.plaintext += 1; + captured.plaintextUrl = req.url; + }); + p.on('connect', (req, clientSocket, head) => { + captured.connectTargets.push(req.url); + captured.connectAuth.push(req.headers['proxy-authorization'] || null); + const [host, port] = req.url.split(':'); + const upstream = net.connect(Number(port), host, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head && head.length) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstreamSockets.push(upstream); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + }); + p.listen(0, '127.0.0.1', () => resolve(p)); + p.on('error', reject); + }); + + const originalReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + try { + const response = await axios.post( + `https://127.0.0.1:${origin.address().port}/path?token=abc123`, + { sensitive: 'leak-canary' }, + { + proxy: { + host: '127.0.0.1', + port: proxy.address().port, + protocol: 'http', + auth: { username: 'admin', password: 'secret' }, + }, + validateStatus: () => true, + } + ); + + assert.strictEqual(response.data, 'secret-body-12345', 'origin body should arrive unmodified through the tunnel'); + assert.strictEqual(captured.plaintext, 0, 'proxy must not see any plaintext request line'); + assert.strictEqual(captured.connectTargets.length, 1, 'proxy should see exactly one CONNECT'); + assert.ok( + captured.connectTargets[0].startsWith(`127.0.0.1:${origin.address().port}`), + `CONNECT should target the origin host:port, got ${captured.connectTargets[0]}` + ); + assert.ok(captured.connectAuth[0], 'Proxy-Authorization should be present on the CONNECT request'); + assert.match( + captured.connectAuth[0], + /^Basic /, + 'CONNECT auth should be Basic-encoded' + ); + const decoded = Buffer.from(captured.connectAuth[0].slice(6), 'base64').toString('utf8'); + assert.strictEqual(decoded, 'admin:secret', 'Proxy-Authorization credentials should match'); + } finally { + if (originalReject === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalReject; + } + for (const s of upstreamSockets) s.destroy(); + origin.closeAllConnections?.(); + proxy.closeAllConnections?.(); + origin.close(); + proxy.close(); + origin.unref?.(); + proxy.unref?.(); + } + }); + + it('should surface a CONNECT 407 from the proxy as an AxiosError (issue #6320)', async () => { + const proxy = await new Promise((resolve, reject) => { + const p = http.createServer(); + p.on('connect', (req, clientSocket) => { + clientSocket.write( + 'HTTP/1.1 407 Proxy Authentication Required\r\n' + + 'Proxy-Authenticate: Basic realm="proxy"\r\n' + + 'Content-Length: 0\r\n' + + '\r\n' + ); + clientSocket.end(); + }); + p.listen(0, '127.0.0.1', () => resolve(p)); + p.on('error', reject); + }); + + try { + await assert.rejects( + async () => { + await axios.get('https://127.0.0.1:1/', { + proxy: { + host: '127.0.0.1', + port: proxy.address().port, + protocol: 'http', + }, + timeout: 4000, + }); + }, + (err) => { + assert.ok(err instanceof AxiosError, 'rejection should be an AxiosError'); + return true; + } + ); + } finally { + proxy.closeAllConnections?.(); + proxy.close(); + proxy.unref?.(); } }); @@ -1888,75 +2037,67 @@ describe('supports http with nodejs', () => { }); const server = await new Promise((resolve, reject) => { - const httpsServer = https - .createServer( - tlsOptions, - (req, res) => { - res.setHeader('Content-Type', 'text/html; charset=UTF-8'); - res.end('12345'); - }, - { port: SERVER_PORT } - ) - .listen(SERVER_PORT, () => resolve(httpsServer)); - + const httpsServer = https.createServer(tlsOptions, (req, res) => { + res.setHeader('Content-Type', 'text/html; charset=UTF-8'); + res.end('12345'); + }); + httpsServer.listen(0, '127.0.0.1', () => resolve(httpsServer)); httpsServer.on('error', reject); }); + let plaintextRequests = 0; + const connectTargets = []; + const upstreamSockets = []; const proxy = await new Promise((resolve, reject) => { - const httpsProxy = https - .createServer( - tlsOptions, - (request, response) => { - const targetUrl = new URL(request.url); - const opts = { - host: targetUrl.hostname, - port: targetUrl.port, - path: `${targetUrl.pathname}${targetUrl.search}`, - protocol: targetUrl.protocol, - rejectUnauthorized: false, - }; + const httpsProxy = https.createServer(tlsOptions, () => { + plaintextRequests += 1; + }); - const proxyRequest = https.get(opts, (res) => { - let body = ''; - - res.on('data', (data) => { - body += data; - }); - - res.on('end', () => { - response.setHeader('Content-Type', 'text/html; charset=UTF-8'); - response.end(body + '6789'); - }); - }); - - proxyRequest.on('error', () => { - response.statusCode = 502; - response.end(); - }); - }, - { port: PROXY_PORT } - ) - .listen(PROXY_PORT, () => resolve(httpsProxy)); + httpsProxy.on('connect', (req, clientSocket, head) => { + connectTargets.push(req.url); + const [targetHost, targetPort] = req.url.split(':'); + const upstream = net.connect(Number(targetPort), targetHost, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head && head.length) upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstreamSockets.push(upstream); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + }); + httpsProxy.listen(0, '127.0.0.1', () => resolve(httpsProxy)); httpsProxy.on('error', reject); }); - const proxyUrl = `https://localhost:${proxy.address().port}/`; + const proxyUrl = `https://127.0.0.1:${proxy.address().port}/`; process.env.https_proxy = proxyUrl; process.env.HTTPS_PROXY = proxyUrl; process.env.no_proxy = ''; process.env.NO_PROXY = ''; + const originalReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; try { - const response = await axios.get(`https://localhost:${server.address().port}/`, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), - }); + const response = await axios.get(`https://127.0.0.1:${server.address().port}/`); - assert.equal(response.data, '123456789', 'should pass through proxy'); + assert.strictEqual(Number(response.data), 12345, 'origin body should be received unmodified'); + assert.strictEqual(plaintextRequests, 0, 'proxy must not see plaintext requests'); + assert.strictEqual(connectTargets.length, 1, 'proxy should see exactly one CONNECT'); } finally { - await Promise.all([closeServer(server), closeServer(proxy)]); + if (originalReject === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = originalReject; + } + for (const s of upstreamSockets) s.destroy(); + server.closeAllConnections?.(); + proxy.closeAllConnections?.(); + server.close(); + proxy.close(); + server.unref?.(); + proxy.unref?.(); if (originalHttpsProxy === undefined) { delete process.env.https_proxy; @@ -3036,6 +3177,123 @@ describe('supports http with nodejs', () => { } }); + describe('HTTPS CONNECT tunneling agent management', () => { + const buildOptions = () => ({ + headers: {}, + beforeRedirects: {}, + hostname: 'example.com', + host: 'example.com', + port: 443, + path: '/', + protocol: 'https:', + }); + const proxyConfig = { host: '127.0.0.1', port: 8030, protocol: 'http' }; + + it('reuses the same tunneling agent for repeated requests through the same proxy', () => { + const a = buildOptions(); + const b = buildOptions(); + __setProxy(a, proxyConfig, 'https://example.com/'); + __setProxy(b, proxyConfig, 'https://example.com/'); + assert.ok(a.agent, 'first request must install a tunneling agent'); + assert.strictEqual( + a.agent, + b.agent, + 'subsequent requests through the same proxy must share one tunneling agent so socket pooling works' + ); + }); + + it('still tunnels through the proxy when a non-proxy httpsAgent is supplied', () => { + const userAgent = new https.Agent({ rejectUnauthorized: false }); + const options = buildOptions(); + __setProxy(options, proxyConfig, 'https://example.com/', false, userAgent); + assert.ok(options.agent, 'proxy must not be silently bypassed when a custom httpsAgent is set'); + assert.notStrictEqual( + options.agent, + userAgent, + 'tunneling agent must be installed in place of the user agent (its TLS options are forwarded internally)' + ); + assert.ok(options.agent instanceof HttpsProxyAgent); + }); + + it('forwards user httpsAgent options to the tunneling agent so origin TLS uses them', () => { + const userAgent = new https.Agent({ rejectUnauthorized: false, ca: 'sentinel-ca' }); + const options = buildOptions(); + __setProxy(options, proxyConfig, 'https://example.com/', false, userAgent); + // HttpsProxyAgent v5 surfaces the merged constructor options on `.proxy`. + assert.strictEqual(options.agent.proxy.rejectUnauthorized, false); + assert.strictEqual(options.agent.proxy.ca, 'sentinel-ca'); + }); + + it('respects a user-supplied HttpsProxyAgent without installing its own', () => { + const userTunnel = new HttpsProxyAgent({ + protocol: 'http:', + hostname: '127.0.0.1', + port: 9999, + }); + const options = buildOptions(); + __setProxy(options, proxyConfig, 'https://example.com/', false, userTunnel); + // The user is handling tunneling end-to-end; setProxy must not overwrite agent. + assert.strictEqual(options.agent, undefined, 'must not install a competing tunneling agent'); + }); + + it('does not strip a user-supplied HttpsProxyAgent on redirect', () => { + const userTunnel = new HttpsProxyAgent({ + protocol: 'http:', + hostname: '127.0.0.1', + port: 9999, + }); + const redirectOptions = { + headers: {}, + beforeRedirects: {}, + hostname: 'redirect.example.com', + host: 'redirect.example.com', + port: 443, + path: '/', + protocol: 'https:', + agent: userTunnel, + }; + __setProxy(redirectOptions, false, 'https://redirect.example.com/', true); + assert.strictEqual( + redirectOptions.agent, + userTunnel, + 'user-supplied HttpsProxyAgent must survive redirects (no proxy on redirect target)' + ); + }); + + it('strips its own tunneling agent on redirect when the redirect target has no proxy', () => { + const initial = buildOptions(); + __setProxy(initial, proxyConfig, 'https://example.com/'); + assert.ok(initial.agent instanceof HttpsProxyAgent, 'precondition: tunneling agent installed'); + + const redirectOptions = { + headers: {}, + beforeRedirects: {}, + hostname: 'final.example.com', + host: 'final.example.com', + port: 443, + path: '/', + protocol: 'https:', + agent: initial.agent, + }; + __setProxy(redirectOptions, false, 'https://final.example.com/', true); + assert.strictEqual( + redirectOptions.agent, + undefined, + 'axios-installed tunneling agent must be cleared when redirect drops the proxy' + ); + }); + + it('handles IPv6 literal proxy hosts', () => { + const options = buildOptions(); + __setProxy( + options, + { host: '::1', port: 8030, protocol: 'http' }, + 'https://example.com/' + ); + assert.ok(options.agent instanceof HttpsProxyAgent, 'must build a tunneling agent for an IPv6 proxy host'); + }); + }); + it('should return malformed URL', async () => { await assert.rejects(axios.get('tel:484-695-3408'), (error) => { assert.equal(error.message, 'Unsupported protocol tel:');