Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/mitm/lib/MitmProxy.ts
1030 views
1
import * as net from 'net';
2
import { Socket } from 'net';
3
import * as http from 'http';
4
import { IncomingMessage } from 'http';
5
import * as https from 'https';
6
import * as http2 from 'http2';
7
import { ServerHttp2Session } from 'http2';
8
import Log from '@secret-agent/commons/Logger';
9
import * as Os from 'os';
10
import * as Path from 'path';
11
import { createPromise } from '@secret-agent/commons/utils';
12
import CertificateGenerator from '@secret-agent/mitm-socket/lib/CertificateGenerator';
13
import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';
14
import EventSubscriber from '@secret-agent/commons/EventSubscriber';
15
import IMitmProxyOptions from '../interfaces/IMitmProxyOptions';
16
import HttpRequestHandler from '../handlers/HttpRequestHandler';
17
import RequestSession from '../handlers/RequestSession';
18
import HttpUpgradeHandler from '../handlers/HttpUpgradeHandler';
19
import NetworkDb from './NetworkDb';
20
21
const { log } = Log(module);
22
const emptyResponse = `<html lang="en"><body>Empty</body></html>`;
23
24
const defaultStorageDirectory =
25
process.env.SA_NETWORK_DIR ??
26
process.env.SA_SESSIONS_DIR ??
27
Path.join(Os.tmpdir(), '.secret-agent');
28
29
/**
30
* This module is heavily inspired by 'https://github.com/joeferner/node-http-mitm-proxy'
31
*/
32
export default class MitmProxy {
33
private static certificateGenerator: CertificateGenerator;
34
private static networkDb: NetworkDb;
35
public get port(): number {
36
return this.httpPort;
37
}
38
39
public get httpPort(): number | undefined {
40
return (this.httpServer.address() as net.AddressInfo)?.port;
41
}
42
43
public get http2Port(): number | undefined {
44
return (this.http2Server.address() as net.AddressInfo)?.port;
45
}
46
47
public get httpsPort(): number | undefined {
48
return (this.httpsServer.address() as net.AddressInfo)?.port;
49
}
50
51
private http2Sessions = new Set<ServerHttp2Session>();
52
53
// used if this is a one-off proxy
54
private isolatedProxyForSessionId?: string;
55
56
// shared session params
57
private sessionById: { [sessionId: string]: RequestSession } = {};
58
private sessionIdByPort: { [port: number]: string } = {};
59
private portsBySessionId: { [sessionId: number]: Set<number> } = {};
60
61
private readonly options: IMitmProxyOptions;
62
private readonly httpServer: http.Server;
63
private readonly httpsServer: https.Server;
64
65
private readonly http2Server: http2.Http2SecureServer;
66
private readonly serverConnects = new Set<net.Socket>();
67
private readonly eventSubscriber = new EventSubscriber();
68
69
private isClosing = false;
70
71
private secureContexts: {
72
[hostname: string]: Promise<void>;
73
} = {};
74
75
constructor(options: IMitmProxyOptions) {
76
this.options = options;
77
78
this.httpServer = http.createServer({ insecureHTTPParser: true });
79
this.eventSubscriber.on(this.httpServer, 'connect', this.onHttpConnect.bind(this));
80
this.eventSubscriber.on(this.httpServer, 'clientError', this.onClientError.bind(this, false));
81
this.eventSubscriber.on(this.httpServer, 'request', this.onHttpRequest.bind(this, false));
82
this.eventSubscriber.on(this.httpServer, 'upgrade', this.onHttpUpgrade.bind(this, false));
83
84
this.httpsServer = https.createServer({ insecureHTTPParser: true });
85
this.eventSubscriber.on(this.httpsServer, 'connect', this.onHttpConnect.bind(this));
86
this.eventSubscriber.on(
87
this.httpsServer,
88
'tlsClientError',
89
this.onClientError.bind(this, true),
90
);
91
this.eventSubscriber.on(this.httpsServer, 'request', this.onHttpRequest.bind(this, true));
92
this.eventSubscriber.on(this.httpsServer, 'upgrade', this.onHttpUpgrade.bind(this, true));
93
94
this.http2Server = http2.createSecureServer();
95
this.eventSubscriber.on(this.http2Server, 'session', this.onHttp2Session.bind(this));
96
this.eventSubscriber.on(this.http2Server, 'sessionError', this.onClientError.bind(this, true));
97
this.eventSubscriber.on(this.http2Server, 'request', this.onHttpRequest.bind(this, true));
98
this.eventSubscriber.on(this.http2Server, 'upgrade', this.onHttpUpgrade.bind(this, true));
99
}
100
101
public close(): void {
102
if (this.isClosing) return;
103
this.isClosing = true;
104
105
const startLogId = log.info('MitmProxy.Closing', {
106
sessionId: this.isolatedProxyForSessionId,
107
});
108
const errors: Error[] = [];
109
110
for (const session of Object.values(this.sessionById)) {
111
try {
112
session.close();
113
} catch (err) {
114
errors.push(err);
115
}
116
}
117
this.sessionById = {};
118
119
for (const connect of this.serverConnects) {
120
destroyConnection(connect);
121
}
122
this.secureContexts = {};
123
try {
124
this.httpServer.close();
125
} catch (err) {
126
errors.push(err);
127
}
128
129
try {
130
for (const session of this.http2Sessions) {
131
session.destroy();
132
}
133
this.http2Sessions.clear();
134
this.http2Server.close();
135
} catch (err) {
136
errors.push(err);
137
}
138
try {
139
this.httpsServer.close();
140
} catch (err) {
141
errors.push(err);
142
}
143
this.eventSubscriber.close();
144
145
log.stats('MitmProxy.Closed', {
146
sessionId: this.isolatedProxyForSessionId,
147
parentLogId: startLogId,
148
closeErrors: errors,
149
});
150
}
151
152
/////// RequestSessions //////////////////////////////////////////////////////////////////////////////////////////////
153
154
public registerSession(session: RequestSession, isDefault: boolean): void {
155
const { sessionId } = session;
156
this.sessionById[sessionId] = session;
157
if (isDefault) {
158
this.isolatedProxyForSessionId = sessionId;
159
} else {
160
// if not default, need to clear out entries
161
session.once('close', () => {
162
setTimeout(() => this.removeSessionTracking(sessionId), 1e3).unref();
163
});
164
}
165
}
166
167
public removeSessionTracking(sessionId: string): void {
168
const ports = this.portsBySessionId[sessionId] || [];
169
for (const port of ports) {
170
delete this.sessionIdByPort[port];
171
}
172
delete this.portsBySessionId[sessionId];
173
delete this.sessionById[sessionId];
174
}
175
176
protected async listen(): Promise<this> {
177
await startServer(this.httpServer, this.options.port ?? 0);
178
await startServer(this.httpsServer);
179
await startServer(this.http2Server);
180
181
// don't listen for errors until server already started
182
this.eventSubscriber.on(this.httpServer, 'error', this.onGenericHttpError.bind(this, false));
183
this.eventSubscriber.on(this.httpsServer, 'error', this.onGenericHttpError.bind(this, true));
184
this.eventSubscriber.on(this.http2Server, 'error', this.onGenericHttpError.bind(this, true));
185
186
return this;
187
}
188
189
private async onHttpRequest(
190
isSSL: boolean,
191
clientToProxyRequest: http.IncomingMessage | http2.Http2ServerRequest,
192
proxyToClientResponse: http.ServerResponse | http2.Http2ServerResponse,
193
): Promise<void> {
194
const sessionId = this.readSessionId(
195
clientToProxyRequest.headers,
196
clientToProxyRequest.socket.remotePort,
197
);
198
if (!sessionId) {
199
return RequestSession.sendNeedsAuth(proxyToClientResponse.socket);
200
}
201
202
const requestSession = this.sessionById[sessionId];
203
if (requestSession?.isClosing) return;
204
205
if (!requestSession) {
206
log.warn('MitmProxy.RequestWithoutSession', {
207
sessionId,
208
isSSL,
209
host: clientToProxyRequest.headers.host ?? clientToProxyRequest.headers[':authority'],
210
url: clientToProxyRequest.url,
211
});
212
proxyToClientResponse.writeHead(504);
213
return proxyToClientResponse.end();
214
}
215
216
if (requestSession.bypassAllWithEmptyResponse) {
217
return proxyToClientResponse.end(emptyResponse);
218
}
219
220
try {
221
await HttpRequestHandler.onRequest({
222
isSSL,
223
requestSession,
224
clientToProxyRequest,
225
proxyToClientResponse,
226
});
227
} catch (error) {
228
// this can only happen during processing of request
229
log.warn('MitmProxy.ErrorProcessingRequest', {
230
sessionId,
231
isSSL,
232
error,
233
host: clientToProxyRequest.headers.host ?? clientToProxyRequest.headers[':authority'],
234
url: clientToProxyRequest.url,
235
});
236
try {
237
proxyToClientResponse.writeHead(400);
238
proxyToClientResponse.end('Bad request');
239
} catch (e) {
240
// don't double throw or log
241
}
242
}
243
}
244
245
private async onHttpUpgrade(
246
isSSL: boolean,
247
clientToProxyRequest: IncomingMessage,
248
socket: Socket,
249
head: Buffer,
250
): Promise<void> {
251
// socket resumes in HttpUpgradeHandler.upgradeResponseHandler
252
socket.pause();
253
const sessionId = this.readSessionId(
254
clientToProxyRequest.headers,
255
clientToProxyRequest.socket.remotePort,
256
);
257
if (!sessionId) {
258
return RequestSession.sendNeedsAuth(socket);
259
}
260
const requestSession = this.sessionById[sessionId];
261
if (requestSession?.isClosing) return;
262
263
if (!requestSession) {
264
log.warn('MitmProxy.UpgradeRequestWithoutSession', {
265
sessionId,
266
isSSL,
267
host: clientToProxyRequest.headers.host,
268
url: clientToProxyRequest.url,
269
});
270
return socket.end('HTTP/1.1 504 Proxy Error\r\n\r\n');
271
}
272
273
try {
274
await HttpUpgradeHandler.onUpgrade({
275
isSSL,
276
socket,
277
head,
278
requestSession,
279
clientToProxyRequest,
280
});
281
} catch (error) {
282
this.onClientError(false, error, socket);
283
}
284
}
285
286
private async onHttpConnect(
287
request: http.IncomingMessage,
288
socket: net.Socket,
289
head: Buffer,
290
): Promise<void> {
291
if (this.isClosing) return;
292
const sessionId = this.readSessionId(request.headers, request.socket.remotePort);
293
if (!sessionId) {
294
return RequestSession.sendNeedsAuth(socket);
295
}
296
this.serverConnects.add(socket);
297
socket.on('error', error => {
298
this.onConnectError(request.url, 'ClientToProxy.ConnectError', error);
299
this.serverConnects.delete(socket);
300
});
301
302
socket.write('HTTP/1.1 200 Connection established\r\n\r\n');
303
// we need first byte of data to detect if request is SSL encrypted
304
if (!head || head.length === 0) {
305
head = await new Promise<Buffer>(resolve => socket.once('data', resolve));
306
}
307
308
socket.pause();
309
310
let proxyToProxyPort = this.httpPort;
311
312
// for https we create a new connect back to the https server so we can have the proper cert and see the traffic
313
if (MitmProxy.isTlsByte(head)) {
314
// URL is in the form 'hostname:port'
315
const [hostname, port] = request.url.split(':', 2);
316
317
if (!this.secureContexts[hostname]) {
318
this.secureContexts[hostname] = this.addSecureContext(hostname);
319
}
320
321
let isHttp2 = true;
322
try {
323
const requestSession = this.sessionById[sessionId];
324
if (requestSession.bypassAllWithEmptyResponse) {
325
isHttp2 = false;
326
} else if (
327
!requestSession.shouldBlockRequest(`https://${hostname}:${port}`) &&
328
!requestSession.shouldBlockRequest(`https://${hostname}`)
329
) {
330
const agent = requestSession.requestAgent;
331
isHttp2 = await agent.isHostAlpnH2(hostname, port);
332
}
333
} catch (error) {
334
if (error instanceof CanceledPromiseError) return;
335
log.warn('Connect.AlpnLookupError', {
336
hostname,
337
error,
338
sessionId,
339
});
340
}
341
342
try {
343
await this.secureContexts[hostname];
344
} catch (error) {
345
if (error instanceof CanceledPromiseError) return;
346
this.onConnectError(request.url, 'ClientToProxy.GenerateCertError', error);
347
this.serverConnects.delete(socket);
348
return;
349
}
350
351
if (isHttp2) {
352
proxyToProxyPort = this.http2Port;
353
} else {
354
proxyToProxyPort = this.httpsPort;
355
}
356
}
357
358
const connectedPromise = createPromise();
359
const proxyConnection = net.connect(
360
{ port: proxyToProxyPort, allowHalfOpen: false },
361
connectedPromise.resolve,
362
);
363
this.serverConnects.add(proxyConnection);
364
proxyConnection.on('error', error => {
365
this.onConnectError(request.url, 'ProxyToProxy.ConnectError', error);
366
if (!socket.destroyed && socket.writable && socket.readable) {
367
socket.destroy(error);
368
}
369
});
370
371
proxyConnection.once('end', () => this.serverConnects.delete(proxyConnection));
372
socket.once('end', () => this.serverConnects.delete(socket));
373
374
proxyConnection.once('close', () => destroyConnection(socket));
375
socket.once('close', () => destroyConnection(proxyConnection));
376
377
await connectedPromise;
378
this.registerProxySession(proxyConnection, sessionId);
379
380
socket.setNoDelay(true);
381
proxyConnection.setNoDelay(true);
382
// create a tunnel back to the same proxy
383
socket.pipe(proxyConnection).pipe(socket);
384
if (head.length) socket.emit('data', head);
385
socket.resume();
386
}
387
388
private onHttp2Session(session: ServerHttp2Session): void {
389
this.http2Sessions.add(session);
390
this.eventSubscriber.once(session, 'close', () => this.http2Sessions.delete(session));
391
}
392
393
/////// ERROR HANDLING ///////////////////////////////////////////////////////
394
395
private onGenericHttpError(isHttp2: boolean, error: Error): void {
396
const logLevel = this.isClosing ? 'stats' : 'error';
397
log[logLevel](`Mitm.Http${isHttp2 ? '2' : ''}ServerError`, {
398
sessionId: this.isolatedProxyForSessionId,
399
error,
400
});
401
}
402
403
private onClientError(isHttp2: boolean, error: Error, socket: net.Socket): void {
404
if ((error as any).code === 'ECONNRESET' || !socket.writable) {
405
return;
406
}
407
const kind = isHttp2 ? 'Http2.SessionError' : 'Http2.ClientError';
408
log.error(`Mitm.${kind}`, {
409
sessionId: this.isolatedProxyForSessionId,
410
error,
411
socketAddress: socket.address(),
412
});
413
414
try {
415
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
416
} catch (e) {
417
// just drown these
418
}
419
}
420
421
private onConnectError(hostname: string, errorKind: string, error: Error): void {
422
const errorCodes = [(error as any).errno, (error as any).code];
423
if (errorCodes.includes('ECONNRESET')) {
424
log.info(`Got ECONNRESET on Proxy Connect, ignoring.`, {
425
sessionId: this.isolatedProxyForSessionId,
426
hostname,
427
});
428
} else if (errorCodes.includes('ECONNABORTED')) {
429
log.info(`Got ECONNABORTED on Proxy Connect, ignoring.`, {
430
sessionId: this.isolatedProxyForSessionId,
431
hostname,
432
});
433
} else if (errorCodes.includes('ERR_STREAM_UNSHIFT_AFTER_END_EVENT')) {
434
log.info(`Got ERR_STREAM_UNSHIFT_AFTER_END_EVENT on Proxy Connect, ignoring.`, {
435
sessionId: this.isolatedProxyForSessionId,
436
hostname,
437
errorKind,
438
});
439
} else if (errorCodes.includes('EPIPE')) {
440
log.info(`Got EPIPE on Proxy Connect, ignoring.`, {
441
sessionId: this.isolatedProxyForSessionId,
442
hostname,
443
errorKind,
444
});
445
} else {
446
const logLevel = this.isClosing ? 'stats' : 'error';
447
log[logLevel]('MitmConnectError', {
448
sessionId: this.isolatedProxyForSessionId,
449
errorKind,
450
error,
451
errorCodes,
452
hostname,
453
});
454
}
455
}
456
457
private async addSecureContext(hostname: string): Promise<void> {
458
if (hostname.includes(':')) hostname = hostname.split(':').shift();
459
460
const cert = await MitmProxy.getCertificate(hostname);
461
this.http2Server.addContext(hostname, cert);
462
this.httpsServer.addContext(hostname, cert);
463
}
464
465
/////// SESSION ID MGMT //////////////////////////////////////////////////////////////////////////////////////////////
466
467
private readSessionId(
468
requestHeaders: { [key: string]: string | string[] | undefined },
469
remotePort: number,
470
): string {
471
if (this.isolatedProxyForSessionId) return this.isolatedProxyForSessionId;
472
473
const authHeader = requestHeaders['proxy-authorization'] as string;
474
if (!authHeader) {
475
return this.sessionIdByPort[remotePort];
476
}
477
478
const [, sessionId] = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':');
479
return sessionId;
480
}
481
482
private registerProxySession(loopbackProxySocket: net.Socket, sessionId: string): void {
483
// local port is the side that originates from our http server
484
this.portsBySessionId[sessionId] ??= new Set();
485
this.portsBySessionId[sessionId].add(loopbackProxySocket.localPort);
486
this.sessionIdByPort[loopbackProxySocket.localPort] = sessionId;
487
}
488
489
public static async start(startingPort?: number, sslCaDir?: string): Promise<MitmProxy> {
490
if (this.certificateGenerator == null) {
491
const baseDir = sslCaDir ?? defaultStorageDirectory;
492
this.networkDb = new NetworkDb(baseDir);
493
this.certificateGenerator = new CertificateGenerator({ storageDir: baseDir });
494
}
495
const proxy = new MitmProxy({
496
port: startingPort,
497
});
498
499
await proxy.listen();
500
return proxy;
501
}
502
503
public static close(): void {
504
if (this.certificateGenerator) {
505
try {
506
this.certificateGenerator.close();
507
} catch (err) {
508
// closing, so don't rebroadcast
509
}
510
this.certificateGenerator = null;
511
}
512
if (this.networkDb) {
513
try {
514
this.networkDb.close();
515
} catch (err) {
516
// closing, so don't rebroadcast
517
}
518
this.networkDb = null;
519
}
520
}
521
522
private static async getCertificate(host: string): Promise<{ cert: string; key: string }> {
523
const { networkDb, certificateGenerator } = this;
524
525
if (!certificateGenerator) return null;
526
527
await certificateGenerator.waitForConnected;
528
const key = await certificateGenerator.getPrivateKey();
529
const existing = networkDb.certificates.get(host);
530
if (existing) {
531
return {
532
key,
533
cert: existing.pem,
534
};
535
}
536
// if it doesn't exist, generate now
537
const { expireDate, cert } = await certificateGenerator.generateCerts(host);
538
networkDb.certificates.insert({ host, pem: cert, expireDate });
539
return { key, cert };
540
}
541
542
private static isTlsByte(buffer: Buffer): boolean {
543
// check for clienthello byte
544
return buffer[0] === 0x16;
545
}
546
}
547
548
function destroyConnection(socket: net.Socket): void {
549
try {
550
socket.destroy();
551
} catch (e) {
552
// nothing to do
553
}
554
}
555
556
function startServer(
557
server: http.Server | http2.Http2SecureServer,
558
listenPort?: number,
559
): Promise<void> {
560
return new Promise<void>((resolve, reject) => {
561
try {
562
server.once('error', reject);
563
server.listen(listenPort, resolve);
564
} catch (err) {
565
reject(err);
566
}
567
});
568
}
569
570