Skip to content

Commit fb3befb

Browse files
authored
fix: no_proxy hostname normalization bypass leads to ssrf (#10661)
1 parent 8023035 commit fb3befb

4 files changed

Lines changed: 270 additions & 1 deletion

File tree

lib/adapters/http.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import formDataToStream from '../helpers/formDataToStream.js';
2323
import readBlob from '../helpers/readBlob.js';
2424
import ZlibHeaderTransformStream from '../helpers/ZlibHeaderTransformStream.js';
2525
import callbackify from '../helpers/callbackify.js';
26+
import shouldBypassProxy from '../helpers/shouldBypassProxy.js';
2627
import {
2728
progressEventReducer,
2829
progressEventDecorator,
@@ -192,7 +193,9 @@ function setProxy(options, configProxy, location) {
192193
if (!proxy && proxy !== false) {
193194
const proxyUrl = getProxyForUrl(location);
194195
if (proxyUrl) {
195-
proxy = new URL(proxyUrl);
196+
if (!shouldBypassProxy(location)) {
197+
proxy = new URL(proxyUrl);
198+
}
196199
}
197200
}
198201
if (proxy) {

lib/helpers/shouldBypassProxy.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
const DEFAULT_PORTS = {
2+
http: 80,
3+
https: 443,
4+
ws: 80,
5+
wss: 443,
6+
ftp: 21,
7+
};
8+
9+
const parseNoProxyEntry = (entry) => {
10+
let entryHost = entry;
11+
let entryPort = 0;
12+
13+
if (entryHost.charAt(0) === '[') {
14+
const bracketIndex = entryHost.indexOf(']');
15+
16+
if (bracketIndex !== -1) {
17+
const host = entryHost.slice(1, bracketIndex);
18+
const rest = entryHost.slice(bracketIndex + 1);
19+
20+
if (rest.charAt(0) === ':' && /^\d+$/.test(rest.slice(1))) {
21+
entryPort = Number.parseInt(rest.slice(1), 10);
22+
}
23+
24+
return [host, entryPort];
25+
}
26+
}
27+
28+
const firstColon = entryHost.indexOf(':');
29+
const lastColon = entryHost.lastIndexOf(':');
30+
31+
if (
32+
firstColon !== -1 &&
33+
firstColon === lastColon &&
34+
/^\d+$/.test(entryHost.slice(lastColon + 1))
35+
) {
36+
entryPort = Number.parseInt(entryHost.slice(lastColon + 1), 10);
37+
entryHost = entryHost.slice(0, lastColon);
38+
}
39+
40+
return [entryHost, entryPort];
41+
};
42+
43+
const normalizeNoProxyHost = (hostname) => {
44+
if (!hostname) {
45+
return hostname;
46+
}
47+
48+
if (hostname.charAt(0) === '[' && hostname.charAt(hostname.length - 1) === ']') {
49+
hostname = hostname.slice(1, -1);
50+
}
51+
52+
return hostname.replace(/\.+$/, '');
53+
};
54+
55+
export default function shouldBypassProxy(location) {
56+
let parsed;
57+
58+
try {
59+
parsed = new URL(location);
60+
} catch (_err) {
61+
return false;
62+
}
63+
64+
const noProxy = (process.env.no_proxy || process.env.NO_PROXY || '').toLowerCase();
65+
66+
if (!noProxy) {
67+
return false;
68+
}
69+
70+
if (noProxy === '*') {
71+
return true;
72+
}
73+
74+
const port =
75+
Number.parseInt(parsed.port, 10) || DEFAULT_PORTS[parsed.protocol.split(':', 1)[0]] || 0;
76+
77+
const hostname = normalizeNoProxyHost(parsed.hostname.toLowerCase());
78+
79+
return noProxy.split(/[\s,]+/).some((entry) => {
80+
if (!entry) {
81+
return false;
82+
}
83+
84+
let [entryHost, entryPort] = parseNoProxyEntry(entry);
85+
86+
entryHost = normalizeNoProxyHost(entryHost);
87+
88+
if (!entryHost) {
89+
return false;
90+
}
91+
92+
if (entryPort && entryPort !== port) {
93+
return false;
94+
}
95+
96+
if (entryHost.charAt(0) === '*') {
97+
entryHost = entryHost.slice(1);
98+
}
99+
100+
if (entryHost.charAt(0) === '.') {
101+
return hostname.endsWith(entryHost);
102+
}
103+
104+
return hostname === entryHost;
105+
});
106+
}

tests/unit/adapters/http.test.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,6 +1738,114 @@ describe('supports http with nodejs', () => {
17381738
}
17391739
});
17401740

1741+
it('should not use proxy for localhost with trailing dot when listed in no_proxy', async () => {
1742+
const originalHttpProxy = process.env.http_proxy;
1743+
const originalHTTPProxy = process.env.HTTP_PROXY;
1744+
const originalNoProxy = process.env.no_proxy;
1745+
const originalNOProxy = process.env.NO_PROXY;
1746+
1747+
let proxyRequests = 0;
1748+
const proxy = await startHTTPServer(
1749+
(_, response) => {
1750+
proxyRequests += 1;
1751+
response.end('proxied');
1752+
},
1753+
{ port: PROXY_PORT }
1754+
);
1755+
1756+
const noProxyValue = 'localhost,127.0.0.1,::1';
1757+
const proxyUrl = `http://localhost:${proxy.address().port}/`;
1758+
process.env.http_proxy = proxyUrl;
1759+
process.env.HTTP_PROXY = proxyUrl;
1760+
process.env.no_proxy = noProxyValue;
1761+
process.env.NO_PROXY = noProxyValue;
1762+
1763+
try {
1764+
await assert.rejects(axios.get('http://localhost.:1/', { timeout: 100 }));
1765+
assert.equal(proxyRequests, 0, 'should not use proxy for localhost with trailing dot');
1766+
} finally {
1767+
await stopHTTPServer(proxy);
1768+
1769+
if (originalHttpProxy === undefined) {
1770+
delete process.env.http_proxy;
1771+
} else {
1772+
process.env.http_proxy = originalHttpProxy;
1773+
}
1774+
1775+
if (originalHTTPProxy === undefined) {
1776+
delete process.env.HTTP_PROXY;
1777+
} else {
1778+
process.env.HTTP_PROXY = originalHTTPProxy;
1779+
}
1780+
1781+
if (originalNoProxy === undefined) {
1782+
delete process.env.no_proxy;
1783+
} else {
1784+
process.env.no_proxy = originalNoProxy;
1785+
}
1786+
1787+
if (originalNOProxy === undefined) {
1788+
delete process.env.NO_PROXY;
1789+
} else {
1790+
process.env.NO_PROXY = originalNOProxy;
1791+
}
1792+
}
1793+
});
1794+
1795+
it('should not use proxy for bracketed IPv6 loopback when listed in no_proxy', async () => {
1796+
const originalHttpProxy = process.env.http_proxy;
1797+
const originalHTTPProxy = process.env.HTTP_PROXY;
1798+
const originalNoProxy = process.env.no_proxy;
1799+
const originalNOProxy = process.env.NO_PROXY;
1800+
1801+
let proxyRequests = 0;
1802+
const proxy = await startHTTPServer(
1803+
(_, response) => {
1804+
proxyRequests += 1;
1805+
response.end('proxied');
1806+
},
1807+
{ port: PROXY_PORT }
1808+
);
1809+
1810+
const noProxyValue = 'localhost,127.0.0.1,::1';
1811+
const proxyUrl = `http://localhost:${proxy.address().port}/`;
1812+
process.env.http_proxy = proxyUrl;
1813+
process.env.HTTP_PROXY = proxyUrl;
1814+
process.env.no_proxy = noProxyValue;
1815+
process.env.NO_PROXY = noProxyValue;
1816+
1817+
try {
1818+
await assert.rejects(axios.get('http://[::1]:1/', { timeout: 100 }));
1819+
assert.equal(proxyRequests, 0, 'should not use proxy for IPv6 loopback');
1820+
} finally {
1821+
await stopHTTPServer(proxy);
1822+
1823+
if (originalHttpProxy === undefined) {
1824+
delete process.env.http_proxy;
1825+
} else {
1826+
process.env.http_proxy = originalHttpProxy;
1827+
}
1828+
1829+
if (originalHTTPProxy === undefined) {
1830+
delete process.env.HTTP_PROXY;
1831+
} else {
1832+
process.env.HTTP_PROXY = originalHTTPProxy;
1833+
}
1834+
1835+
if (originalNoProxy === undefined) {
1836+
delete process.env.no_proxy;
1837+
} else {
1838+
process.env.no_proxy = originalNoProxy;
1839+
}
1840+
1841+
if (originalNOProxy === undefined) {
1842+
delete process.env.NO_PROXY;
1843+
} else {
1844+
process.env.NO_PROXY = originalNOProxy;
1845+
}
1846+
}
1847+
});
1848+
17411849
it('should use proxy for domains not in no_proxy', async () => {
17421850
const originalHttpProxy = process.env.http_proxy;
17431851
const originalHTTPProxy = process.env.HTTP_PROXY;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import shouldBypassProxy from '../../../lib/helpers/shouldBypassProxy.js';
3+
4+
const originalNoProxy = process.env.no_proxy;
5+
const originalNOProxy = process.env.NO_PROXY;
6+
7+
const setNoProxy = (value) => {
8+
process.env.no_proxy = value;
9+
process.env.NO_PROXY = value;
10+
};
11+
12+
afterEach(() => {
13+
if (originalNoProxy === undefined) {
14+
delete process.env.no_proxy;
15+
} else {
16+
process.env.no_proxy = originalNoProxy;
17+
}
18+
19+
if (originalNOProxy === undefined) {
20+
delete process.env.NO_PROXY;
21+
} else {
22+
process.env.NO_PROXY = originalNOProxy;
23+
}
24+
});
25+
26+
describe('helpers::shouldBypassProxy', () => {
27+
it('should bypass proxy for localhost with a trailing dot', () => {
28+
setNoProxy('localhost,127.0.0.1,::1');
29+
30+
expect(shouldBypassProxy('http://localhost.:8080/')).toBe(true);
31+
});
32+
33+
it('should bypass proxy for bracketed ipv6 loopback', () => {
34+
setNoProxy('localhost,127.0.0.1,::1');
35+
36+
expect(shouldBypassProxy('http://[::1]:8080/')).toBe(true);
37+
});
38+
39+
it('should support bracketed ipv6 entries in no_proxy', () => {
40+
setNoProxy('[::1]');
41+
42+
expect(shouldBypassProxy('http://[::1]:8080/')).toBe(true);
43+
});
44+
45+
it('should match wildcard and explicit ports', () => {
46+
setNoProxy('*.example.com,localhost:8080');
47+
48+
expect(shouldBypassProxy('http://api.example.com/')).toBe(true);
49+
expect(shouldBypassProxy('http://localhost:8080/')).toBe(true);
50+
expect(shouldBypassProxy('http://localhost:8081/')).toBe(false);
51+
});
52+
});

0 commit comments

Comments
 (0)