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:');