mirror of
https://github.com/tenrok/axios.git
synced 2026-06-17 19:21:29 +03:00
fix: https data in cleartext to proxy (#10858)
* chore: added package to manage proxy * docs: updated docs to reflect changes * docs: update threatmodel * feat: implement fixes for proxy issue with https * chore: internalise the https-proxy-agent * fix: issues with https-proxy-agent * fix: apply fixes from deep review
This commit is contained in:
@@ -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',
|
||||
|
||||
+4
-4
@@ -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. <br>• `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. <br>• 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. <br>• `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. <br>• 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. <br>• `^` 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. <br>• `follow-redirects` is security-conscious and well-maintained; we track its advisories closely (multiple past axios releases were just `follow-redirects` bumps). <br>• 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. |
|
||||
|
||||
+137
-28
@@ -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;
|
||||
|
||||
Generated
+26
-2
@@ -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": {
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+365
-107
@@ -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:');
|
||||
|
||||
Reference in New Issue
Block a user