Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
QuiteAFancyEmerald
GitHub Repository: QuiteAFancyEmerald/Holy-Unblocker
Path: blob/master/lib/rammerhead/src/classes/RammerheadProxy.js
5253 views
1
const http = require('http');
2
const https = require('https');
3
const stream = require('stream');
4
const fs = require('fs');
5
const path = require('path');
6
const { getPathname } = require('testcafe-hammerhead/lib/utils/url');
7
const { Proxy } = require('testcafe-hammerhead');
8
const WebSocket = require('ws');
9
const httpResponse = require('../util/httpResponse');
10
const streamToString = require('../util/streamToString');
11
const URLPath = require('../util/URLPath');
12
const RammerheadLogging = require('../classes/RammerheadLogging');
13
14
require('../util/fixCorsHeader');
15
require('../util/fixWebsocket');
16
require('../util/addMoreErrorGuards');
17
require('../util/addUrlShuffling');
18
require('../util/patchAsyncResourceProcessor');
19
let addJSDiskCache = function (path, size) {
20
require('../util/addJSDiskCache')(path, size);
21
// modification only works once
22
addJSDiskCache = () => {};
23
};
24
25
/**
26
* taken directly from
27
* https://github.com/DevExpress/testcafe-hammerhead/blob/a9fbf7746ff347f7bdafe1f80cf7135eeac21e34/src/typings/proxy.d.ts#L1
28
* @typedef {object} ServerInfo
29
* @property {string} hostname
30
* @property {number} port
31
* @property {number} crossDomainPort
32
* @property {string} protocol
33
* @property {string} domain
34
* @property {boolean} cacheRequests
35
*/
36
37
/**
38
* @typedef {object} RammerheadServerInfo
39
* @property {string} hostname
40
* @property {number} port
41
* @property {'https:'|'http:'} protocol
42
*/
43
44
/**
45
* @private
46
* @typedef {import('./RammerheadSession')} RammerheadSession
47
*/
48
49
/**
50
* wrapper for hammerhead's Proxy
51
*/
52
class RammerheadProxy extends Proxy {
53
/**
54
*
55
* @param {object} options
56
* @param {RammerheadLogging|undefined} options.logger
57
* @param {(req: http.IncomingMessage) => string} options.loggerGetIP - use custom logic to get IP, either from headers or directly
58
* @param {string} options.bindingAddress - hostname for proxy to bind to
59
* @param {number} options.port - port for proxy to listen to
60
* @param {number|null} options.crossDomainPort - crossDomain port to simulate cross origin requests. set to null
61
* to disable using this. highly not recommended to disable this because it breaks sites that check for the origin header
62
* @param {boolean} options.dontListen - avoid calling http.listen() if you need to use sticky-session to load balance
63
* @param {http.ServerOptions} options.ssl - set to null to disable ssl
64
* @param {(req: http.IncomingMessage) => RammerheadServerInfo} options.getServerInfo - force hammerhead to rewrite using specified
65
* server info (server info includes hostname, port, and protocol). Useful for a reverse proxy setup like nginx where you
66
* need to rewrite the hostname/port/protocol
67
* @param {boolean} options.disableLocalStorageSync - disables localStorage syncing (default: false)
68
* @param {string} options.diskJsCachePath - set to null to disable disk cache and use memory instead (disabled by default)
69
* @param {number} options.jsCacheSize - in bytes. default: 50mb
70
*/
71
constructor({
72
loggerGetIP = (req) => req.socket.remoteAddress,
73
logger = new RammerheadLogging({ logLevel: 'disabled' }),
74
bindingAddress = '127.0.0.1',
75
port = 8080,
76
crossDomainPort = 8081,
77
dontListen = false,
78
ssl = null,
79
getServerInfo = (req) => {
80
const { hostname, port } = new URL('http://' + req.headers.host);
81
return {
82
hostname,
83
port,
84
protocol: req.socket.encrypted ? 'https:' : 'http:'
85
};
86
},
87
disableLocalStorageSync = false,
88
diskJsCachePath = null,
89
jsCacheSize = 50 * 1024 * 1024
90
} = {}) {
91
if (!crossDomainPort) {
92
const httpOrHttps = ssl ? https : http;
93
const proxyHttpOrHttps = http;
94
const originalProxyCreateServer = proxyHttpOrHttps.createServer;
95
const originalCreateServer = httpOrHttps.createServer; // handle recursion case if proxyHttpOrHttps and httpOrHttps are the same
96
let onlyOneHttpServer = null;
97
98
// a hack to force testcafe-hammerhead's proxy library into using only one http port.
99
// a downside to using only one proxy server is that crossdomain requests
100
// will not be simulated correctly
101
proxyHttpOrHttps.createServer = function (...args) {
102
const emptyFunc = () => {};
103
if (onlyOneHttpServer) {
104
// createServer for server1 already called. now we return a mock http server for server2
105
return { on: emptyFunc, listen: emptyFunc, close: emptyFunc };
106
}
107
if (args.length !== 2) throw new Error('unexpected argument length coming from hammerhead');
108
return (onlyOneHttpServer = originalCreateServer(...args));
109
};
110
111
// now, we force the server to listen to a specific port and a binding address, regardless of what
112
// hammerhead server.listen(anything)
113
const originalListen = http.Server.prototype.listen;
114
http.Server.prototype.listen = function (_proxyPort) {
115
if (dontListen) return;
116
originalListen.call(this, port, bindingAddress);
117
};
118
119
// actual proxy initialization
120
// the values don't matter (except for developmentMode), since we'll be rewriting serverInfo anyway
121
super('hostname', 'port', 'port', {
122
ssl,
123
developmentMode: true,
124
cache: true
125
});
126
127
// restore hooked functions to their original state
128
proxyHttpOrHttps.createServer = originalProxyCreateServer;
129
http.Server.prototype.listen = originalListen;
130
} else {
131
// just initialize the proxy as usual, since we don't need to do hacky stuff like the above.
132
// we still need to make sure the proxy binds to the correct address though
133
const originalListen = http.Server.prototype.listen;
134
http.Server.prototype.listen = function (portArg) {
135
if (dontListen) return;
136
originalListen.call(this, portArg, bindingAddress);
137
};
138
super('doesntmatter', port, crossDomainPort, {
139
ssl,
140
developmentMode: true,
141
cache: true
142
});
143
this.crossDomainPort = crossDomainPort;
144
http.Server.prototype.listen = originalListen;
145
}
146
147
this._setupRammerheadServiceRoutes();
148
this._setupLocalStorageServiceRoutes(disableLocalStorageSync);
149
150
this.onRequestPipeline = [];
151
this.onUpgradePipeline = [];
152
this.websocketRoutes = [];
153
this.rewriteServerHeaders = {
154
'permissions-policy': (headerValue) => headerValue && headerValue.replace(/sync-xhr/g, 'sync-yes'),
155
'feature-policy': (headerValue) => headerValue && headerValue.replace(/sync-xhr/g, 'sync-yes'),
156
'referrer-policy': () => 'no-referrer-when-downgrade',
157
'report-to': () => undefined,
158
'cross-origin-embedder-policy': () => undefined
159
};
160
161
this.getServerInfo = getServerInfo;
162
this.serverInfo1 = null; // make sure no one uses these serverInfo
163
this.serverInfo2 = null;
164
165
this.loggerGetIP = loggerGetIP;
166
this.logger = logger;
167
168
addJSDiskCache(diskJsCachePath, jsCacheSize);
169
}
170
171
// add WS routing
172
/**
173
* since we have .GET and .POST, why not add in a .WS also
174
* @param {string|RegExp} route - can be '/route/to/things' or /^\\/route\\/(this)|(that)\\/things$/
175
* @param {(ws: WebSocket, req: http.IncomingMessage) => WebSocket} handler - ws is the connection between the client and the server
176
* @param {object} websocketOptions - read https://www.npmjs.com/package/ws for a list of Websocket.Server options. Note that
177
* the { noServer: true } will always be applied
178
* @returns {WebSocket.Server}
179
*/
180
WS(route, handler, websocketOptions = {}) {
181
if (this.checkIsRoute(route)) {
182
throw new TypeError('WS route already exists');
183
}
184
185
const wsServer = new WebSocket.Server({
186
...websocketOptions,
187
noServer: true
188
});
189
this.websocketRoutes.push({ route, handler, wsServer });
190
191
return wsServer;
192
}
193
unregisterWS(route) {
194
if (!this.getWSRoute(route, true)) {
195
throw new TypeError('websocket route does not exist');
196
}
197
}
198
/**
199
* @param {string} path
200
* @returns {{ route: string|RegExp, handler: (ws: WebSocket, req: http.IncomingMessage) => WebSocket, wsServer: WebSocket.Server}|null}
201
*/
202
getWSRoute(path, doDelete = false) {
203
for (let i = 0; i < this.websocketRoutes.length; i++) {
204
if (
205
(typeof this.websocketRoutes[i].route === 'string' && this.websocketRoutes[i].route === path) ||
206
(this.websocketRoutes[i] instanceof RegExp && this.websocketRoutes[i].route.test(path))
207
) {
208
const route = this.websocketRoutes[i];
209
if (doDelete) {
210
this.websocketRoutes.splice(i, 1);
211
i--;
212
}
213
return route;
214
}
215
}
216
return null;
217
}
218
/**
219
* @private
220
*/
221
_WSRouteHandler(req, socket, head) {
222
const route = this.getWSRoute(req.url);
223
if (route) {
224
// RH stands for rammerhead. RHROUTE is a custom implementation by rammerhead that is
225
// unrelated to hammerhead
226
this.logger.traffic(`WSROUTE UPGRADE ${this.loggerGetIP(req)} ${req.url}`);
227
route.wsServer.handleUpgrade(req, socket, head, (client, req) => {
228
this.logger.traffic(`WSROUTE OPEN ${this.loggerGetIP(req)} ${req.url}`);
229
client.once('close', () => {
230
this.logger.traffic(`WSROUTE CLOSE ${this.loggerGetIP(req)} ${req.url}`);
231
});
232
route.handler(client, req);
233
});
234
return true;
235
}
236
}
237
238
// manage pipelines //
239
/**
240
* @param {(req: http.IncomingMessage,
241
* res: http.ServerResponse,
242
* serverInfo: ServerInfo,
243
* isRoute: boolean,
244
* isWebsocket: boolean) => Promise<boolean>} onRequest - return true to terminate handoff to proxy.
245
* There is an isWebsocket even though there is an onUpgrade pipeline already. This is because hammerhead
246
* processes the onUpgrade and then passes it directly to onRequest, but without the "head" Buffer argument.
247
* The onUpgrade pipeline is to solve that lack of the "head" argument issue in case one needs it.
248
* @param {boolean} beginning - whether to add it to the beginning of the pipeline
249
*/
250
addToOnRequestPipeline(onRequest, beginning = false) {
251
if (beginning) {
252
this.onRequestPipeline.push(onRequest);
253
} else {
254
this.onRequestPipeline.unshift(onRequest);
255
}
256
}
257
/**
258
* @param {(req: http.IncomingMessage,
259
* socket: stream.Duplex,
260
* head: Buffer,
261
* serverInfo: ServerInfo,
262
* isRoute: boolean) => Promise<boolean>} onUpgrade - return true to terminate handoff to proxy
263
* @param {boolean} beginning - whether to add it to the beginning of the pipeline
264
*/
265
addToOnUpgradePipeline(onUpgrade, beginning = false) {
266
if (beginning) {
267
this.onUpgradePipeline.push(onUpgrade);
268
} else {
269
this.onUpgradePipeline.unshift(onUpgrade);
270
}
271
}
272
273
// override hammerhead's proxy functions to use the pipeline //
274
checkIsRoute(req) {
275
if (req instanceof RegExp) {
276
return !!this.getWSRoute(req);
277
}
278
// code modified from
279
// https://github.com/DevExpress/testcafe-hammerhead/blob/879d6ae205bb711dfba8c1c88db635e8803b8840/src/proxy/router.ts#L95
280
const routerQuery = `${req.method} ${getPathname(req.url || '')}`;
281
const route = this.routes.get(routerQuery);
282
if (route) {
283
return true;
284
}
285
for (const routeWithParams of this.routesWithParams) {
286
const routeMatch = routerQuery.match(routeWithParams.re);
287
if (routeMatch) {
288
return true;
289
}
290
}
291
return !!this.getWSRoute(req.url);
292
}
293
/**
294
* @param {http.IncomingMessage} req
295
* @param {http.ServerResponse} res
296
* @param {ServerInfo} serverInfo
297
*/
298
async _onRequest(req, res, serverInfo) {
299
serverInfo = this._rewriteServerInfo(req);
300
301
const isWebsocket = res instanceof stream.Duplex;
302
303
if (!isWebsocket) {
304
// strip server headers
305
const originalWriteHead = res.writeHead;
306
const self = this;
307
res.writeHead = function (statusCode, statusMessage, headers) {
308
if (!headers) {
309
headers = statusMessage;
310
statusMessage = undefined;
311
}
312
313
if (headers) {
314
const alreadyRewrittenHeaders = [];
315
if (Array.isArray(headers)) {
316
// [content-type, text/html, headerKey, headerValue, ...]
317
for (let i = 0; i < headers.length - 1; i += 2) {
318
const header = headers[i].toLowerCase();
319
if (header in self.rewriteServerHeaders) {
320
alreadyRewrittenHeaders.push(header);
321
headers[i + 1] =
322
self.rewriteServerHeaders[header] &&
323
self.rewriteServerHeaders[header](headers[i + 1]);
324
if (!headers[i + 1]) {
325
headers.splice(i, 2);
326
i -= 2;
327
}
328
}
329
}
330
for (const header in self.rewriteServerHeaders) {
331
if (alreadyRewrittenHeaders.includes(header)) continue;
332
// if user wants to add headers, they can do that here
333
const value = self.rewriteServerHeaders[header] && self.rewriteServerHeaders[header]();
334
if (value) {
335
headers.push(header, value);
336
}
337
}
338
} else {
339
for (const header in headers) {
340
if (header in self.rewriteServerHeaders) {
341
alreadyRewrittenHeaders.push(header);
342
headers[header] =
343
self.rewriteServerHeaders[header] && self.rewriteServerHeaders[header]();
344
if (!headers[header]) {
345
delete headers[header];
346
}
347
}
348
}
349
for (const header in self.rewriteServerHeaders) {
350
if (alreadyRewrittenHeaders.includes(header)) continue;
351
const value = self.rewriteServerHeaders[header] && self.rewriteServerHeaders[header]();
352
if (value) {
353
headers[header] = value;
354
}
355
}
356
}
357
}
358
359
if (statusMessage) {
360
originalWriteHead.call(this, statusCode, statusMessage, headers);
361
} else {
362
originalWriteHead.call(this, statusCode, headers);
363
}
364
};
365
}
366
367
const isRoute = this.checkIsRoute(req);
368
const ip = this.loggerGetIP(req);
369
370
this.logger.traffic(`${isRoute ? 'ROUTE ' : ''}${ip} ${req.url}`);
371
for (const handler of this.onRequestPipeline) {
372
if ((await handler.call(this, req, res, serverInfo, isRoute, isWebsocket)) === true) {
373
return;
374
}
375
}
376
// hammerhead's routing does not support websockets. Allowing it
377
// will result in an error thrown
378
if (isRoute && isWebsocket) {
379
httpResponse.badRequest(this.logger, req, res, ip, 'Rejected unsupported websocket request');
380
return;
381
}
382
super._onRequest(req, res, serverInfo);
383
}
384
/**
385
* @param {http.IncomingMessage} req
386
* @param {stream.Duplex} socket
387
* @param {Buffer} head
388
* @param {ServerInfo} serverInfo
389
*/
390
async _onUpgradeRequest(req, socket, head, serverInfo) {
391
serverInfo = this._rewriteServerInfo(req);
392
for (const handler of this.onUpgradePipeline) {
393
const isRoute = this.checkIsRoute(req);
394
if ((await handler.call(this, req, socket, head, serverInfo, isRoute)) === true) {
395
return;
396
}
397
}
398
if (this._WSRouteHandler(req, socket, head)) return;
399
super._onUpgradeRequest(req, socket, head, serverInfo);
400
}
401
402
/**
403
* @private
404
* @param {http.IncomingMessage} req
405
* @returns {ServerInfo}
406
*/
407
_rewriteServerInfo(req) {
408
const serverInfo = this.getServerInfo(req);
409
return {
410
hostname: serverInfo.hostname,
411
port: serverInfo.port,
412
crossDomainPort: serverInfo.crossDomainPort || this.crossDomainPort || serverInfo.port,
413
protocol: serverInfo.protocol,
414
domain: `${serverInfo.protocol}//${serverInfo.hostname}:${serverInfo.port}`,
415
cacheRequests: false
416
};
417
}
418
/**
419
* @private
420
*/
421
_setupRammerheadServiceRoutes() {
422
this.GET('/rammerhead.js', {
423
content: fs.readFileSync(
424
path.join(__dirname, '../client/rammerhead' + (process.env.DEVELOPMENT ? '.js' : '.min.js'))
425
),
426
contentType: 'application/x-javascript'
427
});
428
this.GET('/api/shuffleDict', (req, res) => {
429
const { id } = new URLPath(req.url).getParams();
430
if (!id || !this.openSessions.has(id)) {
431
return httpResponse.badRequest(this.logger, req, res, this.loggerGetIP(req), 'Invalid session id');
432
}
433
res.end(JSON.stringify(this.openSessions.get(id).shuffleDict) || '');
434
});
435
}
436
/**
437
* @private
438
*/
439
_setupLocalStorageServiceRoutes(disableSync) {
440
this.POST('/syncLocalStorage', async (req, res) => {
441
if (disableSync) {
442
res.writeHead(404);
443
res.end('server disabled localStorage sync');
444
return;
445
}
446
const badRequest = (msg) => httpResponse.badRequest(this.logger, req, res, this.loggerGetIP(req), msg);
447
const respondJson = (obj) => res.end(JSON.stringify(obj));
448
const { sessionId, origin } = new URLPath(req.url).getParams();
449
450
if (!sessionId || !this.openSessions.has(sessionId)) {
451
return badRequest('Invalid session id');
452
}
453
if (!origin) {
454
return badRequest('Invalid origin');
455
}
456
457
let parsed;
458
try {
459
parsed = JSON.parse(await streamToString(req));
460
} catch (e) {
461
return badRequest('bad client body');
462
}
463
464
const now = Date.now();
465
const session = this.openSessions.get(sessionId, false);
466
if (!session.data.localStorage) session.data.localStorage = {};
467
468
switch (parsed.type) {
469
case 'sync':
470
if (parsed.fetch) {
471
// client is syncing for the first time
472
if (!session.data.localStorage[origin]) {
473
// server does not have any data on origin, so create an empty record
474
// and send an empty object back
475
session.data.localStorage[origin] = { data: {}, timestamp: now };
476
return respondJson({
477
timestamp: now,
478
data: {}
479
});
480
} else {
481
// server does have data, so send data back
482
return respondJson({
483
timestamp: session.data.localStorage[origin].timestamp,
484
data: session.data.localStorage[origin].data
485
});
486
}
487
} else {
488
// sync server and client localStorage
489
490
parsed.timestamp = parseInt(parsed.timestamp);
491
if (isNaN(parsed.timestamp)) return badRequest('must specify valid timestamp');
492
if (parsed.timestamp > now) return badRequest('cannot specify timestamp in the future');
493
if (!parsed.data || typeof parsed.data !== 'object')
494
return badRequest('data must be an object');
495
496
for (const prop in parsed.data) {
497
if (typeof parsed.data[prop] !== 'string') {
498
return badRequest('data[prop] must be a string');
499
}
500
}
501
502
if (!session.data.localStorage[origin]) {
503
// server does not have data, so use client's
504
session.data.localStorage[origin] = { data: parsed.data, timestamp: now };
505
return respondJson({});
506
} else if (session.data.localStorage[origin].timestamp <= parsed.timestamp) {
507
// server data is either the same as client or outdated, but we
508
// sync even if timestamps are the same in case the client changed the localStorage
509
// without updating
510
session.data.localStorage[origin].data = parsed.data;
511
session.data.localStorage[origin].timestamp = parsed.timestamp;
512
return respondJson({});
513
} else {
514
// client data is stale
515
return respondJson({
516
timestamp: session.data.localStorage[origin].timestamp,
517
data: session.data.localStorage[origin].data
518
});
519
}
520
}
521
case 'update':
522
if (!session.data.localStorage[origin])
523
return badRequest('must perform sync first on a new origin');
524
if (!parsed.updateData || typeof parsed.updateData !== 'object')
525
return badRequest('updateData must be an object');
526
for (const prop in parsed.updateData) {
527
if (!parsed.updateData[prop] || typeof parsed.updateData[prop] !== 'string')
528
return badRequest('updateData[prop] must be a non-empty string');
529
}
530
for (const prop in parsed.updateData) {
531
session.data.localStorage[origin].data[prop] = parsed.updateData[prop];
532
}
533
session.data.localStorage[origin].timestamp = now;
534
return respondJson({
535
timestamp: now
536
});
537
default:
538
return badRequest('unknown type ' + parsed.type);
539
}
540
});
541
}
542
543
openSession() {
544
throw new TypeError('unimplemented. please use a RammerheadSessionStore and use their .add() method');
545
}
546
close() {
547
super.close();
548
this.openSessions.close();
549
}
550
551
/**
552
* @param {string} route
553
* @param {StaticContent | (req: http.IncomingMessage, res: http.ServerResponse) => void} handler
554
*/
555
GET(route, handler) {
556
if (route === '/hammerhead.js') {
557
handler.content = fs.readFileSync(
558
path.join(__dirname, '../client/hammerhead' + (process.env.DEVELOPMENT ? '.js' : '.min.js'))
559
);
560
}
561
super.GET(route, handler);
562
}
563
564
// the following is to fix hamerhead's typescript definitions
565
/**
566
* @param {string} route
567
* @param {StaticContent | (req: http.IncomingMessage, res: http.ServerResponse) => void} handler
568
*/
569
POST(route, handler) {
570
super.POST(route, handler);
571
}
572
}
573
574
module.exports = RammerheadProxy;
575
576