Skip to content

Commit f521a4b

Browse files
committed
Add support for no-proxy passthrough configurations
1 parent 4939072 commit f521a4b

File tree

4 files changed

+298
-27
lines changed

4 files changed

+298
-27
lines changed

src/rules/requests/request-handlers.ts

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import {
2929
h2HeadersToH1,
3030
isAbsoluteUrl,
3131
cleanUpHeaders,
32-
isMockttpBody
32+
isMockttpBody,
33+
matchesNoProxy
3334
} from '../../util/request-utils';
3435
import { streamToBuffer, asBuffer } from '../../util/buffer-utils';
3536
import { isLocalPortActive, isSocketLoop } from '../../util/socket-util';
@@ -423,6 +424,26 @@ export interface ProxyConfig {
423424
* For example: http://..., socks5://..., pac+http://...
424425
*/
425426
proxyUrl: string;
427+
428+
/**
429+
* A list of no-proxy values, matching URLs which should not be proxied.
430+
*
431+
* This is a common proxy feature, but unfortunately isn't standardized. See
432+
* https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/ for some
433+
* background. This implementation is intended to match Curl's behaviour, and
434+
* any differences are a bug.
435+
*
436+
* The currently supported formats are:
437+
* - example.com (matches domain and all subdomains)
438+
* - example.com:443 (matches domain and all subdomains, but only on that port)
439+
* - 10.0.0.1 (matches IP, but only when used directly - does not resolve domains)
440+
*
441+
* Some other formats (e.g. leading dots or *.) will work, but the leading
442+
* characters are ignored. More formats may be added in future, e.g. CIDR ranges.
443+
* To maximize compatibility with values used elsewhere, unrecognized formats
444+
* will generally be ignored, but may match in unexpected ways.
445+
*/
446+
noProxy?: string[];
426447
}
427448

