Skip to content

Commit 0b3a3df

Browse files
committed
Fix bug where including auth params in a proxy URL broke all connections
This happened because http-proxy-agent attempts to make a single (non-CONNECT) proxy request, including proxy-auth there, and that resulted in a setHeader() call on the request after the initial request had been created with raw headers (which permanently sets the headers), which threw HTTP_HEADERS_ALREADY_SENT. To fix this, we now drop proxy-agent to implement our own proxy logic, and always use https-proxy-agent for HTTP(S), so we *always* do a CONNECT in these cases. This allows the proxy-agent to set the header for the CONNECT independently of the raw headers set later on. It also lets us implement our own stricter caching for proxy agents, and it's going to let us doing better custom configuration for proxy-agents in other ways too (imminently).
1 parent 8fad51a commit 0b3a3df

File tree

6 files changed

+87
-13
lines changed

6 files changed

+87
-13
lines changed
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Each of these breaks because they use synthetic default imports internally for
2+
// core node packages. Easiest to skip their types entirely:
3+
declare module 'https-proxy-agent';
4+
declare module 'socks-proxy-agent';
5+
declare module 'pac-proxy-agent';

custom-typings/proxy-agent.d.ts

-7
This file was deleted.

package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,6 @@
129129
"form-data-encoder": "^1.7.2",
130130
"formdata-node": "^4.3.2",
131131
"fs-extra": "^8.1.0",
132-
"http-proxy-agent": "^2.0.0",
133-
"https-proxy-agent": "^2.2.1",
134132
"karma": "^6.3.2",
135133
"karma-chai": "^0.1.0",
136134
"karma-chrome-launcher": "^2.2.0",
@@ -168,7 +166,6 @@
168166
"@graphql-tools/schema": "^8.5.0",
169167
"@graphql-tools/utils": "^8.8.0",
170168
"@httptoolkit/httpolyglot": "^2.0.1",
171-
"@httptoolkit/proxy-agent": "^5.0.1-socks-lookup-fix.0",
172169
"@httptoolkit/subscriptions-transport-ws": "^0.11.2",
173170
"@httptoolkit/websocket-stream": "^6.0.1",
174171
"@types/cors": "^2.8.6",
@@ -188,13 +185,17 @@
188185
"graphql-tag": "^2.12.6",
189186
"http-encoding": "^1.5.1",
190187
"http2-wrapper": "2.0.5",
188+
"https-proxy-agent": "^5.0.1",
191189
"isomorphic-ws": "^4.0.1",
192190
"lodash": "^4.16.4",
191+
"lru-cache": "^7.14.0",
193192
"native-duplexpair": "^1.0.0",
194193
"node-forge": "^1.2.1",
194+
"pac-proxy-agent": "^5.0.0",
195195
"parse-multipart-data": "^1.4.0",
196196
"performance-now": "^2.1.0",
197197
"portfinder": "1.0.28",
198+
"socks-proxy-agent": "^7.0.0",
198199
"typed-error": "^3.0.2",
199200
"uuid": "^8.3.2",
200201
"ws": "^8.8.0"

src/rules/http-agents.ts

+51-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import * as _ from 'lodash';
2+
import * as url from 'url';
23
import * as http from 'http';
34
import * as https from 'https';
4-
import ProxyAgent = require('@httptoolkit/proxy-agent');
5+
6+
import * as LRU from 'lru-cache';
7+
8+
import getHttpsProxyAgent = require('https-proxy-agent');
9+
import getPacProxyAgent = require('pac-proxy-agent');
10+
import { SocksProxyAgent } from 'socks-proxy-agent';
11+
const getSocksProxyAgent = (opts: any) => new SocksProxyAgent(opts);
512

613
import { isNode } from "../util/util";
714
import { getProxySetting, matchesNoProxy, ProxySettingSource } from './proxy-config';
@@ -16,6 +23,31 @@ const KeepAliveAgents = isNode
1623
})
1724
} : {};
1825

