axios's shouldBypassProxy does not recognize IPv4-mapped IPv6 addresses, allowing NO_PROXY bypass (incomplete fix for CVE-2025-62718)
Description
### Summary shouldBypassProxy, introduced in v1.15.0 to fix CVE-2025-62718, does not normalise IPv4-mapped IPv6 addresses. When NO_PROXY lists an IPv4 address such as 127.0.0.1 or 169.254.169.254, a request URL using the IPv4-mapped IPv6 form (::ffff:7f00:1, ::ffff:a9fe:a9fe) still routes through the configured proxy. Node.js resolves these addresses to the underlying IPv4 host, so the request reaches the internal service via the proxy rather than being blocked.
### Details lib/helpers/shouldBypassProxy.js (v1.15.0):
const LOOPBACK_ADDRESSES = new Set(['localhost', '127.0.0.1', '::1']);
const isLoopback = (host) => LOOPBACK_ADDRESSES.has(host);
// normalizeNoProxyHost strips brackets and trailing dots, but not ::ffff: prefix
return hostname === entryHost || (isLoopback(hostname) && isLoopback(entryHost));
The WHATWG URL parser canonicalises http://[::ffff:127.0.0.1]/ to hostname [::ffff:7f00:1]. After bracket-stripping: ::ffff:7f00:1. This string does not match 127.0.0.1 in NO_PROXY and is not in LOOPBACK_ADDRESSES, so shouldBypassProxy returns false and the proxy is used. proxy-from-env (called before shouldBypassProxy) has the same gap - it does not equate ::ffff:7f00:1 with 127.0.0.1 - so neither layer catches the bypass.
PoC
// NO_PROXY=127.0.0.1,localhost,::1 HTTP_PROXY=http://attacker:8080
import shouldBypassProxy from 'axios/lib/helpers/shouldBypassProxy.js';
// All three should return true (bypass proxy). Only the first two do.
console.log(shouldBypassProxy('http://127.0.0.1/')); // true [OK]
console.log(shouldBypassProxy('http://[::1]/')); // true [OK]
console.log(shouldBypassProxy('http://[::ffff:127.0.0.1]/')); // false <- bypass
console.log(shouldBypassProxy('http://[::ffff:7f00:1]/')); // false <- bypass
Node.js routes ::ffff:7f00:1 to 127.0.0.1:
// net.connect({ host: '::ffff:7f00:1', port: 80 }) reaches a service
// bound to 127.0.0.1:80 — confirmed on Node.js v24, Linux and macOS.
Cloud metadata SSRF: ::ffff:a9fe:a9fe = ::ffff:169.254.169.254. If NO_PROXY=169.254.169.254 is set to block IMDS access, a request to http://[::ffff:a9fe:a9fe]/latest/meta-data/ bypasses it.
Fix
Canonicalise IPv4-mapped IPv6 in normalizeNoProxyHost before any comparison:
const ipv4MappedDotted = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i;
const ipv4MappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
function hexToIPv4(a, b) {
const hi = parseInt(a, 16), lo = parseInt(b, 16);
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
}
const normalizeNoProxyHost = (hostname) => {
if (!hostname) return hostname;
if (hostname[0] === '[' && hostname.at(-1) === ']')
hostname = hostname.slice(1, -1);
hostname = hostname.replace(/\.+$/, '').toLowerCase();
let m;
if ((m = hostname.match(ipv4MappedDotted))) return m[1];
if ((m = hostname.match(ipv4MappedHex))) return hexToIPv4(m[1], m[2]);
return hostname;
};
Impact
Any application that sets NO_PROXY to exclude internal or metadata endpoints and uses an HTTP/HTTPS proxy can have those exclusions bypassed by a URL using IPv4-mapped IPv6 notation. The attacker must control the request URL. In cloud environments with instance metadata services, this can lead to credential exfiltration.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Axios v1.15.0's shouldBypassProxy fails to normalize IPv4-mapped IPv6 addresses, letting NO_PROXY-listed internal hosts be routed through the proxy.
Vulnerability
In Axios v1.15.0, the shouldBypassProxy function introduced to fix CVE-2025-62718 does not normalize IPv4-mapped IPv6 addresses such as ::ffff:7f00:1 (representing 127.0.0.1). When NO_PROXY lists an IPv4 address like 127.0.0.1 or 169.254.169.254, a request URL using the IPv4-mapped IPv6 form still passes through the proxy because the string comparison and loopback check fail to equate ::ffff:7f00:1 with 127.0.0.1. The same gap exists in the proxy-from-env library called before shouldBypassProxy [2][3].
Exploitation
An attacker who controls a proxy endpoint that the client trusts (e.g., via HTTP_PROXY=http://attacker:8080) can craft a request to a protected internal service using an IPv4-mapped IPv6 address (e.g., http://[::ffff:127.0.0.1]/ or http://[::ffff:a9fe:a9fe]/). No special network position is required beyond being able to configure the proxy or trick the victim into using a malicious proxy. The WHATWG URL parser canonicalizes the address to [::ffff:7f00:1], which shouldBypassProxy does not recognize as matching 127.0.0.1 in NO_PROXY, so the proxy is used and the request is forwarded to the attacker-controlled proxy [2].
Impact
A successful attacker can intercept or manipulate traffic intended for internal services that were supposed to be excluded from proxying via NO_PROXY. This leads to information disclosure (e.g., reading credentials or sensitive responses), potential request forgery (the attacker can modify the request), and network access to internal hosts that should have been bypassed. The impact is limited to scenarios where the attacker can control the proxy endpoint and the client has an internal IP in its NO_PROXY list [2][3].
Mitigation
The vulnerability affects Axios v1.15.0. As of the publication date (2026-05-29), no patched version has been released. Users should monitor the Axios repository for a fix that normalizes IPv4-mapped IPv6 addresses (e.g., converting ::ffff:127.0.0.1 to 127.0.0.1 before comparison). Until a patch is available, avoid using NO_PROXY with IPv4 addresses when the client might receive IPv4-mapped IPv6 addresses, or implement a custom proxy bypass check as a workaround [2][3].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3Patches
203cdfc99e8dbfix: backport the fixes from the v1 branch (#10688)
8 files changed · +397 −4
lib/adapters/http.js+7 −4 modified@@ -19,6 +19,7 @@ var platform = require('../platform'); var fromDataURI = require('../helpers/fromDataURI'); var stream = require('stream'); var estimateDataURLDecodedBytes = require('../helpers/estimateDataURLDecodedBytes.js'); +var shouldBypassProxy = require('../helpers/shouldBypassProxy'); var isHttps = /https:?/; @@ -46,9 +47,11 @@ function setProxy(options, configProxy, location) { if (!proxy && proxy !== false) { var proxyUrl = getProxyForUrl(location); if (proxyUrl) { - proxy = url.parse(proxyUrl); - // replace 'host' since the proxy object is not a URL object - proxy.host = proxy.hostname; + if (!shouldBypassProxy(location)) { + proxy = url.parse(proxyUrl); + // replace 'host' since the proxy object is not a URL object + proxy.host = proxy.hostname; + } } } if (proxy) { @@ -273,7 +276,7 @@ module.exports = function httpAdapter(config) { } else { options.hostname = parsed.hostname; options.port = parsed.port; - setProxy(options, config.proxy, protocol + '//' + parsed.hostname + (parsed.port ? ':' + parsed.port : '') + options.path); + setProxy(options, config.proxy, protocol + '//' + parsed.host + options.path); } var transport;
lib/core/dispatchRequest.js+5 −0 modified@@ -6,6 +6,7 @@ var isCancel = require('../cancel/isCancel'); var defaults = require('../defaults'); var CanceledError = require('../cancel/CanceledError'); var normalizeHeaderName = require('../helpers/normalizeHeaderName'); +var sanitizeHeaderValue = require('../helpers/sanitizeHeaderValue'); /** * Throws a `CanceledError` if cancellation has been requested. @@ -58,6 +59,10 @@ module.exports = function dispatchRequest(config) { } ); + utils.forEach(config.headers, function sanitizeHeaderConfigValue(value, header) { + config.headers[header] = sanitizeHeaderValue(value); + }); + var adapter = config.adapter || defaults.adapter; return adapter(config).then(function onAdapterResolution(response) {
lib/helpers/sanitizeHeaderValue.js+22 −0 added@@ -0,0 +1,22 @@ +'use strict'; + +var utils = require('../utils'); + +var INVALID_HEADER_VALUE_RE = /[^\x09\x20-\x7E\x80-\xFF]/g; +var BOUNDARY_WHITESPACE_RE = /^[\x09\x20]+|[\x09\x20]+$/g; + +function sanitizeHeaderValue(value) { + if (value === false || value == null) { + return value; + } + + if (utils.isArray(value)) { + return value.map(sanitizeHeaderValue); + } + + return String(value) + .replace(INVALID_HEADER_VALUE_RE, '') + .replace(BOUNDARY_WHITESPACE_RE, ''); +} + +module.exports = sanitizeHeaderValue;
lib/helpers/shouldBypassProxy.js+133 −0 added@@ -0,0 +1,133 @@ +'use strict'; + +var URL = require('url').URL; + +var DEFAULT_PORTS = { + http: 80, + https: 443, + ws: 80, + wss: 443, + ftp: 21 +}; + +function parseNoProxyEntry(entry) { + var entryHost = entry; + var entryPort = 0; + + if (entryHost.charAt(0) === '[') { + var bracketIndex = entryHost.indexOf(']'); + + if (bracketIndex !== -1) { + var host = entryHost.slice(1, bracketIndex); + var rest = entryHost.slice(bracketIndex + 1); + + if (rest.charAt(0) === ':' && /^\d+$/.test(rest.slice(1))) { + entryPort = parseInt(rest.slice(1), 10); + } + + return [host, entryPort]; + } + } + + var firstColon = entryHost.indexOf(':'); + var lastColon = entryHost.lastIndexOf(':'); + + if (firstColon !== -1 && firstColon === lastColon && /^\d+$/.test(entryHost.slice(lastColon + 1))) { + entryPort = parseInt(entryHost.slice(lastColon + 1), 10); + entryHost = entryHost.slice(0, lastColon); + } + + return [entryHost, entryPort]; +} + +function normalizeNoProxyHost(hostname) { + if (!hostname) { + return hostname; + } + + if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') { + hostname = hostname.slice(1, -1); + } + + return hostname.replace(/\.+$/, ''); +} + +function isLoopbackIPv4(hostname) { + var octets = hostname.split('.'); + + if (octets.length !== 4) { + return false; + } + + if (octets[0] !== '127') { + return false; + } + + return octets.every(function testOctet(octet) { + return /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255; + }); +} + +function isLoopbackHost(hostname) { + return hostname === 'localhost' || hostname === '::1' || isLoopbackIPv4(hostname); +} + +module.exports = function shouldBypassProxy(location) { + var parsed; + + try { + parsed = new URL(location); + } catch (err) { + return false; + } + + var noProxy = (process.env.no_proxy || process.env.NO_PROXY || '').toLowerCase(); + + if (!noProxy) { + return false; + } + + if (noProxy === '*') { + return true; + } + + var protocol = parsed.protocol.split(':', 1)[0]; + var port = parsed.port !== '' ? parseInt(parsed.port, 10) : (DEFAULT_PORTS[protocol] || 0); + var hostname = normalizeNoProxyHost(parsed.hostname.toLowerCase()); + + return noProxy.split(/[\s,]+/).some(function testNoProxyEntry(entry) { + if (!entry) { + return false; + } + + var entryParts = parseNoProxyEntry(entry); + var entryHost = normalizeNoProxyHost(entryParts[0]); + var entryPort = entryParts[1]; + + if (entryHost === '*') { + return true; + } + + if (!entryHost) { + return false; + } + + if (entryPort && entryPort !== port) { + return false; + } + + if (isLoopbackHost(hostname) && isLoopbackHost(entryHost)) { + return true; + } + + if (entryHost.charAt(0) === '*') { + entryHost = entryHost.slice(1); + } + + if (entryHost.charAt(0) === '.') { + return hostname.slice(-entryHost.length) === entryHost; + } + + return hostname === entryHost; + }); +};
test/specs/headers.spec.js+13 −0 modified@@ -112,4 +112,17 @@ describe('headers', function () { done(); }); }); + + it('should sanitize headers containing invalid characters', function (done) { + axios('/foo', { + headers: { + 'x-test': ' ok\r\nInjected: yes\t' + } + }); + + getAjaxRequest().then(function (request) { + testHeaderValue(request.requestHeaders, 'x-test', 'okInjected: yes'); + done(); + }); + }); });
test/unit/adapters/http.js+123 −0 modified@@ -31,8 +31,39 @@ describe('supports http with nodejs', function () { proxy = null; } delete process.env.http_proxy; + delete process.env.HTTP_PROXY; delete process.env.https_proxy; delete process.env.no_proxy; + delete process.env.NO_PROXY; + }); + + it('should sanitize request headers containing invalid characters', function (done) { + server = http.createServer(function (req, res) { + res.setHeader('Content-Type', 'text/plain'); + res.end(req.headers['x-test']); + }).listen(4444, function () { + axios.get('http://localhost:4444/', { + headers: { + 'x-test': ' ok\r\nInjected: yes\t' + } + }).then(function (response) { + assert.equal(response.data, 'okInjected: yes'); + done(); + }).catch(done); + }); + }); + + it('should preserve request error for unavailable host with invalid characters', function (done) { + axios.get('http://localhost:1/', { + headers: { + 'x-test': 'ok\r\nInjected: yes' + } + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function (error) { + assert.notEqual(error.message, 'Invalid character in header content ["x-test"]'); + done(); + }); }); it('should throw an error if the timeout property is not parsable as a number', function (done) { @@ -967,6 +998,98 @@ describe('supports http with nodejs', function () { }); }); + it('should not use proxy for localhost with trailing dot when listed in no_proxy', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost,127.0.0.1,::1'; + process.env.NO_PROXY = 'localhost,127.0.0.1,::1'; + + axios.get('http://localhost.:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for localhost with trailing dot'); + done(); + }); + }); + }); + + it('should not use proxy for bracketed IPv6 loopback when listed in no_proxy', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost,127.0.0.1,::1'; + process.env.NO_PROXY = 'localhost,127.0.0.1,::1'; + + axios.get('http://[::1]:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for IPv6 loopback'); + done(); + }); + }); + }); + + it('should not use proxy for 127.0.0.1 when no_proxy is localhost', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost'; + process.env.NO_PROXY = 'localhost'; + + axios.get('http://127.0.0.1:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for IPv4 loopback alias'); + done(); + }); + }); + }); + + it('should not use proxy for [::1] when no_proxy is localhost', function (done) { + var proxyRequests = 0; + + proxy = http.createServer(function (request, response) { + proxyRequests += 1; + response.end('proxied'); + }).listen(4000, function () { + process.env.http_proxy = 'http://localhost:4000/'; + process.env.HTTP_PROXY = 'http://localhost:4000/'; + process.env.no_proxy = 'localhost'; + process.env.NO_PROXY = 'localhost'; + + axios.get('http://[::1]:1/', { + timeout: 100 + }).then(function () { + done(new Error('request should not succeed')); + }).catch(function () { + assert.equal(proxyRequests, 0, 'should not use proxy for IPv6 loopback alias'); + done(); + }); + }); + }); + it('should use proxy for domains not in no_proxy', function (done) { server = http.createServer(function (req, res) { res.setHeader('Content-Type', 'text/html; charset=UTF-8');
test/unit/helpers/sanitizeHeaderValue.js+20 −0 added@@ -0,0 +1,20 @@ +var assert = require('assert'); +var sanitizeHeaderValue = require('../../../lib/helpers/sanitizeHeaderValue'); + +describe('helpers::sanitizeHeaderValue', function () { + it('should remove invalid header characters', function () { + assert.strictEqual(sanitizeHeaderValue('ok\r\nInjected: yes'), 'okInjected: yes'); + assert.strictEqual(sanitizeHeaderValue('ok\x01bad'), 'okbad'); + }); + + it('should remove boundary whitespace', function () { + assert.strictEqual(sanitizeHeaderValue(' value\t'), 'value'); + }); + + it('should sanitize array values recursively', function () { + assert.deepStrictEqual( + sanitizeHeaderValue([' safe=1 ', 'unsafe=1\nInjected: true']), + ['safe=1', 'unsafe=1Injected: true'] + ); + }); +});
test/unit/helpers/shouldBypassProxy.js+74 −0 added@@ -0,0 +1,74 @@ +var assert = require('assert'); +var shouldBypassProxy = require('../../../lib/helpers/shouldBypassProxy'); + +var originalNoProxy = process.env.no_proxy; +var originalNOProxy = process.env.NO_PROXY; + +function setNoProxy(value) { + process.env.no_proxy = value; + process.env.NO_PROXY = value; +} + +describe('helpers::shouldBypassProxy', function () { + afterEach(function () { + if (originalNoProxy === undefined) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalNoProxy; + } + + if (originalNOProxy === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNOProxy; + } + }); + + it('should bypass proxy for localhost with a trailing dot', function () { + setNoProxy('localhost,127.0.0.1,::1'); + assert.strictEqual(shouldBypassProxy('http://localhost.:8080/'), true); + }); + + it('should bypass proxy for bracketed ipv6 loopback', function () { + setNoProxy('localhost,127.0.0.1,::1'); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + }); + + it('should support bracketed ipv6 entries in no_proxy', function () { + setNoProxy('[::1]'); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + }); + + it('should match wildcard and explicit ports', function () { + setNoProxy('*.example.com,localhost:8080'); + + assert.strictEqual(shouldBypassProxy('http://api.example.com/'), true); + assert.strictEqual(shouldBypassProxy('http://localhost:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://localhost:8081/'), false); + }); + + it('should treat localhost and loopback IP aliases as equivalent', function () { + setNoProxy('localhost'); + + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + + setNoProxy('127.0.0.1'); + + assert.strictEqual(shouldBypassProxy('http://localhost:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + + setNoProxy('::1'); + + assert.strictEqual(shouldBypassProxy('http://localhost:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8080/'), true); + }); + + it('should keep loopback alias matching port-aware', function () { + setNoProxy('localhost:8080'); + + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://[::1]:8080/'), true); + assert.strictEqual(shouldBypassProxy('http://127.0.0.1:8081/'), false); + }); +});
fb3befb6daacfix: no_proxy hostname normalization bypass leads to ssrf (#10661)
4 files changed · +270 −1
lib/adapters/http.js+4 −1 modified@@ -23,6 +23,7 @@ import formDataToStream from '../helpers/formDataToStream.js'; import readBlob from '../helpers/readBlob.js'; import ZlibHeaderTransformStream from '../helpers/ZlibHeaderTransformStream.js'; import callbackify from '../helpers/callbackify.js'; +import shouldBypassProxy from '../helpers/shouldBypassProxy.js'; import { progressEventReducer, progressEventDecorator, @@ -192,7 +193,9 @@ function setProxy(options, configProxy, location) { if (!proxy && proxy !== false) { const proxyUrl = getProxyForUrl(location); if (proxyUrl) { - proxy = new URL(proxyUrl); + if (!shouldBypassProxy(location)) { + proxy = new URL(proxyUrl); + } } } if (proxy) {
lib/helpers/shouldBypassProxy.js+106 −0 added@@ -0,0 +1,106 @@ +const DEFAULT_PORTS = { + http: 80, + https: 443, + ws: 80, + wss: 443, + ftp: 21, +}; + +const parseNoProxyEntry = (entry) => { + let entryHost = entry; + let entryPort = 0; + + if (entryHost.charAt(0) === '[') { + const bracketIndex = entryHost.indexOf(']'); + + if (bracketIndex !== -1) { + const host = entryHost.slice(1, bracketIndex); + const rest = entryHost.slice(bracketIndex + 1); + + if (rest.charAt(0) === ':' && /^\d+$/.test(rest.slice(1))) { + entryPort = Number.parseInt(rest.slice(1), 10); + } + + return [host, entryPort]; + } + } + + const firstColon = entryHost.indexOf(':'); + const lastColon = entryHost.lastIndexOf(':'); + + if ( + firstColon !== -1 && + firstColon === lastColon && + /^\d+$/.test(entryHost.slice(lastColon + 1)) + ) { + entryPort = Number.parseInt(entryHost.slice(lastColon + 1), 10); + entryHost = entryHost.slice(0, lastColon); + } + + return [entryHost, entryPort]; +}; + +const normalizeNoProxyHost = (hostname) => { + if (!hostname) { + return hostname; + } + + if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') { + hostname = hostname.slice(1, -1); + } + + return hostname.replace(/\.+$/, ''); +}; + +export default function shouldBypassProxy(location) { + let parsed; + + try { + parsed = new URL(location); + } catch (_err) { + return false; + } + + const noProxy = (process.env.no_proxy || process.env.NO_PROXY || '').toLowerCase(); + + if (!noProxy) { + return false; + } + + if (noProxy === '*') { + return true; + } + + const port = + Number.parseInt(parsed.port, 10) || DEFAULT_PORTS[parsed.protocol.split(':', 1)[0]] || 0; + + const hostname = normalizeNoProxyHost(parsed.hostname.toLowerCase()); + + return noProxy.split(/[\s,]+/).some((entry) => { + if (!entry) { + return false; + } + + let [entryHost, entryPort] = parseNoProxyEntry(entry); + + entryHost = normalizeNoProxyHost(entryHost); + + if (!entryHost) { + return false; + } + + if (entryPort && entryPort !== port) { + return false; + } + + if (entryHost.charAt(0) === '*') { + entryHost = entryHost.slice(1); + } + + if (entryHost.charAt(0) === '.') { + return hostname.endsWith(entryHost); + } + + return hostname === entryHost; + }); +}
tests/unit/adapters/http.test.js+108 −0 modified@@ -1738,6 +1738,114 @@ describe('supports http with nodejs', () => { } }); + it('should not use proxy for localhost with trailing dot when listed in no_proxy', async () => { + const originalHttpProxy = process.env.http_proxy; + const originalHTTPProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.no_proxy; + const originalNOProxy = process.env.NO_PROXY; + + let proxyRequests = 0; + const proxy = await startHTTPServer( + (_, response) => { + proxyRequests += 1; + response.end('proxied'); + }, + { port: PROXY_PORT } + ); + + const noProxyValue = 'localhost,127.0.0.1,::1'; + const proxyUrl = `http://localhost:${proxy.address().port}/`; + process.env.http_proxy = proxyUrl; + process.env.HTTP_PROXY = proxyUrl; + process.env.no_proxy = noProxyValue; + process.env.NO_PROXY = noProxyValue; + + try { + await assert.rejects(axios.get('http://localhost.:1/', { timeout: 100 })); + assert.equal(proxyRequests, 0, 'should not use proxy for localhost with trailing dot'); + } finally { + await stopHTTPServer(proxy); + + if (originalHttpProxy === undefined) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalHttpProxy; + } + + if (originalHTTPProxy === undefined) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHTTPProxy; + } + + if (originalNoProxy === undefined) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalNoProxy; + } + + if (originalNOProxy === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNOProxy; + } + } + }); + + it('should not use proxy for bracketed IPv6 loopback when listed in no_proxy', async () => { + const originalHttpProxy = process.env.http_proxy; + const originalHTTPProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.no_proxy; + const originalNOProxy = process.env.NO_PROXY; + + let proxyRequests = 0; + const proxy = await startHTTPServer( + (_, response) => { + proxyRequests += 1; + response.end('proxied'); + }, + { port: PROXY_PORT } + ); + + const noProxyValue = 'localhost,127.0.0.1,::1'; + const proxyUrl = `http://localhost:${proxy.address().port}/`; + process.env.http_proxy = proxyUrl; + process.env.HTTP_PROXY = proxyUrl; + process.env.no_proxy = noProxyValue; + process.env.NO_PROXY = noProxyValue; + + try { + await assert.rejects(axios.get('http://[::1]:1/', { timeout: 100 })); + assert.equal(proxyRequests, 0, 'should not use proxy for IPv6 loopback'); + } finally { + await stopHTTPServer(proxy); + + if (originalHttpProxy === undefined) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalHttpProxy; + } + + if (originalHTTPProxy === undefined) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHTTPProxy; + } + + if (originalNoProxy === undefined) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalNoProxy; + } + + if (originalNOProxy === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNOProxy; + } + } + }); + it('should use proxy for domains not in no_proxy', async () => { const originalHttpProxy = process.env.http_proxy; const originalHTTPProxy = process.env.HTTP_PROXY;
tests/unit/helpers/shouldBypassProxy.test.js+52 −0 added@@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import shouldBypassProxy from '../../../lib/helpers/shouldBypassProxy.js'; + +const originalNoProxy = process.env.no_proxy; +const originalNOProxy = process.env.NO_PROXY; + +const setNoProxy = (value) => { + process.env.no_proxy = value; + process.env.NO_PROXY = value; +}; + +afterEach(() => { + if (originalNoProxy === undefined) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalNoProxy; + } + + if (originalNOProxy === undefined) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNOProxy; + } +}); + +describe('helpers::shouldBypassProxy', () => { + it('should bypass proxy for localhost with a trailing dot', () => { + setNoProxy('localhost,127.0.0.1,::1'); + + expect(shouldBypassProxy('http://localhost.:8080/')).toBe(true); + }); + + it('should bypass proxy for bracketed ipv6 loopback', () => { + setNoProxy('localhost,127.0.0.1,::1'); + + expect(shouldBypassProxy('http://[::1]:8080/')).toBe(true); + }); + + it('should support bracketed ipv6 entries in no_proxy', () => { + setNoProxy('[::1]'); + + expect(shouldBypassProxy('http://[::1]:8080/')).toBe(true); + }); + + it('should match wildcard and explicit ports', () => { + setNoProxy('*.example.com,localhost:8080'); + + expect(shouldBypassProxy('http://api.example.com/')).toBe(true); + expect(shouldBypassProxy('http://localhost:8080/')).toBe(true); + expect(shouldBypassProxy('http://localhost:8081/')).toBe(false); + }); +});
Vulnerability mechanics
Root cause
"Missing normalization of IPv4-mapped IPv6 addresses in `shouldBypassProxy` allows NO_PROXY bypass."
Attack vector
An attacker who controls the request URL can bypass NO_PROXY exclusions by using the IPv4-mapped IPv6 notation. The WHATWG URL parser canonicalizes `http://[::ffff:127.0.0.1]/` to hostname `[::ffff:7f00:1]`; after bracket-stripping, `::ffff:7f00:1` does not match `127.0.0.1` in NO_PROXY and is not in the loopback set, so `shouldBypassProxy` returns false and the request is routed through the configured proxy [ref_id=1][ref_id=2]. Node.js resolves `::ffff:7f00:1` to `127.0.0.1`, so the request reaches the internal service via the proxy rather than being blocked [ref_id=1][ref_id=2]. In cloud environments, an attacker can target `http://[::ffff:a9fe:a9fe]/latest/meta-data/` to reach the IMDS at `169.254.169.254` when `NO_PROXY=169.254.169.254` is set [ref_id=1][ref_id=2].
Affected code
The vulnerable function is `shouldBypassProxy` in `lib/helpers/shouldBypassProxy.js` (v1.15.0). The `normalizeNoProxyHost` helper strips brackets and trailing dots but does not canonicalize IPv4-mapped IPv6 addresses such as `::ffff:7f00:1` [ref_id=1][ref_id=2]. The `isLoopback` function uses a fixed set (`'localhost', '127.0.0.1', '::1'`) that does not include any IPv4-mapped IPv6 form [ref_id=1][ref_id=2].
What the fix does
Both patches ([patch_id=3102127] and [patch_id=3102128]) introduce a new `shouldBypassProxy` module that is called from `setProxy` in `lib/adapters/http.js` before the proxy URL is used. The new `normalizeNoProxyHost` function strips brackets and trailing dots, but the patches do **not** include the IPv4-mapped IPv6 canonicalization logic described in the advisory's suggested fix. Instead, the patches add loopback-alias equivalence: `isLoopbackHost` matches `localhost`, `::1`, and any `127.x.x.x` address, and the comparison logic treats two loopback hosts as equivalent regardless of their string form [patch_id=3102127][patch_id=3102128]. This means `::ffff:7f00:1` is still not matched directly, but the advisory's recommended fix (regex-based conversion of `::ffff:` prefixed addresses to dotted IPv4) is described in the reference write-ups as the intended remediation [ref_id=1][ref_id=2].
Preconditions
- configThe application must set NO_PROXY (or no_proxy) to exclude internal IPs such as 127.0.0.1 or 169.254.169.254
- configThe application must configure an HTTP/HTTPS proxy via HTTP_PROXY or http_proxy
- inputThe attacker must be able to control the request URL (e.g., via user-supplied input that becomes the axios request target)
Reproduction
Set `NO_PROXY=127.0.0.1,localhost,::1` and `HTTP_PROXY=http://attacker:8080`. Run the following JavaScript:
```javascript import shouldBypassProxy from 'axios/lib/helpers/shouldBypassProxy.js'; console.log(shouldBypassProxy('http://127.0.0.1/')); // true [OK] console.log(shouldBypassProxy('http://[::1]/')); // true [OK] console.log(shouldBypassProxy('http://[::ffff:127.0.0.1]/')); // false <- bypass console.log(shouldBypassProxy('http://[::ffff:7f00:1]/')); // false <- bypass ```
The last two calls return `false`, meaning the proxy is used despite NO_PROXY listing `127.0.0.1` [ref_id=1][ref_id=2].
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.