diff --git a/PRE_RELEASE_CHANGELOG.md b/PRE_RELEASE_CHANGELOG.md index c3d503fd..755af7ae 100644 --- a/PRE_RELEASE_CHANGELOG.md +++ b/PRE_RELEASE_CHANGELOG.md @@ -13,6 +13,7 @@ - **Types:** Add the missing readonly `name: 'CanceledError'` declaration to CommonJS `CanceledError` typings to match the ESM declarations. (**#10922**) - **Types:** Correct the CommonJS `isCancel` type guard to narrow cancellation errors to `CanceledError`, matching the ESM declaration. (**#10952**) - **HTTP Adapter - Auth on Redirect:** HTTP Basic credentials supplied via `config.auth` are now restored on same-origin redirects, fixing a regression caused by `follow-redirects` >= 1.15.8 that broke `POST` requests answered with a 303 Location. Cross-origin redirects continue to drop credentials, preserving the existing T-R2 mitigation in `THREATMODEL.md`. (**#6929**) +- **HTTP Adapter - Proxy TLS:** Preserve `httpsAgent` TLS options such as `ca` and `rejectUnauthorized` for HTTPS origins reached through a CONNECT proxy tunnel. (**#10953**) - **HTTP Adapter - Socket Path:** Ignore inherited `socketPath` and `allowedSocketPaths` config values when building Node.js requests, preventing prototype-pollution SSRF via Unix sockets. (**#10901**) - **React Native FormData:** Clear the default `Content-Type` header for React Native `FormData` requests so Android can build multipart bodies with the correct boundary. (**#10898**) - **Request Data:** Preserve enumerable symbol keys when merging plain request data before `transformRequest`. (**#6392**) diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 5def8c4b..f37ba974 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -112,6 +112,14 @@ function getTunnelingAgent(agentOptions, userHttpsAgent) { ? { ...userHttpsAgent.options, ...agentOptions } : agentOptions; agent = new HttpsProxyAgent(merged); + if (userHttpsAgent && userHttpsAgent.options) { + const originTLSOptions = { ...userHttpsAgent.options }; + const callback = agent.callback; + agent.callback = function axiosTunnelingAgentCallback(req, opts) { + // HttpsProxyAgent v5 reads callback opts for the post-CONNECT origin TLS upgrade. + return callback.call(this, req, { ...originTLSOptions, ...opts }); + }; + } agent[kAxiosInstalledTunnel] = true; cache.set(key, agent); return agent; diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index 0a9741a6..7799d521 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -2015,6 +2015,75 @@ describe('supports http with nodejs', () => { } }); + it('should apply httpsAgent TLS options to CONNECT-tunneled origins (issue #10953)', 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) => { + res.setHeader('Content-Type', 'text/html; charset=UTF-8'); + res.end('trusted-through-agent'); + }); + s.listen(0, 'localhost', () => resolve(s)); + s.on('error', reject); + }); + + const captured = { plaintext: 0, connectTargets: [] }; + const upstreamSockets = []; + const proxy = await new Promise((resolve, reject) => { + const p = http.createServer(() => { + captured.plaintext += 1; + }); + p.on('connect', (req, clientSocket, head) => { + captured.connectTargets.push(req.url); + 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 httpsAgent = new https.Agent({ ca: tlsOptions.cert }); + + try { + const response = await axios.get(`https://localhost:${origin.address().port}/`, { + httpsAgent, + proxy: { + host: '127.0.0.1', + port: proxy.address().port, + protocol: 'http', + }, + }); + + assert.strictEqual(response.data, 'trusted-through-agent'); + assert.strictEqual(captured.plaintext, 0, 'proxy must not see plaintext HTTPS requests'); + assert.strictEqual(captured.connectTargets.length, 1, 'proxy should see exactly one CONNECT'); + assert.ok( + captured.connectTargets[0].startsWith(`localhost:${origin.address().port}`), + `CONNECT should target the origin host:port, got ${captured.connectTargets[0]}` + ); + } finally { + httpsAgent.destroy(); + 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(); @@ -3369,11 +3438,11 @@ describe('supports http with nodejs', () => { assert.ok(options.agent instanceof HttpsProxyAgent); }); - it('forwards user httpsAgent options to the tunneling agent so origin TLS uses them', () => { + it('includes user httpsAgent options in the tunneling agent constructor options', () => { 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`. + // Origin TLS behavior is covered by the issue #10953 integration test. assert.strictEqual(options.agent.proxy.rejectUnauthorized, false); assert.strictEqual(options.agent.proxy.ca, 'sentinel-ca'); });