26+
const ProxyAgentFactoryMap = {
27+
'http:': getHttpsProxyAgent, // HTTPS here really means 'CONNECT-tunnelled' - it can do either
28+
'https:': getHttpsProxyAgent,
29+
30+
'pac+http:': getPacProxyAgent,
31+
'pac+https:': getPacProxyAgent,
32+
33+
'socks:': getSocksProxyAgent,
34+
'socks4:': getSocksProxyAgent,
35+
'socks4a:': getSocksProxyAgent,
36+
'socks5:': getSocksProxyAgent,
37+
'socks5h:': getSocksProxyAgent
38+
} as const;
39+
40+
const proxyAgentCache = new LRU<string, http.Agent>({
41+
max: 20,
42+
43+
ttl: 1000 * 60 * 5, // Drop refs to unused agents after 5 minutes
44+
ttlResolution: 1000 * 60, // Check for expiry once every minute maximum
45+
ttlAutopurge: true, // Actively drop expired agents
46+
updateAgeOnGet: true // Don't drop agents while they're in use
47+
});
48+
49+
const getCacheKey = (options: {}) => JSON.stringify(options);
50+
1951
export async function getAgent({
2052
protocol, hostname, port, tryHttp2, keepAlive, proxySettingSource
2153
}: {
@@ -35,7 +67,24 @@ export async function getAgent({
3567
if (!matchesNoProxy(hostname, port, proxySetting.noProxy)) {
3668
// We notably ignore HTTP/2 upstream in this case: it's complicated to mix that up with proxying
3769
// so for now we ignore it entirely.
38-
return new ProxyAgent(proxySetting.proxyUrl) as http.Agent;
70+
71+
const cacheKey = getCacheKey({
72+
url: proxySetting?.proxyUrl
73+
});
74+
75+
if (!proxyAgentCache.has(cacheKey)) {
76+
const { protocol, auth, hostname, port } = url.parse(proxySetting?.proxyUrl);
77+
const buildProxyAgent = ProxyAgentFactoryMap[protocol as keyof typeof ProxyAgentFactoryMap];
78+
79+
proxyAgentCache.set(cacheKey, buildProxyAgent({
80+
protocol,
81+
auth,
82+
hostname,
83+
port
84+
}));
85+
}
86+
87+
return proxyAgentCache.get(cacheKey);
3988
}
4089
}
4190

test/integration/proxy.spec.ts

+24
Original file line numberDiff line numberDiff line change
@@ -2669,6 +2669,30 @@ nodeOnly(() => {
26692669
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(1);
26702670
});
26712671

2672+
it("should support authenticating to the remote proxy", async () => {
2673+
// Remote server sends fixed response on this one URL:
2674+
await remoteServer.forGet('/test-url').thenReply(200, "Remote server says hi!");
2675+
2676+
// Mockttp forwards requests via our intermediate proxy
2677+
await server.forAnyRequest().thenPassThrough({
2678+
proxyConfig: {
2679+
proxyUrl: intermediateProxy.url
2680+
.replace('://', '://username:password@')
2681+
}
2682+
});
2683+
2684+
const response = await request.get(remoteServer.urlFor("/test-url"));
2685+
2686+
// We get a successful response
2687+
expect(response).to.equal("Remote server says hi!");
2688+
// And it went via the intermediate proxy
2689+
expect((await proxyEndpoint.getSeenRequests()).length).to.equal(1);
2690+
2691+
// N.B: we don't actually check that the auth params are used here, only that the request with
2692+
// them in the URL sends OK. We can't, unfortunately, since they only exist in the CONNECT
2693+
// and that's always unwrapped and never exposed. Visible in Wireshark though.
2694+
});
2695+
26722696
it("should skip the proxy if the target is in the no-proxy list", async () => {
26732697
// Remote server sends fixed response on this one URL:
26742698
await remoteServer.forGet('/test-url').thenReply(200, "Remote server says hi!");

tsconfig.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
"scripthost"
1313
],
1414
"paths": {
15-
"@httptoolkit/proxy-agent": ["./custom-typings/proxy-agent.d.ts"]
15+
"https-proxy-agent": ["./custom-typings/proxy-agent-modules.d.ts"],
16+
"socks-proxy-agent": ["./custom-typings/proxy-agent-modules.d.ts"],
17+
"pac-proxy-agent": ["./custom-typings/proxy-agent-modules.d.ts"]
1618
}
1719
},
1820
"compileOnSave": true,

0 commit comments

Comments
 (0)