428449
export interface PassThroughLookupOptions {
@@ -480,7 +501,7 @@ export interface PassThroughHandlerOptions {
480501
/**
481502
* Upstream proxy configuration: pass through requests via this proxy
482503
*/
483-
proxyConfig?: ProxyConfig
504+
proxyConfig?: ProxyConfig;
484505

485506
/**
486507
* Custom DNS options, to allow configuration of the resolver used
@@ -872,32 +893,38 @@ const KeepAliveAgents = isNode
872893
})
873894
} : {};
874895

875-
function getAgent(options: {
896+
function getAgent({
897+
protocol, hostname, port, tryHttp2, keepAlive, proxyConfig
898+
}: {
876899
protocol: 'http:' | 'https:' | undefined,
900+
hostname: string,
901+
port: number,
877902
tryHttp2: boolean,
878903
keepAlive: boolean
879904
proxyConfig: ProxyConfig | undefined,
880905
}): {} | undefined {
881-
if (options.proxyConfig && options.proxyConfig.proxyUrl) {
906+
if (proxyConfig && proxyConfig.proxyUrl) {
882907
// If there's a (non-empty) proxy configured, use it. We require non-empty because empty strings
883908
// will fall back to detecting from the environment, which is likely to behave unexpectedly.
884909

885-
// We notably ignore HTTP/2 upstream in this case: it's complicated to mix that up with proxying
886-
// so for now we ignore it entirely.
887-
return new ProxyAgent(options.proxyConfig.proxyUrl);
888-
} else {
889-
if (options.tryHttp2 && options.protocol === 'https:') {
890-
// H2 wrapper takes multiple agents, uses the appropriate one for the detected protocol.
891-
// We notably never use H2 upstream for plaintext, it's rare and we can't use ALPN to detect it.
892-
return { https: KeepAliveAgents['https:'], http2: undefined };
893-
} else if (options.keepAlive) {
894-
// HTTP/1.1 or HTTP/1 with explicit keep-alive
895-
return KeepAliveAgents[options.protocol || 'http:']
896-
} else {
897-
// HTTP/1 without KA - just send the request with no agent
898-
return undefined;
910+
if (!matchesNoProxy(hostname, port, proxyConfig.noProxy)) {
911+
// We notably ignore HTTP/2 upstream in this case: it's complicated to mix that up with proxying
912+
// so for now we ignore it entirely.
913+
return new ProxyAgent(proxyConfig.proxyUrl);
899914
}
900915
}
916+
917+
if (tryHttp2 && protocol === 'https:') {
918+
// H2 wrapper takes multiple agents, uses the appropriate one for the detected protocol.
919+
// We notably never use H2 upstream for plaintext, it's rare and we can't use ALPN to detect it.
920+
return { https: KeepAliveAgents['https:'], http2: undefined };
921+
} else if (keepAlive) {
922+
// HTTP/1.1 or HTTP/1 with explicit keep-alive
923+
return KeepAliveAgents[protocol || 'http:']
924+
} else {
925+
// HTTP/1 without KA - just send the request with no agent
926+
return undefined;
927+
}
901928
}
902929

903930
export class PassThroughHandler extends Serializable implements RequestHandler {
@@ -1216,22 +1243,25 @@ export class PassThroughHandler extends Serializable implements RequestHandler {
12161243
: http.request
12171244
) as typeof https.request;
12181245

1246+
const effectivePort = !!port
1247+
? parseInt(port, 10)
1248+
: (protocol === 'https:' ? 443 : 80);
1249+
12191250
let family: undefined | 4 | 6;
12201251
if (hostname === 'localhost') {
12211252
// Annoying special case: some localhost servers listen only on either ipv4 or ipv6.
12221253
// Very specific situation, but a very common one for development use.
12231254
// We need to work out which one family is, as Node sometimes makes bad choices.
1224-
const portToTest = !!port
1225-
? parseInt(port, 10)
1226-
: (protocol === 'https:' ? 443 : 80);
12271255

1228-
if (await isLocalPortActive('::1', portToTest)) family = 6;
1256+
if (await isLocalPortActive('::1', effectivePort)) family = 6;
12291257
else family = 4;
12301258
}
12311259

12321260
// Mirror the keep-alive-ness of the incoming request in our outgoing request
12331261
const agent = getAgent({
12341262
protocol: (protocol || undefined) as 'http:' | 'https:' | undefined,
1263+
hostname: hostname!,
1264+
port: effectivePort,
12351265
tryHttp2: shouldTryH2Upstream,
12361266
keepAlive: shouldKeepAlive(clientReq),
12371267
proxyConfig: this.proxyConfig

src/util/request-utils.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,36 @@ export const shouldKeepAlive = (req: OngoingRequest): boolean =>
6565
req.headers['connection'] !== 'close' &&
6666
req.headers['proxy-connection'] !== 'close';
6767

68+
export const matchesNoProxy = (hostname: string, portNum: number, noProxyValues: string[] | undefined) => {
69+
if (!noProxyValues || noProxyValues.length === 0) return false; // Skip everything in the common case.
70+
71+
const port = portNum.toString();
72+
const hostParts = hostname.split('.').reverse();
73+
74+
return noProxyValues.some((noProxy) => {
75+
const [noProxyHost, noProxyPort] = noProxy.split(':') as [string, string | undefined];
76+
77+
let noProxyParts = noProxyHost.split('.').reverse();
78+
const lastPart = noProxyParts[noProxyParts.length - 1];
79+
if (lastPart === '' || lastPart === '*') {
80+
noProxyParts = noProxyParts.slice(0, -1);
81+
}
82+
83+
if (noProxyPort && port !== noProxyPort) return false;
84+
85+
for (let i = 0; i < noProxyParts.length; i++) {
86+
let noProxyPart = noProxyParts[i];
87+
let hostPart = hostParts[i];
88+
89+
if (hostPart === undefined) return false; // No-proxy is longer than hostname
90+
if (noProxyPart !== hostPart) return false; // Mismatch
91+
}
92+
93+
// If we run out of no-proxy parts with no mismatch then we've matched
94+
return true;
95+
});
96+
}
97+
6898
export const setHeaders = (response: http.ServerResponse, headers: Headers) => {
6999
Object.keys(headers).forEach((header) => {
70100
let value = headers[header];

test/integration/proxy.spec.ts

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,7 +1545,7 @@ nodeOnly(() => {
15451545
describe("when configured to transform requests automatically", () => {
15461546

15471547
beforeEach(async () => {
1548-
server = getLocal({ debug: true });
1548+
server = getLocal();
15491549
await server.start();
15501550
process.env = _.merge({}, process.env, server.proxyEnv);
15511551

@@ -1868,7 +1868,7 @@ nodeOnly(() => {
18681868
describe("when configured to transform responses automatically", () => {
18691869

18701870
beforeEach(async () => {
1871-
server = getLocal({ debug: true });
1871+
server = getLocal();
18721872
await server.start();
18731873
process.env = _.merge({}, process.env, server.proxyEnv);
18741874

@@ -2166,11 +2166,11 @@ nodeOnly(() => {
21662166

21672167
describe("when configured to use an upstream proxy", () => {
21682168

2169-
const intermediateProxy = getLocal({ debug: true });
2169+
const intermediateProxy = getLocal();
21702170
let proxyEndpoint: MockedEndpoint;
21712171

21722172
beforeEach(async () => {
2173-
server = getLocal({ debug: true });
2173+
server = getLocal();
21742174
await server.start();
21752175

21762176
await intermediateProxy.start();
@@ -2200,6 +2200,143 @@ nodeOnly(() => {
22002200
// And it went via the intermediate proxy
22012201
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(1);
22022202
});
2203+
2204+
it("should skip the proxy if the target is in the no-proxy list", async () => {
2205+
// Remote server sends fixed response on this one URL:
2206+
await remoteServer.get('/test-url').thenReply(200, "Remote server says hi!");
2207+
2208+
// Mockttp forwards requests via our intermediate proxy
2209+
await server.anyRequest().thenPassThrough({
2210+
proxyConfig: {
2211+
proxyUrl: intermediateProxy.url,
2212+
noProxy: ['localhost']
2213+
}
2214+
});
2215+
2216+
const response = await request.get(remoteServer.urlFor("/test-url"));
2217+
2218+
// We get a successful response
2219+
expect(response).to.equal("Remote server says hi!");
2220+
2221+
// And it didn't use the proxy
2222+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(0);
2223+
});
2224+
2225+
it("should skip the proxy if the target is in the no-proxy list with a matching port", async () => {
2226+
// Remote server sends fixed response on this one URL:
2227+
await remoteServer.get('/test-url').thenReply(200, "Remote server says hi!");
2228+
2229+
// Mockttp forwards requests via our intermediate proxy
2230+
await server.anyRequest().thenPassThrough({
2231+
proxyConfig: {
2232+
proxyUrl: intermediateProxy.url,
2233+
noProxy: [`localhost:${remoteServer.port}`]
2234+
}
2235+
});
2236+
2237+
const response = await request.get(remoteServer.urlFor("/test-url"));
2238+
2239+
// We get a successful response
2240+
expect(response).to.equal("Remote server says hi!");
2241+
2242+
// And it didn't use the proxy
2243+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(0);
2244+
});
2245+
2246+
it("should skip the proxy if the target's implicit port is in the no-proxy list", async () => {
2247+
// Mockttp forwards requests via our intermediate proxy
2248+
await server.anyRequest().thenPassThrough({
2249+
proxyConfig: {
2250+
proxyUrl: intermediateProxy.url,
2251+
noProxy: ['example.com:80']
2252+
}
2253+
});
2254+
2255+
await request.get('http://example.com/').catch(() => {});
2256+
2257+
// And it didn't use the proxy
2258+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(0);
2259+
});
2260+
2261+
it("should skip the proxy if a suffix of the target is in the no-proxy list", async () => {
2262+
// Remote server sends fixed response on this one URL:
2263+
await remoteServer.get('/test-url').thenReply(200, "Remote server says hi!");
2264+
2265+
// Mockttp forwards requests via our intermediate proxy
2266+
await server.anyRequest().thenPassThrough({
2267+
proxyConfig: {
2268+
proxyUrl: intermediateProxy.url,
2269+
noProxy: ['localhost']
2270+
}
2271+
});
2272+
2273+
const response = await request.get(
2274+
`http://test-subdomain.localhost:${remoteServer.port}/test-url`
2275+
);
2276+
2277+
// We get a successful response
2278+
expect(response).to.equal("Remote server says hi!");
2279+
2280+
// And it didn't use the proxy
2281+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(0);
2282+
});
2283+
2284+
it("should not skip the proxy if an unrelated URL is in the no-proxy list", async () => {
2285+
// Remote server sends fixed response on this one URL:
2286+
await remoteServer.get('/test-url').thenReply(200, "Remote server says hi!");
2287+
2288+
// Mockttp forwards requests via our intermediate proxy
2289+
await server.anyRequest().thenPassThrough({
2290+
proxyConfig: {
2291+
proxyUrl: intermediateProxy.url,
2292+
noProxy: ['example.com']
2293+
}
2294+
});
2295+
2296+
const response = await request.get(remoteServer.urlFor("/test-url"));
2297+
2298+
// We get a successful response
2299+
expect(response).to.equal("Remote server says hi!");
2300+
2301+
// And it went via the intermediate proxy
2302+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(1);
2303+
});
2304+
2305+
it("should not skip the proxy if the target's port is not in the no-proxy list", async () => {
2306+
// Remote server sends fixed response on this one URL:
2307+
await remoteServer.get('/test-url').thenReply(200, "Remote server says hi!");
2308+
2309+
// Mockttp forwards requests via our intermediate proxy
2310+
await server.anyRequest().thenPassThrough({
2311+
proxyConfig: {
2312+
proxyUrl: intermediateProxy.url,
2313+
noProxy: ['localhost:1234']
2314+
}
2315+
});
2316+
2317+
const response = await request.get(remoteServer.urlFor("/test-url"));
2318+
2319+
// We get a successful response
2320+
expect(response).to.equal("Remote server says hi!");
2321+
2322+
// And it went via the intermediate proxy
2323+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(1);
2324+
});
2325+
2326+
it("should not skip the proxy if the target's implicit port is not in the no-proxy list", async () => {
2327+
// Mockttp forwards requests via our intermediate proxy
2328+
await server.anyRequest().thenPassThrough({
2329+
proxyConfig: {
2330+
proxyUrl: intermediateProxy.url,
2331+
noProxy: ['example.com:443']
2332+
}
2333+
});
2334+
2335+
await request.get('http://example.com/').catch(() => {});
2336+
2337+
// And it didn't use the proxy
2338+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(1);
2339+
});
22032340
});
22042341

22052342
describe("when configured with custom DNS options", function () {

0 commit comments

Comments
 (0)