Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/mitm/lib/MitmRequestAgent.ts
1030 views
1
import MitmSocket from '@secret-agent/mitm-socket';
2
import * as http2 from 'http2';
3
import { ClientHttp2Session, Http2ServerRequest } from 'http2';
4
import Log from '@secret-agent/commons/Logger';
5
import * as https from 'https';
6
import * as http from 'http';
7
import MitmSocketSession from '@secret-agent/mitm-socket/lib/MitmSocketSession';
8
import IResourceHeaders from '@secret-agent/interfaces/IResourceHeaders';
9
import ITcpSettings from '@secret-agent/interfaces/ITcpSettings';
10
import ITlsSettings from '@secret-agent/interfaces/ITlsSettings';
11
import Resolvable from '@secret-agent/commons/Resolvable';
12
import IHttp2ConnectSettings from '@secret-agent/interfaces/IHttp2ConnectSettings';
13
import IHttpSocketConnectOptions from '@secret-agent/interfaces/IHttpSocketConnectOptions';
14
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
15
import IMitmRequestContext from '../interfaces/IMitmRequestContext';
16
import MitmRequestContext from './MitmRequestContext';
17
import RequestSession from '../handlers/RequestSession';
18
import HeadersHandler from '../handlers/HeadersHandler';
19
import ResourceState from '../interfaces/ResourceState';
20
import SocketPool from './SocketPool';
21
import Http2PushPromiseHandler from '../handlers/Http2PushPromiseHandler';
22
import Http2SessionBinding from './Http2SessionBinding';
23
24
const { log } = Log(module);
25
26
// TODO: this is off by default because golang 1.14 has an issue verifying certain certificate authorities:
27
// https://github.com/golang/go/issues/24652
28
// https://github.com/golang/go/issues/38365
29
const allowUnverifiedCertificates = Boolean(JSON.parse(process.env.MITM_ALLOW_INSECURE ?? 'true'));
30
31
export default class MitmRequestAgent {
32
public static defaultMaxConnectionsPerOrigin = 6;
33
public socketSession: MitmSocketSession;
34
private session: RequestSession;
35
private readonly maxConnectionsPerOrigin: number;
36
private readonly eventSubscriber = new EventSubscriber();
37
38
private readonly socketPoolByOrigin = new Map<string, SocketPool>();
39
private readonly socketPoolByResolvedHost = new Map<string, SocketPool>();
40
41
constructor(session: RequestSession) {
42
this.session = session;
43
44
const tcpSettings: ITcpSettings = {};
45
const tlsSettings: ITlsSettings = {};
46
session.plugins.onTcpConfiguration(tcpSettings);
47
session.plugins.onTlsConfiguration(tlsSettings);
48
49
this.socketSession = new MitmSocketSession(session.sessionId, {
50
rejectUnauthorized: allowUnverifiedCertificates === false,
51
clientHelloId: tlsSettings?.tlsClientHelloId,
52
tcpTtl: tcpSettings?.tcpTtl,
53
tcpWindowSize: tcpSettings?.tcpWindowSize,
54
});
55
this.maxConnectionsPerOrigin =
56
tlsSettings?.socketsPerOrigin ?? MitmRequestAgent.defaultMaxConnectionsPerOrigin;
57
}
58
59
public async request(
60
ctx: IMitmRequestContext,
61
): Promise<http2.ClientHttp2Stream | http.ClientRequest> {
62
const url = ctx.url;
63
64
const requestSettings: https.RequestOptions = {
65
method: ctx.method,
66
path: url.pathname + url.search,
67
host: url.hostname,
68
port: url.port || (ctx.isSSL ? 443 : 80),
69
headers: ctx.requestHeaders,
70
rejectUnauthorized: allowUnverifiedCertificates === false,
71
// @ts-ignore
72
insecureHTTPParser: true, // if we don't include this setting, invalid characters in http requests will blow up responses
73
};
74
75
await this.assignSocket(ctx, requestSettings as any);
76
77
ctx.cacheHandler.onRequest();
78
79
ctx.setState(ResourceState.BeforeSendRequest);
80
81
if (ctx.isServerHttp2) {
82
// NOTE: must come after connect to know if http2
83
await ctx.requestSession.plugins.beforeHttpRequest(ctx);
84
HeadersHandler.prepareRequestHeadersForHttp2(ctx);
85
return this.http2Request(ctx);
86
}
87
88
if (!ctx.requestHeaders.host && !ctx.requestHeaders.Host) {
89
ctx.requestHeaders.Host = ctx.url.host;
90
}
91
HeadersHandler.cleanProxyHeaders(ctx);
92
await ctx.requestSession.plugins.beforeHttpRequest(ctx);
93
94
requestSettings.headers = ctx.requestHeaders;
95
return this.http1Request(ctx, requestSettings);
96
}
97
98
public freeSocket(ctx: IMitmRequestContext): void {
99
if (ctx.isUpgrade || ctx.isServerHttp2 || this.session.isClosing) {
100
return;
101
}
102
const headers = ctx.responseOriginalHeaders;
103
let isCloseRequested = false;
104
105
if (headers) {
106
if (headers.Connection === 'close' || headers.connection === 'close') {
107
isCloseRequested = true;
108
}
109
}
110
const socket = ctx.proxyToServerMitmSocket;
111
112
if (!socket.isReusable() || isCloseRequested) {
113
return socket.close();
114
}
115
116
socket.isReused = true;
117
118
const pool = this.getSocketPoolByOrigin(ctx.url.origin);
119
pool?.freeSocket(ctx.proxyToServerMitmSocket);
120
}
121
122
public close(): void {
123
try {
124
this.socketSession.close();
125
this.socketSession = null;
126
} catch (err) {
127
// don't need to log closing sessions
128
}
129
for (const pool of this.socketPoolByOrigin.values()) {
130
pool.close();
131
}
132
this.socketPoolByOrigin.clear();
133
this.socketPoolByResolvedHost.clear();
134
this.eventSubscriber.close();
135
this.session = null;
136
}
137
138
public async isHostAlpnH2(hostname: string, port: string): Promise<boolean> {
139
const pool = this.getSocketPoolByOrigin(`${hostname}:${port}`);
140
141
const options = { host: hostname, port, isSsl: true, keepAlive: true, servername: hostname };
142
return await pool.isHttp2(false, () => this.createSocketConnection(options));
143
}
144
145
public async createSocketConnection(options: IHttpSocketConnectOptions): Promise<MitmSocket> {
146
const session = this.session;
147
148
const dnsLookupTime = new Date();
149
const ipIfNeeded = await session.lookupDns(options.host);
150
151
const mitmSocket = new MitmSocket(session.sessionId, {
152
host: ipIfNeeded || options.host,
153
port: String(options.port),
154
isSsl: options.isSsl,
155
servername: options.servername || options.host,
156
keepAlive: options.keepAlive,
157
isWebsocket: options.isWebsocket,
158
keylogPath: process.env.SSLKEYLOGFILE,
159
});
160
mitmSocket.dnsResolvedIp = ipIfNeeded;
161
mitmSocket.dnsLookupTime = dnsLookupTime;
162
this.eventSubscriber.once(mitmSocket, 'connect', () =>
163
session.emit('socket-connect', { socket: mitmSocket }),
164
);
165
166
if (session.upstreamProxyUrl) {
167
mitmSocket.setProxyUrl(session.upstreamProxyUrl);
168
}
169
170
await mitmSocket.connect(this.socketSession, 10e3);
171
172
if (options.isWebsocket) {
173
mitmSocket.socket.setNoDelay(true);
174
mitmSocket.socket.setTimeout(0);
175
}
176
return mitmSocket;
177
}
178
179
private async assignSocket(
180
ctx: IMitmRequestContext,
181
options: IHttpSocketConnectOptions & { headers: IResourceHeaders },
182
): Promise<MitmSocket> {
183
ctx.setState(ResourceState.GetSocket);
184
const pool = this.getSocketPoolByOrigin(ctx.url.origin);
185
186
options.isSsl = ctx.isSSL;
187
options.keepAlive = !((options.headers.connection ??
188
options.headers.Connection) as string)?.match(/close/i);
189
options.isWebsocket = ctx.isUpgrade;
190
191
const mitmSocket = await pool.getSocket(options.isWebsocket, () =>
192
this.createSocketConnection(options),
193
);
194
MitmRequestContext.assignMitmSocket(ctx, mitmSocket);
195
return mitmSocket;
196
}
197
198
private getSocketPoolByOrigin(origin: string): SocketPool {
199
let lookup = origin.split('://').pop();
200
if (!lookup.includes(':') && origin.includes('://')) {
201
const isSecure = origin.startsWith('wss://') || origin.startsWith('https://');
202
if (isSecure) lookup += ':443';
203
else lookup += ':80';
204
}
205
if (!this.socketPoolByOrigin.has(lookup)) {
206
this.socketPoolByOrigin.set(
207
lookup,
208
new SocketPool(lookup, this.maxConnectionsPerOrigin, this.session),
209
);
210
}
211
212
return this.socketPoolByOrigin.get(lookup);
213
}
214
215
private async http1Request(
216
ctx: IMitmRequestContext,
217
requestSettings: http.RequestOptions,
218
): Promise<http.ClientRequest> {
219
const httpModule = ctx.isSSL ? https : http;
220
ctx.setState(ResourceState.CreateProxyToServerRequest);
221
222
let didHaveFlushErrors = false;
223
224
ctx.proxyToServerMitmSocket.receivedEOF = false;
225
const request = httpModule.request({
226
...requestSettings,
227
createConnection: () => ctx.proxyToServerMitmSocket.socket,
228
agent: null,
229
});
230
231
function initError(error): void {
232
if (error.code === 'ECONNRESET') {
233
didHaveFlushErrors = true;
234
return;
235
}
236
log.info(`MitmHttpRequest.Http1SendRequestError`, {
237
sessionId: ctx.requestSession.sessionId,
238
request: requestSettings,
239
error,
240
});
241
}
242
243
request.once('error', initError);
244
245
let callbackArgs: any[];
246
request.once('response', (...args: any[]) => {
247
callbackArgs = args;
248
});
249
request.once('upgrade', (...args: any[]) => {
250
callbackArgs = args;
251
});
252
253
// we have to rebroadcast because this function is async, so the handlers can register late
254
const rebroadcastMissedEvent = (
255
event: string,
256
handler: (...args: any[]) => void,
257
): http.ClientRequest => {
258
if ((event === 'response' || event === 'upgrade') && callbackArgs) {
259
handler(...callbackArgs);
260
callbackArgs = null;
261
}
262
// hand off to another fn
263
if (event === 'error') request.off('error', initError);
264
return request;
265
};
266
const originalOn = request.on.bind(request);
267
const originalOnce = request.once.bind(request);
268
request.on = function onOverride(event, handler): http.ClientRequest {
269
originalOn(event, handler);
270
return rebroadcastMissedEvent(event, handler);
271
};
272
request.once = function onOverride(event, handler): http.ClientRequest {
273
originalOnce(event, handler);
274
return rebroadcastMissedEvent(event, handler);
275
};
276
277
// if re-using, we need to make sure the connection can still be written to by probing it
278
if (ctx.proxyToServerMitmSocket.isReused) {
279
if (!request.headersSent) request.flushHeaders();
280
// give this 100 ms to flush (go is on a wait timer right now)
281
await new Promise(resolve => setTimeout(resolve, 100));
282
if (
283
didHaveFlushErrors ||
284
ctx.proxyToServerMitmSocket.isClosing ||
285
ctx.proxyToServerMitmSocket.receivedEOF
286
) {
287
const socket = ctx.proxyToServerMitmSocket;
288
socket.close();
289
await this.assignSocket(ctx, requestSettings as any);
290
return this.http1Request(ctx, requestSettings);
291
}
292
}
293
return request;
294
}
295
296
/////// ////////// Http2 helpers //////////////////////////////////////////////////////////////////
297
298
private async http2Request(ctx: IMitmRequestContext): Promise<http2.ClientHttp2Stream> {
299
const client = await this.createHttp2Session(ctx);
300
ctx.setState(ResourceState.CreateProxyToServerRequest);
301
const weight = (ctx.clientToProxyRequest as Http2ServerRequest).stream?.state?.weight;
302
303
return client.request(ctx.requestHeaders, { waitForTrailers: true, weight, exclusive: true });
304
}
305
306
private async createHttp2Session(ctx: IMitmRequestContext): Promise<ClientHttp2Session> {
307
const origin = ctx.url.origin;
308
let originSocketPool: SocketPool;
309
let resolvedHost: string;
310
if (ctx.dnsResolvedIp) {
311
const port = ctx.url.port || (ctx.isSSL ? 443 : 80);
312
resolvedHost = `${ctx.dnsResolvedIp}:${port}`;
313
originSocketPool = this.socketPoolByResolvedHost.get(resolvedHost);
314
}
315
originSocketPool ??= this.getSocketPoolByOrigin(origin);
316
317
if (resolvedHost && !this.socketPoolByResolvedHost.has(resolvedHost)) {
318
this.socketPoolByResolvedHost.set(resolvedHost, originSocketPool);
319
}
320
321
const existing = originSocketPool.getHttp2Session();
322
if (existing) return existing.client;
323
324
const clientToProxyH2Session = (ctx.clientToProxyRequest as Http2ServerRequest).stream?.session;
325
326
ctx.setState(ResourceState.CreateH2Session);
327
328
const settings: IHttp2ConnectSettings = {
329
settings: clientToProxyH2Session?.remoteSettings,
330
localWindowSize: clientToProxyH2Session?.state.localWindowSize,
331
};
332
if (ctx.requestSession.plugins.onHttp2SessionConnect) {
333
await ctx.requestSession.plugins.onHttp2SessionConnect(ctx, settings);
334
}
335
336
const connectPromise = new Resolvable<void>();
337
const proxyToServerH2Client = http2.connect(
338
origin,
339
{
340
settings: settings.settings,
341
createConnection: () => ctx.proxyToServerMitmSocket.socket,
342
},
343
async remoteSession => {
344
if ('setLocalWindowSize' in remoteSession && settings.localWindowSize) {
345
// @ts-ignore
346
remoteSession.setLocalWindowSize(settings.localWindowSize);
347
await new Promise(setImmediate);
348
}
349
connectPromise.resolve();
350
},
351
);
352
353
// eslint-disable-next-line @typescript-eslint/no-unused-vars
354
const binding = new Http2SessionBinding(
355
clientToProxyH2Session,
356
proxyToServerH2Client,
357
originSocketPool.eventSubscriber,
358
{
359
sessionId: this.session.sessionId,
360
origin,
361
},
362
);
363
originSocketPool.eventSubscriber.on(
364
proxyToServerH2Client,
365
'stream',
366
async (stream, headers, flags, rawHeaders) => {
367
try {
368
const pushPromise = new Http2PushPromiseHandler(ctx, stream, headers, flags, rawHeaders);
369
await pushPromise.onRequest();
370
} catch (error) {
371
log.warn('Http2.ClientToProxy.ReadPushPromiseError', {
372
sessionId: this.session.sessionId,
373
rawHeaders,
374
error,
375
});
376
}
377
},
378
);
379
originSocketPool.eventSubscriber.on(proxyToServerH2Client, 'origin', origins => {
380
for (const svcOrigin of origins) {
381
this.getSocketPoolByOrigin(svcOrigin).registerHttp2Session(
382
proxyToServerH2Client,
383
ctx.proxyToServerMitmSocket,
384
);
385
}
386
});
387
388
originSocketPool.registerHttp2Session(proxyToServerH2Client, ctx.proxyToServerMitmSocket);
389
390
await connectPromise;
391
return proxyToServerH2Client;
392
}
393
}
394
395