Skip to content

Commit 09d37d4

Browse files
committed
fixup! http: match subdomains for plain NO_PROXY entries
Keep the change scoped to plain domain entries. Preserve the existing leading-dot and wildcard behavior, while preventing the new suffix match from applying to empty entries or IP literals. Signed-off-by: Nikita Snetkov <lukyanish@gmail.com>
1 parent 15fa397 commit 09d37d4

4 files changed

Lines changed: 31 additions & 104 deletions

File tree

‎doc/api/http.md‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4562,8 +4562,7 @@ added:
45624562
changes:
45634563
- version: REPLACEME
45644564
pr-url: https://github.com/nodejs/node/pull/65617
4565-
description: Plain `NO_PROXY` entries now match subdomains, IP entries
4566-
are matched exactly, and empty entries are ignored.
4565+
description: Plain domain entries in `NO_PROXY` now match subdomains.
45674566
-->
45684567
45694568
> Stability: 1.1 - Active development
@@ -4625,12 +4624,9 @@ The `NO_PROXY` environment variable supports several formats:
46254624
* `*.example.com` - Wildcard domain match
46264625
* `192.168.1.100` - Exact IP address match
46274626
* `192.168.1.1-192.168.1.100` - IP address range
4628-
* `example.com:8080` - Hostname with specific port (exact host match, no
4629-
subdomains)
4627+
* `example.com:8080` - Hostname with specific port
46304628
4631-
Multiple entries should be separated by commas; empty entries are ignored.
4632-
A plain or leading-dot IP entry matches only that exact IP, and no domain
4633-
entry can bypass a host that is an IP address literal.
4629+
Multiple entries should be separated by commas.
46344630
46354631
### Example
46364632

‎lib/internal/http.js‎

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,7 @@ class ProxyConfig {
132132
this.auth = `Basic ${Buffer.from(auth).toString('base64')}`;
133133
}
134134
if (noProxyList) {
135-
// Drop empty entries (e.g. from trailing commas) - an empty string
136-
// would suffix-match any host ending with a dot.
137-
this.bypassList = noProxyList.split(',')
138-
.map((entry) => entry.trim().toLowerCase())
139-
.filter((entry) => entry !== '');
135+
this.bypassList = noProxyList.split(',').map((entry) => entry.trim().toLowerCase());
140136
} else {
141137
this.bypassList = []; // No bypass list provided.
142138
}
@@ -168,26 +164,22 @@ class ProxyConfig {
168164
if (entry === '*') return false; // * bypasses all hosts.
169165
if (entry === host || entry === hostWithPort) return false; // Matching host and host:port
170166

171-
// Strip a leading "." if present, then match as a suffix with a
172-
// label boundary. Suffix matching only applies between domains: IP
173-
// literals are matched exactly (or by range, below), as documented.
174-
// "*.example.com" is handled below (subdomains only).
175-
if (!entry.startsWith('*.')) {
176-
const suffix = entry[0] === '.' ? entry.substring(1) : entry;
177-
if (host === suffix ||
178-
(suffix !== '' && !hostIsIP && !isIP(suffix) &&
179-
host.endsWith(suffix) && host[host.length - suffix.length - 1] === '.')) {
180-
return false;
181-
}
167+
// Plain domain entries also match subdomains at a label boundary.
168+
// Keep IP hosts and entries out of this new suffix match.
169+
if (entry !== '' && entry[0] !== '.' && !entry.startsWith('*.') &&
170+
!hostIsIP && isIP(entry) === 0 && host.endsWith(`.${entry}`)) {
171+
return false;
182172
}
183173

184-
// Handle wildcards like *.example.com. IP hosts never match domain
185-
// rules, and a bare "*." must not match every host ending with a dot.
186-
if (!hostIsIP && entry.length > 2 && entry.startsWith('*.') &&
187-
host.endsWith(entry.substring(1))) {
188-
return false;
174+
// Follow curl's behavior: strip leading dot before matching suffixes.
175+
if (entry[0] === '.') {
176+
const suffix = entry.substring(1);
177+
if (host === suffix || (host.endsWith(suffix) && host[host.length - suffix.length - 1] === '.')) return false;
189178
}
190179

180+
// Handle wildcards like *.example.com
181+
if (entry.startsWith('*.') && host.endsWith(entry.substring(1))) return false;
182+
191183
// Handle IP ranges (simple format like 192.168.1.0-192.168.1.255)
192184
// TODO(joyeecheung): support IPv6.
193185
if (entry.includes('-') && isIPv4(host)) {

‎test/client-proxy/test-http-proxy-request-no-proxy-domain.mjs‎

Lines changed: 10 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { runProxiedRequest } from '../common/proxy-server.js';
1010
const server = http.createServer(common.mustCall((req, res) => {
1111
res.writeHead(200, { 'Content-Type': 'text/plain' });
1212
res.end('Hello World\n');
13-
}, 11));
13+
}, 8));
1414
server.on('error', common.mustNotCall((err) => { console.error('Server error', err); }));
1515
server.listen(0, '127.0.0.1');
1616
await once(server, 'listening');
@@ -98,72 +98,32 @@ await once(proxy, 'listening');
9898
}
9999

