Skip to content

Commit

Permalink
feat: implement request blocking based on whitelist for the main proc…
Browse files Browse the repository at this point in the history
…ess of the electrum

fix
  • Loading branch information
peter-sanderson committed Jan 13, 2025
1 parent ad8e308 commit 22d43d9
Show file tree
Hide file tree
Showing 20 changed files with 441 additions and 68 deletions.
2 changes: 1 addition & 1 deletion packages/request-manager/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# @trezor/request-manager

Library to allow efficient and stable proxy for requests using Tor or other similar systems.
For now it works specifically with Tor, but it may be more generic in future and integrate with other similar proxy systems like Tor.
For now, it works specifically with Tor, but it may be more generic in future and integrate with other similar proxy systems like Tor.

## Examples

Expand Down
6 changes: 4 additions & 2 deletions packages/request-manager/e2e/identities-stress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path';

import { createInterceptor, TorController } from '../src';
import { torRunner } from './torRunner';
import { InterceptorOptions } from '../src/types';

// The purpose of this script is to allow "manual" testing Tor identities changing some parameters.
// Run it like:
Expand All @@ -16,7 +17,8 @@ const processId = process.pid;
const torDataDir = path.join(__dirname, 'tmp');
const ipRegex = /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/;

const INTERCEPTOR = {
const interceptorOptions: InterceptorOptions = {
getWhitelistedDomains: () => ['check.torproject.org'],
handler: () => {},
getTorSettings: () => ({ running: true, host, port }),
};
Expand All @@ -28,7 +30,7 @@ const intervalBetweenRequests = 1000 * 20;

(async () => {
// Callback in in createInterceptor should return true in order for the request to use Tor.
createInterceptor(INTERCEPTOR);
createInterceptor(interceptorOptions);

console.log('Starting Tor.');
// Starting Tor controller to make sure that Tor is running.
Expand Down
16 changes: 14 additions & 2 deletions packages/request-manager/e2e/interceptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import path from 'path';
import http from 'http';
import WebSocket from 'ws';

// Todo: Currently this needs to be done in order to interceptor in test to work.
// This shall be taken care of, as we shall be intercepting the native fetch as well.
// import fetch from 'node-fetch';

import { TorController, createInterceptor } from '../src';
import { torRunner } from './torRunner';
import { TorIdentities } from '../src/torIdentities';
import { InterceptorOptions } from '../src/types';

const hostIp = '127.0.0.1';
const port = 38835;
Expand All @@ -30,14 +35,21 @@ describe('Interceptor', () => {

const torSettings = { running: true, host: hostIp, port };

const INTERCEPTOR = {
const interceptorOptions: InterceptorOptions = {
getWhitelistedDomains: () => [
'check.torproject.org',
'httpbin.org',
'tbtc1.trezor.io',
'localhost',
'127.0.0.1',
],
handler: () => {},
getTorSettings: () => torSettings,
};

beforeAll(async () => {
// Callback in createInterceptor should return true in order for the request to use Tor.
torIdentities = createInterceptor(INTERCEPTOR).torIdentities;
torIdentities = createInterceptor(interceptorOptions).torIdentities;
// Starting Tor controller to make sure that Tor is running.
torController = new TorController({
host: hostIp,
Expand Down
2 changes: 2 additions & 0 deletions packages/request-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"sideEffects": false,
"main": "src/index.ts",
"scripts": {
"test:unit": "yarn g:jest --testPathIgnorePatterns e2e -c ../../jest.config.base.js",
"test:e2e": "yarn g:jest --runInBand -c ../../jest.config.base.js",
"type-check": "yarn g:tsc --build tsconfig.json",
"test:stress": "ts-node -O '{\"module\": \"commonjs\"}' ./e2e/identities-stress.ts"
Expand All @@ -17,6 +18,7 @@
},
"devDependencies": {
"@trezor/eslint": "workspace:*",
"node-fetch": "^2.6.4",
"ts-node": "^10.9.1",
"ws": "^8.18.0"
}
Expand Down
20 changes: 15 additions & 5 deletions packages/request-manager/src/interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,27 @@ import { interceptHttps } from './interceptor/interceptHttps';
import { interceptHttp } from './interceptor/interceptHttp';
import { interceptNetConnect } from './interceptor/interceptNetConnect';
import { interceptNetSocketConnect } from './interceptor/interceptNetSocketConnect';
import { isWhitelistedHost } from './interceptor/interceptorTypesAndUtils';

export const createInterceptor = (interceptorOptions: InterceptorOptions) => {
const requestPool = createRequestPool(interceptorOptions);
const torIdentities = new TorIdentities(interceptorOptions.getTorSettings);
const context = { ...interceptorOptions, requestPool, torIdentities };

interceptNetSocketConnect(context);
interceptNetConnect(context);
interceptHttp(context);
interceptHttps(context);
interceptTlsConnect(context);
const validateRequest = ({ hostname }: { hostname: string }) => {
if (!isWhitelistedHost(hostname, context.getWhitelistedDomains())) {
// Sometimes the error is not reported correctly so for debug reasons we log it as well
console.error(`Request blocked, not whitelisted domain: ${hostname}`);

throw new Error(`Request blocked, not whitelisted domain: ${hostname}`);
}
};

interceptNetSocketConnect({ context, validateRequest });
interceptNetConnect({ context, validateRequest });
interceptHttp({ context, validateRequest });
interceptHttps({ context, validateRequest });
interceptTlsConnect({ context, validateRequest });

return { requestPool, torIdentities };
};
28 changes: 24 additions & 4 deletions packages/request-manager/src/interceptor/interceptHttp.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import http from 'http';
import https from 'https';

import { InterceptorContext } from './interceptorTypesAndUtils';
import { Interceptor } from './interceptorTypesAndUtils';
import { overloadHttpRequest } from './overloadHttpRequest';
import { overloadWebsocketHandshake } from './overloadWebsocketHandshake';

export const interceptHttp = (context: InterceptorContext) => {
export const interceptHttp: Interceptor = ({ context, validateRequest }) => {
const originalHttpRequest = http.request;

http.request = (...args) => {
const overload = overloadHttpRequest(context, 'http', ...args);
const [url, options, callback] = args;

const overload = overloadHttpRequest({
context,
protocol: 'http',
url,
options,
callback,
validateRequest,
});

if (overload) {
const [identity, ...overloadedArgs] = overload;

Expand All @@ -23,7 +33,17 @@ export const interceptHttp = (context: InterceptorContext) => {
const originalHttpGet = http.get;

http.get = (...args) => {
const overload = overloadWebsocketHandshake(context, 'http', ...args);
const [url, options, callback] = args;

const overload = overloadWebsocketHandshake({
context,
protocol: 'http',
url,
options,
callback,
validateRequest,
});

if (overload) {
const [identity, ...overloadedArgs] = overload;

Expand Down
28 changes: 24 additions & 4 deletions packages/request-manager/src/interceptor/interceptHttps.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import https from 'https';

import { InterceptorContext } from './interceptorTypesAndUtils';
import { Interceptor } from './interceptorTypesAndUtils';
import { overloadHttpRequest } from './overloadHttpRequest';
import { overloadWebsocketHandshake } from './overloadWebsocketHandshake';

export const interceptHttps = (context: InterceptorContext) => {
export const interceptHttps: Interceptor = ({ context, validateRequest }) => {
const originalHttpsRequest = https.request;

https.request = (...args) => {
const overload = overloadHttpRequest(context, 'https', ...args);
const [url, options, callback] = args;

const overload = overloadHttpRequest({
context,
protocol: 'https',
url,
options,
callback,
validateRequest,
});

if (overload) {
const [identity, ...overloadedArgs] = overload;

Expand All @@ -22,7 +32,17 @@ export const interceptHttps = (context: InterceptorContext) => {
const originalHttpsGet = https.get;

https.get = (...args) => {
const overload = overloadWebsocketHandshake(context, 'https', ...args);
const [url, options, callback] = args;

const overload = overloadWebsocketHandshake({
context,
protocol: 'https',
url,
options,
callback,
validateRequest,
});

if (overload) {
const [identity, ...overloadedArgs] = overload;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import net from 'net';

import { InterceptorContext } from './interceptorTypesAndUtils';
import { Interceptor } from './interceptorTypesAndUtils';

export const interceptNetConnect = (context: InterceptorContext) => {
export const interceptNetConnect: Interceptor = ({ context, validateRequest }) => {
const originalConnect = net.connect;

net.connect = function (...args) {
Expand All @@ -23,6 +23,9 @@ export const interceptNetConnect = (context: InterceptorContext) => {
details = typeof callback === 'string' ? `${callback}:${options}` : options.toString();
}

const hostname = details.split(':')[0];
validateRequest({ hostname });

context.handler({
type: 'INTERCEPTED_REQUEST',
method: 'net.connect',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import net from 'net';

import { InterceptorContext } from './interceptorTypesAndUtils';
import { Interceptor } from './interceptorTypesAndUtils';

export const interceptNetSocketConnect = (context: InterceptorContext) => {
export const interceptNetSocketConnect: Interceptor = ({ context, validateRequest }) => {
// To avoid disclosure that the request was sent by trezor-suite
// remove headers added by underlying libs before they are sent to the server.
// 1. nodejs http always(!) adds "Connection: close" header
// https://github.com/nodejs/node/blob/e48763840625c037282681456ecd1e1cb034f636/lib/_http_outgoing.js#L508-L510
// 2. node-fetch always(!) adds "User-Agent", "Accept", "Connection"...
// https://github.com/node-fetch/node-fetch/blob/7b86e946b02dfdd28f4f8fca3d73a022cbb5ca1e/src/request.js#L226
const originalSocketWrite = net.Socket.prototype.write;

net.Socket.prototype.write = function (data, ...args) {
const overloadedHeaders: string[] = [];

if (typeof data === 'string' && /Allowed-Headers/gi.test(data)) {
const headers = data.split('\r\n');
const allowedHeaders = headers
Expand Down Expand Up @@ -50,6 +52,7 @@ export const interceptNetSocketConnect = (context: InterceptorContext) => {

net.Socket.prototype.connect = function (...args) {
const [options, callback] = args;

let request: typeof options;
let details: string;
if (Array.isArray(options)) {
Expand All @@ -73,6 +76,9 @@ export const interceptNetSocketConnect = (context: InterceptorContext) => {
details = typeof callback === 'string' ? `${callback}:${request}` : request.toString();
}

const hostname = details.split(':')[0];
validateRequest({ hostname });

context.handler({
type: 'INTERCEPTED_REQUEST',
method: 'net.Socket.connect',
Expand Down
34 changes: 26 additions & 8 deletions packages/request-manager/src/interceptor/interceptTlsConnect.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
import tls from 'tls';

import { InterceptorContext, isWhitelistedHost } from './interceptorTypesAndUtils';
import { Interceptor, isWhitelistedHost } from './interceptorTypesAndUtils';

export const interceptTlsConnect = (context: InterceptorContext) => {
export const interceptTlsConnect: Interceptor = ({ context, validateRequest }) => {
const originalTlsConnect = tls.connect;

tls.connect = (...args) => {
const [options] = args;
if (typeof options === 'object') {
const [optionsOrPort, optionsOrHost] = args;
if (typeof optionsOrPort === 'object') {
context.handler({
type: 'INTERCEPTED_REQUEST',
method: 'tls.connect',
details: options.host || options.servername || 'unknown',
details: optionsOrPort.host || optionsOrPort.servername || 'unknown',
});

// allow untrusted/self-signed certificates for whitelisted domains (like https://*.sldev.cz)
options.rejectUnauthorized =
options.rejectUnauthorized ??
!isWhitelistedHost(options.host, context.notRequiredTorDomainsList);
optionsOrPort.rejectUnauthorized =
optionsOrPort.rejectUnauthorized ??
!isWhitelistedHost(optionsOrPort.host, context.notRequiredTorDomainsList);
}

const getHostname = (): string => {
if (typeof optionsOrPort === 'object') {
return optionsOrPort.host || optionsOrPort.servername || 'unknown';
}

if (typeof optionsOrHost === 'string') {
return optionsOrHost;
}

return '';
};

const hostname = getHostname().split(':')[0] ?? '';

// This is here for defensive reasons, the original (AFAIK) uses net.connect to create
// new socket, and it already contains the interception logic. But to be 100% lets do check here as well.
validateRequest({ hostname });

return originalTlsConnect(...(args as Parameters<typeof tls.connect>));
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,27 @@ export type InterceptorContext = InterceptorOptions & {
torIdentities: TorIdentities;
};

export type Interceptor = (params: {
context: InterceptorContext;
validateRequest: ({ hostname }: { hostname: string }) => void;
}) => void;

export const isWhitelistedHost = (
hostname: unknown,
whitelist: string[] = ['127.0.0.1', 'localhost'],
) =>
typeof hostname === 'string' &&
whitelist.some(url => url === hostname || hostname.endsWith(url));
) => {
if (typeof hostname !== 'string') {
return false; // Defensively block the request
}

if (hostname.trim() === '') {
return false; // Defensively block the request
}

return whitelist.some(
whitelistedUrl =>
whitelistedUrl === hostname ||
// Todo: this .endsWith() seems fishy to me, why we need this?
hostname.endsWith(whitelistedUrl),
);
};
Loading

0 comments on commit 22d43d9

Please sign in to comment.