diff --git a/packages/request-manager/README.md b/packages/request-manager/README.md index f28d251351c..4062b8429f3 100644 --- a/packages/request-manager/README.md +++ b/packages/request-manager/README.md @@ -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 diff --git a/packages/request-manager/e2e/identities-stress.ts b/packages/request-manager/e2e/identities-stress.ts index faad13c90c0..0e32a448a56 100644 --- a/packages/request-manager/e2e/identities-stress.ts +++ b/packages/request-manager/e2e/identities-stress.ts @@ -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: @@ -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 }), }; @@ -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. diff --git a/packages/request-manager/e2e/interceptor.test.ts b/packages/request-manager/e2e/interceptor.test.ts index 5186a783a83..17567fc0078 100644 --- a/packages/request-manager/e2e/interceptor.test.ts +++ b/packages/request-manager/e2e/interceptor.test.ts @@ -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; @@ -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, diff --git a/packages/request-manager/package.json b/packages/request-manager/package.json index f4ec89d00f9..45d9a310fdb 100644 --- a/packages/request-manager/package.json +++ b/packages/request-manager/package.json @@ -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" @@ -17,6 +18,7 @@ }, "devDependencies": { "@trezor/eslint": "workspace:*", + "node-fetch": "^2.6.4", "ts-node": "^10.9.1", "ws": "^8.18.0" } diff --git a/packages/request-manager/src/interceptor.ts b/packages/request-manager/src/interceptor.ts index 81d28932b3f..df444371161 100644 --- a/packages/request-manager/src/interceptor.ts +++ b/packages/request-manager/src/interceptor.ts @@ -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 }; }; diff --git a/packages/request-manager/src/interceptor/interceptHttp.ts b/packages/request-manager/src/interceptor/interceptHttp.ts index fd094261f2f..d67d2a78f93 100644 --- a/packages/request-manager/src/interceptor/interceptHttp.ts +++ b/packages/request-manager/src/interceptor/interceptHttp.ts @@ -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; @@ -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; diff --git a/packages/request-manager/src/interceptor/interceptHttps.ts b/packages/request-manager/src/interceptor/interceptHttps.ts index e4f78a77169..7df3a8e5e13 100644 --- a/packages/request-manager/src/interceptor/interceptHttps.ts +++ b/packages/request-manager/src/interceptor/interceptHttps.ts @@ -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; @@ -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; diff --git a/packages/request-manager/src/interceptor/interceptNetConnect.ts b/packages/request-manager/src/interceptor/interceptNetConnect.ts index 85e32173608..f1fb0412cb1 100644 --- a/packages/request-manager/src/interceptor/interceptNetConnect.ts +++ b/packages/request-manager/src/interceptor/interceptNetConnect.ts @@ -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) { @@ -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', diff --git a/packages/request-manager/src/interceptor/interceptNetSocketConnect.ts b/packages/request-manager/src/interceptor/interceptNetSocketConnect.ts index 4ad0d651311..9763977a189 100644 --- a/packages/request-manager/src/interceptor/interceptNetSocketConnect.ts +++ b/packages/request-manager/src/interceptor/interceptNetSocketConnect.ts @@ -1,8 +1,8 @@ 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 @@ -10,8 +10,10 @@ export const interceptNetSocketConnect = (context: InterceptorContext) => { // 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 @@ -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)) { @@ -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', diff --git a/packages/request-manager/src/interceptor/interceptTlsConnect.ts b/packages/request-manager/src/interceptor/interceptTlsConnect.ts index 1537a704e66..8f33c89672b 100644 --- a/packages/request-manager/src/interceptor/interceptTlsConnect.ts +++ b/packages/request-manager/src/interceptor/interceptTlsConnect.ts @@ -1,25 +1,44 @@ 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 `tls.connect` implementation (AFAIK) + // uses net.connect to create new socket, and it already contains the interception logic. + // But to be 100% sure, lets do the check here as well. + validateRequest({ hostname }); + return originalTlsConnect(...(args as Parameters)); }; }; diff --git a/packages/request-manager/src/interceptor/interceptorTypesAndUtils.ts b/packages/request-manager/src/interceptor/interceptorTypesAndUtils.ts index dcebb6682d1..775cb024c0b 100644 --- a/packages/request-manager/src/interceptor/interceptorTypesAndUtils.ts +++ b/packages/request-manager/src/interceptor/interceptorTypesAndUtils.ts @@ -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), + ); +}; diff --git a/packages/request-manager/src/interceptor/overloadHttpRequest.ts b/packages/request-manager/src/interceptor/overloadHttpRequest.ts index 67b6d8b3061..1a047bbe9bd 100644 --- a/packages/request-manager/src/interceptor/overloadHttpRequest.ts +++ b/packages/request-manager/src/interceptor/overloadHttpRequest.ts @@ -27,22 +27,36 @@ const getIdentityForAgent = (options: Readonly) => { } }; +type OverloadHttpRequestParams = { + context: InterceptorContext; + protocol: 'http' | 'https'; + url: string | URL | http.RequestOptions; + options?: http.RequestOptions | ((r: http.IncomingMessage) => void); + callback?: unknown; + validateRequest: (params: { hostname: string }) => void; +}; + /** * http(s).request could have different arguments according to its types definition, * but we only care when second argument (url) is object containing RequestOptions. */ -export const overloadHttpRequest = ( - context: InterceptorContext, - protocol: 'http' | 'https', - url: string | URL | http.RequestOptions, - options?: http.RequestOptions | ((r: http.IncomingMessage) => void), - callback?: unknown, -) => { +export const overloadHttpRequest = ({ + context, + protocol, + url, + options, + callback, + validateRequest, +}: OverloadHttpRequestParams) => { + const hostname = typeof url === 'object' ? url.hostname ?? url.host ?? '' : ''; + + validateRequest({ hostname }); + if ( !callback && typeof url === 'object' && 'headers' in url && - !isWhitelistedHost(url.hostname, context.notRequiredTorDomainsList) && + !isWhitelistedHost(hostname, context.notRequiredTorDomainsList) && (!options || typeof options === 'function') ) { const isTorEnabled = context.getTorSettings().running; diff --git a/packages/request-manager/src/interceptor/overloadWebsocketHandshake.ts b/packages/request-manager/src/interceptor/overloadWebsocketHandshake.ts index 85401ca7cc9..b3ef06a4593 100644 --- a/packages/request-manager/src/interceptor/overloadWebsocketHandshake.ts +++ b/packages/request-manager/src/interceptor/overloadWebsocketHandshake.ts @@ -3,13 +3,23 @@ import http from 'http'; import { InterceptorContext, isWhitelistedHost } from './interceptorTypesAndUtils'; import { overloadHttpRequest } from './overloadHttpRequest'; -export const overloadWebsocketHandshake = ( - context: InterceptorContext, - protocol: 'http' | 'https', - url: string | URL | http.RequestOptions, - options?: http.RequestOptions | ((r: http.IncomingMessage) => void), - callback?: unknown, -) => { +type OverloadWebsocketHandshakeParams = { + context: InterceptorContext; + protocol: 'http' | 'https'; + url: string | URL | http.RequestOptions; + options?: http.RequestOptions | ((r: http.IncomingMessage) => void); + callback?: unknown; + validateRequest: (params: { hostname: string }) => void; +}; + +export const overloadWebsocketHandshake = ({ + context, + protocol, + url, + options, + callback, + validateRequest, +}: OverloadWebsocketHandshakeParams) => { // @trezor/blockchain-link is adding an SocksProxyAgent to each connection // related to https://github.com/trezor/trezor-suite/issues/7689 // this condition should be removed once suite will stop using TrezorConnect.setProxy @@ -20,12 +30,13 @@ export const overloadWebsocketHandshake = ( ) { delete url.agent; } + if ( typeof url === 'object' && !isWhitelistedHost(url.host, context.notRequiredTorDomainsList) && // difference between overloadHttpRequest 'headers' in url && url.headers?.Upgrade === 'websocket' ) { - return overloadHttpRequest(context, protocol, url, options, callback); + return overloadHttpRequest({ context, protocol, url, options, callback, validateRequest }); } }; diff --git a/packages/request-manager/src/types.ts b/packages/request-manager/src/types.ts index 91254f8b490..33cd106849a 100644 --- a/packages/request-manager/src/types.ts +++ b/packages/request-manager/src/types.ts @@ -57,6 +57,15 @@ export type InterceptedEvent = | { type: 'ERROR'; error: Error; + } + | { + type: 'SET_WHITELISTED_DOMAINS_FOR_CUSTOM_BACKENDS'; + coin: string; + domains: string[]; + } + | { + type: 'ADD_WHITELISTED_DOMAIN'; + domain: string; }; export type TorSettings = { @@ -70,6 +79,7 @@ export type InterceptorOptions = { getTorSettings: () => TorSettings; allowTorBypass?: boolean; notRequiredTorDomainsList?: string[]; + getWhitelistedDomains: () => string[]; }; export const TOR_CONTROLLER_STATUS = { diff --git a/packages/request-manager/tests/interceptor-whitelist.test.ts b/packages/request-manager/tests/interceptor-whitelist.test.ts new file mode 100644 index 00000000000..77d33e51f66 --- /dev/null +++ b/packages/request-manager/tests/interceptor-whitelist.test.ts @@ -0,0 +1,136 @@ +import WebSocket from 'ws'; +import fetch from 'node-fetch'; +import net, { Socket } from 'net'; +import tls, { TLSSocket } from 'tls'; + +import { createInterceptor } from '../src'; +import { InterceptorOptions } from '../src/types'; + +const WHITELISTED_DOMAIN = 'tbtc1.trezor.io'; +const NOT_WHITELISTED_DOMAIN = 'tbtc2.trezor.io'; + +const createWebSocket = (url: string) => + new Promise((resolve, reject) => { + const ws = new WebSocket(url, { + headers: { 'User-Agent': 'Trezor Suite' }, + }); + ws.on('open', () => { + ws.close(); + resolve(); + }); + ws.on('error', reject); + }); + +type AnySocket = Socket | TLSSocket; + +const promisifySocket = (host: string, port: number, socket: T) => + new Promise((resolve, reject) => { + const errorHandler = (err: Error) => reject(err); + socket.on('error', errorHandler); + socket.connect(port, host, () => resolve(socket)); + }); + +const openTcpSocket = (host: string, port: number) => promisifySocket(host, port, new Socket()); + +const openTlsSocket = (host: string, port: number) => + promisifySocket(host, port, new Socket(null as any /* TODO omg why? */)); + +const openNetSocket = (host: string, port: number) => promisifySocket(host, port, new net.Socket()); + +const performNetConnect = (host: string, port: number) => + new Promise((resolve, reject) => { + const socket = net.connect(port, host); + socket.on('error', (err: Error) => reject(err)); + socket.on('connect', () => resolve(socket)); + }); + +const performTlsConnect = (host: string, port: number) => + new Promise((resolve, reject) => { + const socket = tls.connect(port, host); + socket.on('error', (err: Error) => reject(err)); + socket.on('connect', () => resolve(socket)); + }); + +describe('Interceptor', () => { + const torSettings = { running: false }; + + const interceptorOptions: InterceptorOptions = { + getWhitelistedDomains: () => [WHITELISTED_DOMAIN], + handler: () => {}, + getTorSettings: () => torSettings, + }; + + beforeAll(() => { + createInterceptor(interceptorOptions); + }); + + it('Blocks websocket connections', async () => { + await createWebSocket(`wss://${WHITELISTED_DOMAIN}/websocket`); + + await expect(createWebSocket(`wss://${NOT_WHITELISTED_DOMAIN}/websocket`)).rejects.toThrow( + `Request blocked, not whitelisted domain: ${NOT_WHITELISTED_DOMAIN}`, + ); + }); + + ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].forEach(method => { + it(`Blocks all not whitelisted requests for: ${method}`, async () => { + await expect( + fetch(`https://${WHITELISTED_DOMAIN}/`, { method }), + ).resolves.toBeDefined(); + + await expect(fetch(`https://${NOT_WHITELISTED_DOMAIN}/`, { method })).rejects.toThrow( + `Request blocked, not whitelisted domain: ${NOT_WHITELISTED_DOMAIN}`, + ); + }); + }); + + it('blocks the TCP connection', async () => { + (await openTcpSocket(WHITELISTED_DOMAIN, 80)).end(); + + await expect(openTcpSocket(NOT_WHITELISTED_DOMAIN, 80)).rejects.toThrow( + `Request blocked, not whitelisted domain: ${NOT_WHITELISTED_DOMAIN}`, + ); + }); + + it('blocks the TLS connection', async () => { + (await openTlsSocket(WHITELISTED_DOMAIN, 80)).end(); + + await expect(openTlsSocket(NOT_WHITELISTED_DOMAIN, 80)).rejects.toThrow( + `Request blocked, not whitelisted domain: ${NOT_WHITELISTED_DOMAIN}`, + ); + }); + + it('blocks net.Socket', async () => { + (await openNetSocket(WHITELISTED_DOMAIN, 80)).end(); + + await expect(openNetSocket(NOT_WHITELISTED_DOMAIN, 80)).rejects.toThrow( + `Request blocked, not whitelisted domain: ${NOT_WHITELISTED_DOMAIN}`, + ); + }); + + it('blocks net.connect', async () => { + (await performNetConnect(WHITELISTED_DOMAIN, 80)).end(); + + try { + await performNetConnect(NOT_WHITELISTED_DOMAIN, 80); + expect('').toBe('Should throw an error'); + } catch (error) { + expect(error.message).toBe( + `Request blocked, not whitelisted domain: ${NOT_WHITELISTED_DOMAIN}`, + ); + } + }); + + it('blocks tls.connect', async () => { + (await performTlsConnect(WHITELISTED_DOMAIN, 80)).end(); + + try { + await performTlsConnect(NOT_WHITELISTED_DOMAIN, 80); + expect('').toBe('Should throw an error'); + } catch (error) { + expect(error.message).toBe( + `Request blocked, not whitelisted domain: ${NOT_WHITELISTED_DOMAIN}`, + ); + } + }); +}); diff --git a/packages/suite-desktop-core/src/modules/coinjoin.ts b/packages/suite-desktop-core/src/modules/coinjoin.ts index c2fa8b4d379..11ba157016c 100644 --- a/packages/suite-desktop-core/src/modules/coinjoin.ts +++ b/packages/suite-desktop-core/src/modules/coinjoin.ts @@ -117,6 +117,13 @@ export const init: ModuleInit = ({ mainWindowProxy, store, mainThreadEmitter }) const clientProxyOptions: IpcProxyHandlerOptions = { onCreateInstance: async (settings: ConstructorParameters[0]) => { + const urlObj = new URL(settings.coordinatorUrl); + + mainThreadEmitter.emit('module/request-interceptor', { + type: 'ADD_WHITELISTED_DOMAIN', + domain: urlObj.hostname ?? urlObj.host, + }); + const coinjoinMiddleware = await synchronize(getCoinjoinProcess); const port = coinjoinMiddleware.getPort(); // override default url in coinjoin settings diff --git a/packages/suite-desktop-core/src/modules/request-interceptor.ts b/packages/suite-desktop-core/src/modules/request-interceptor.ts index 3165b7c9135..6e5877dfe75 100644 --- a/packages/suite-desktop-core/src/modules/request-interceptor.ts +++ b/packages/suite-desktop-core/src/modules/request-interceptor.ts @@ -11,32 +11,71 @@ import { createInterceptor, InterceptedEvent } from '@trezor/request-manager'; import { isDevEnv } from '@suite-common/suite-utils'; import { TorStatus } from '@trezor/suite-desktop-api'; +import { allowedDomains } from '../config'; + import type { ModuleInit } from './index'; export const SERVICE_NAME = 'request-interceptor'; +/** + * Main reason for this whitelist is to mitigate potential dependency attack, + * where malicious dependency could send requests and potentially spy on the user, etc... + */ +const mainThreadAllowedDomain = { + general: [...allowedDomains], + customBackends: {} as Record, + coinjoinCoordinatorUrl: undefined as string | undefined, +}; + export const init: ModuleInit = ({ mainWindowProxy, store, mainThreadEmitter }) => { const { logger } = global; - const requestInterceptorEventHandler = (event: InterceptedEvent) => { - if (event.type === 'INTERCEPTED_REQUEST') { - logger.debug(SERVICE_NAME, `${event.method} - ${event.details}`); - } - if (event.type === 'INTERCEPTED_RESPONSE') { - logger.debug( - SERVICE_NAME, - `request to ${event.host} took ${event.time}ms and responded with status code ${event.statusCode}`, - ); - } - if (event.type === 'NETWORK_MISBEHAVING') { - logger.debug(SERVICE_NAME, 'networks is misbehaving'); - mainWindowProxy.getInstance()?.webContents.send('tor/status', { - type: TorStatus.Misbehaving, - }); - } + const requestInterceptorEventHandler = (event: InterceptedEvent): void => { + switch (event.type) { + case 'INTERCEPTED_REQUEST': + logger.debug(SERVICE_NAME, `${event.method} - ${event.details}`); + + return; + + case 'INTERCEPTED_RESPONSE': + logger.debug( + SERVICE_NAME, + `request to ${event.host} took ${event.time}ms and responded with status code ${event.statusCode}`, + ); + + return; + + case 'NETWORK_MISBEHAVING': + logger.debug(SERVICE_NAME, 'networks is misbehaving'); + mainWindowProxy.getInstance()?.webContents.send('tor/status', { + type: TorStatus.Misbehaving, + }); + + return; + + case 'CIRCUIT_MISBEHAVING': + mainThreadEmitter.emit('module/reset-tor-circuits', event); + + return; + + case 'INTERCEPTED_HEADERS': + case 'ERROR': + return; + + case 'SET_WHITELISTED_DOMAINS_FOR_CUSTOM_BACKENDS': + mainThreadAllowedDomain.customBackends[event.coin] = event.domains; + + return; + + case 'ADD_WHITELISTED_DOMAIN': + mainThreadAllowedDomain.general.push(event.domain); + + return; - if (event.type === 'CIRCUIT_MISBEHAVING') { - mainThreadEmitter.emit('module/reset-tor-circuits', event); + default: { + const _exhaustiveCheck: never = event; // Poor-man's `switch-exhaustiveness-check` + throw new Error('Unhandled case: ' + _exhaustiveCheck); + } } }; @@ -48,5 +87,9 @@ export const init: ModuleInit = ({ mainWindowProxy, store, mainThreadEmitter }) getTorSettings: () => store.getTorSettings(), allowTorBypass: isDevEnv, notRequiredTorDomainsList: ['127.0.0.1', 'localhost', '.sldev.cz'], + getWhitelistedDomains: () => [ + ...mainThreadAllowedDomain.general, + ...Object.values(mainThreadAllowedDomain.customBackends).flat(), + ], }); }; diff --git a/packages/suite-desktop-core/src/modules/trezor-connect.ts b/packages/suite-desktop-core/src/modules/trezor-connect.ts index 8ace4b9776e..b66158571b5 100644 --- a/packages/suite-desktop-core/src/modules/trezor-connect.ts +++ b/packages/suite-desktop-core/src/modules/trezor-connect.ts @@ -2,11 +2,43 @@ import { ipcMain } from 'electron'; import TrezorConnect, { DEVICE_EVENT } from '@trezor/connect'; import { createIpcProxyHandler, IpcProxyHandlerOptions } from '@trezor/ipc-proxy'; +import { parseElectrumUrl } from '@trezor/utils'; -import { ModuleInit, ModuleInitBackground } from './index'; +import { MainThreadEmitter, ModuleInit, ModuleInitBackground } from './index'; export const SERVICE_NAME = '@trezor/connect'; +type EmitOnSetCustomBackendToMainThreadToAllowDomainsParams = { + params: Parameters; + mainThreadEmitter: MainThreadEmitter; +}; + +const emitOnSetCustomBackendToMainThreadToAllowDomains = ({ + params, + mainThreadEmitter, +}: EmitOnSetCustomBackendToMainThreadToAllowDomainsParams) => { + const param = params[0]; + + if (param !== undefined && param.blockchainLink !== undefined) { + const domains = (param.blockchainLink.url ?? []).map(url => { + const electrumUrlResult = parseElectrumUrl(url); + if (electrumUrlResult !== undefined) { + return electrumUrlResult.host; + } + + const urlObj = new URL(url); + + return urlObj.hostname ?? urlObj.host; + }); + + mainThreadEmitter.emit('module/request-interceptor', { + type: 'SET_WHITELISTED_DOMAINS_FOR_CUSTOM_BACKENDS', + coin: param.coin, + domains, + }); + } +}; + export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store }) => { const { logger } = global; logger.info(SERVICE_NAME, `Starting service`); @@ -32,6 +64,10 @@ export const initBackground: ModuleInitBackground = ({ mainThreadEmitter, store return response; } + if (method === 'blockchainSetCustomBackend') { + emitOnSetCustomBackendToMainThreadToAllowDomains({ params, mainThreadEmitter }); + } + return (TrezorConnect[method] as any)(...params); }, onAddListener: (eventName, listener) => { diff --git a/packages/suite-desktop-core/src/threads/coinjoin-backend.ts b/packages/suite-desktop-core/src/threads/coinjoin-backend.ts index f763ef965e8..78ed2dcf643 100644 --- a/packages/suite-desktop-core/src/threads/coinjoin-backend.ts +++ b/packages/suite-desktop-core/src/threads/coinjoin-backend.ts @@ -3,6 +3,7 @@ import { CoinjoinBackend, CoinjoinBackendSettings } from '@trezor/coinjoin'; import { isDevEnv } from '@suite-common/suite-utils'; import { createThread } from '../libs/thread'; +import { onionDomain } from '../config'; type BackgroundCoinjoinBackendSettings = CoinjoinBackendSettings & { torSettings: TorSettings; @@ -26,6 +27,12 @@ class BackgroundCoinjoinBackend extends CoinjoinBackend { } } +/** + * For Coinjoin backend we only need to allow our backends to do discovery: + * download block-filters, download blocks and transactions form mempool (via our backends). + */ +const coinjoinWhitelist = ['localhost', '127.0.0.1', 'trezor.io', onionDomain]; + const init = (settings: BackgroundCoinjoinBackendSettings) => { const backend = new BackgroundCoinjoinBackend(settings); @@ -35,6 +42,7 @@ const init = (settings: BackgroundCoinjoinBackendSettings) => { getTorSettings: () => backend.torSettings, allowTorBypass: isDevEnv, notRequiredTorDomainsList: ['127.0.0.1', 'localhost', '.sldev.cz'], + getWhitelistedDomains: () => coinjoinWhitelist, }); return backend; diff --git a/yarn.lock b/yarn.lock index b7a32cb9523..6a8c0415a62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12250,6 +12250,7 @@ __metadata: "@trezor/eslint": "workspace:*" "@trezor/node-utils": "workspace:^" "@trezor/utils": "workspace:*" + node-fetch: "npm:^2.6.4" socks-proxy-agent: "npm:8.0.4" ts-node: "npm:^10.9.1" ws: "npm:^8.18.0"