100100
{
101-
// Test NO_PROXY with a trailing comma still matching the real entries.
102-
const { code, signal, stderr, stdout } = await runProxiedRequest({
103-
NODE_USE_ENV_PROXY: 1,
104-
REQUEST_URL: `http://test.example.com:${server.address().port}/test`,
105-
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
106-
RESOLVE_TO_LOCALHOST: 'test.example.com',
107-
NO_PROXY: 'example.com,',
108-
});
109-
110-
// The request should succeed and bypass proxy.
111-
assert.match(stdout, /Status Code: 200/);
112-
assert.match(stdout, /Hello World/);
113-
assert.match(stdout, /Resolving lookup for test\.example\.com/);
114-
assert.strictEqual(stderr.trim(), '');
115-
assert.strictEqual(code, 0);
116-
assert.strictEqual(signal, null);
117-
}
118-
119-
{
120-
// Test NO_PROXY with an empty entry (trailing comma) should NOT match
121-
// hostnames ending with a dot.
122-
const { code, signal, stderr, stdout } = await runProxiedRequest({
123-
NODE_USE_ENV_PROXY: 1,
124-
REQUEST_URL: `http://evil.com.:${server.address().port}/test`,
125-
HTTP_PROXY: `http://localhost:${server.address().port}`,
126-
RESOLVE_TO_LOCALHOST: 'evil.com.',
127-
NO_PROXY: 'example.com,',
128-
});
129-
130-
// The request should go through the proxy (not bypass it): the empty
131-
// entry must not match, and evil.com. does not match example.com.
132-
assert.match(stdout, /Status Code: 200/);
133-
assert.doesNotMatch(stdout, /Resolving lookup for evil\.com/);
134-
assert.strictEqual(stderr.trim(), '');
135-
assert.strictEqual(code, 0);
136-
assert.strictEqual(signal, null);
137-
}
138-
139-
{
140-
// Test NO_PROXY with a lone "." entry should NOT match hostnames ending
141-
// with a dot.
101+
// Test NO_PROXY with a plain domain should NOT match partial domain names.
142102
const { code, signal, stderr, stdout } = await runProxiedRequest({
143103
NODE_USE_ENV_PROXY: 1,
144-
REQUEST_URL: `http://evil.com.:${server.address().port}/test`,
104+
REQUEST_URL: `http://badexample.com:${server.address().port}/test`,
145105
HTTP_PROXY: `http://localhost:${server.address().port}`,
146-
RESOLVE_TO_LOCALHOST: 'evil.com.',
147-
NO_PROXY: '.',
106+
RESOLVE_TO_LOCALHOST: 'badexample.com',
107+
NO_PROXY: 'example.com',
148108
});
149109

150-
// The request should go through the proxy (not bypass it).
110+
// The request should go through the proxy (not bypass it),
111+
// because badexample.com is not a subdomain of example.com.
151112
assert.match(stdout, /Status Code: 200/);
152-
assert.doesNotMatch(stdout, /Resolving lookup for evil\.com/);
113+
assert.doesNotMatch(stdout, /Resolving lookup for badexample\.com/);
153114
assert.strictEqual(stderr.trim(), '');
154115
assert.strictEqual(code, 0);
155116
assert.strictEqual(signal, null);
156117
}
157118

158119
{
159-
// Test NO_PROXY with a bare "*." entry should NOT match hostnames ending
160-
// with a dot.
120+
// Test that an empty plain entry does not match a hostname ending in a dot.
161121
const { code, signal, stderr, stdout } = await runProxiedRequest({
162122
NODE_USE_ENV_PROXY: 1,
163123
REQUEST_URL: `http://evil.com.:${server.address().port}/test`,
164124
HTTP_PROXY: `http://localhost:${server.address().port}`,
165125
RESOLVE_TO_LOCALHOST: 'evil.com.',
166-
NO_PROXY: '*.',
126+
NO_PROXY: 'example.com,',
167127
});
168128

169129
// The request should go through the proxy (not bypass it).
@@ -174,25 +134,6 @@ await once(proxy, 'listening');
174134
assert.strictEqual(signal, null);
175135
}
176136

177-
{
178-
// Test NO_PROXY with a plain domain should NOT match partial domain names.
179-
const { code, signal, stderr, stdout } = await runProxiedRequest({
180-
NODE_USE_ENV_PROXY: 1,
181-
REQUEST_URL: `http://badexample.com:${server.address().port}/test`,
182-
HTTP_PROXY: `http://localhost:${server.address().port}`,
183-
RESOLVE_TO_LOCALHOST: 'badexample.com',
184-
NO_PROXY: 'example.com',
185-
});
186-
187-
// The request should go through the proxy (not bypass it),
188-
// because badexample.com is not a subdomain of example.com.
189-
assert.match(stdout, /Status Code: 200/);
190-
assert.doesNotMatch(stdout, /Resolving lookup for badexample\.com/);
191-
assert.strictEqual(stderr.trim(), '');
192-
assert.strictEqual(code, 0);
193-
assert.strictEqual(signal, null);
194-
}
195-
196137
// Test NO_PROXY with leading-dot entry should NOT match partial domain names.
197138
// Regression test: .example.com must not match notexample.com or badexample.com.
198139
{

‎test/client-proxy/test-http-proxy-request-no-proxy-ip-suffix.mjs‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
// This tests that NO_PROXY IP entries and IP hosts are matched exactly,
2-
// never as domain suffixes.
1+
// This tests that plain NO_PROXY entries do not suffix-match IP addresses.
32

43
import * as common from '../common/index.mjs';
54
import assert from 'node:assert';
@@ -16,18 +15,17 @@ await once(server, 'listening');
1615
const proxy = http.createServer(common.mustCall((req, res) => {
1716
res.writeHead(200, { 'Content-Type': 'text/plain' });
1817
res.end('proxied');
19-
}, 3));
18+
}));
2019
proxy.listen(0);
2120
await once(proxy, 'listening');
2221

23-
// An IP host must not be bypassed by entries that only match it as a
24-
// string suffix: plain, leading-dot, or wildcard.
25-
for (const noProxy of ['0.1', '.0.1', '*.0.1']) {
22+
{
23+
// A plain entry must not bypass an IP host by matching a string suffix.
2624
const { code, signal, stderr, stdout } = await runProxiedRequest({
2725
NODE_USE_ENV_PROXY: 1,
2826
REQUEST_URL: `http://127.0.0.1:${server.address().port}/test`,
2927
HTTP_PROXY: `http://localhost:${proxy.address().port}`,
30-
NO_PROXY: noProxy,
28+
NO_PROXY: '0.1',
3129
});
3230

3331
// The request should go through the proxy (not bypass it).

0 commit comments

Comments
 (0)