From 3b99bd8a93bc7b8eac97984838ac1950725bad23 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Tue, 26 May 2026 20:11:30 +0200 Subject: [PATCH] use better dns proxying --- .../services/browser_dns_leak_guard.dart | 109 --------------- .../services/browser_dns_leak_guard.g.dart | 128 ----------------- apps/weblibre/lib/main.dart | 7 - .../src/background/BackgroundMain.ts | 130 ++++++++++-------- .../test/unit/BackgroundMain.test.ts | 52 +++++-- 5 files changed, 111 insertions(+), 315 deletions(-) delete mode 100644 apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.dart delete mode 100644 apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.g.dart diff --git a/apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.dart b/apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.dart deleted file mode 100644 index 22b7bb38..00000000 --- a/apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.dart +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) 2024-2026 Fabian Freund. - * - * This file is part of WebLibre - * (see https://weblibre.eu). - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:weblibre/core/logger.dart'; -import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart'; -import 'package:weblibre/features/user/data/models/engine_settings.dart'; -import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; - -part 'browser_dns_leak_guard.g.dart'; - -/// Watches the proxy runtime and **disables** GeckoView's TRR (sets -/// [DohSettingsMode.off]) while at least one profile is running. -/// -/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via -/// the system network, *before* any SOCKS connection is established — so a -/// DoH lookup leaks the destination outside the proxy even when the data -/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables -/// TRR so GeckoView uses its native resolver, which — combined with -/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the -/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a -/// direct DoH lookup first. -/// -/// The previous TRR mode is captured on engage and restored when all -/// profiles stop. -/// -/// This is a side-effect-only provider: it must be `keepAlive: true` and is -/// explicitly listened-to from main.dart so the side effect runs without any -/// widget needing to depend on it. -@Riverpod(keepAlive: true) -class BrowserDnsLeakGuard extends _$BrowserDnsLeakGuard { - DohSettingsMode? _savedMode; - - @override - Future build() async { - final runtime = ref.watch(singboxProxyRuntimeRepositoryProvider); - - // Skip while a start/stop is in flight. `startProfiles` resets the runtime - // state to `AsyncLoading` for the entire restart — `asData` is briefly - // null, which would otherwise look like "no profiles running" and trigger - // a premature DoH restore in the middle of e.g. starting a second profile - // while one is already active, opening a leak window during the transition. - if (runtime.isLoading) return; - - final anyRunning = runtime.asData?.value.endpoints.isNotEmpty ?? false; - if (anyRunning) { - await _enforceOffMode(); - } else if (_savedMode != null) { - await _restoreSavedMode(); - } - } - - Future _enforceOffMode() async { - final engine = ref.read(engineSettingsRepositoryProvider.notifier); - try { - final current = await engine.fetchSettings(); - if (current.dohSettingsMode == DohSettingsMode.off) { - // Already off — don't capture it as the "saved" value, otherwise we - // would restore it back to off on disengage instead of the user's - // real previous choice. - return; - } - _savedMode = current.dohSettingsMode; - await engine.updateSettings( - (current) => current.copyWith.dohSettingsMode(DohSettingsMode.off), - ); - } catch (error, stack) { - logger.e( - 'browser DNS leak guard failed to disable TRR', - error: error, - stackTrace: stack, - ); - } - } - - Future _restoreSavedMode() async { - final saved = _savedMode; - _savedMode = null; - if (saved == null) return; - try { - await ref - .read(engineSettingsRepositoryProvider.notifier) - .updateSettings((current) => current.copyWith.dohSettingsMode(saved)); - } catch (error, stack) { - logger.e( - 'browser DNS leak guard failed to restore DoH mode', - error: error, - stackTrace: stack, - ); - } - } -} diff --git a/apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.g.dart b/apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.g.dart deleted file mode 100644 index 21b7547f..00000000 --- a/apps/weblibre/lib/features/proxy/domain/services/browser_dns_leak_guard.g.dart +++ /dev/null @@ -1,128 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'browser_dns_leak_guard.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning -/// Watches the proxy runtime and **disables** GeckoView's TRR (sets -/// [DohSettingsMode.off]) while at least one profile is running. -/// -/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via -/// the system network, *before* any SOCKS connection is established — so a -/// DoH lookup leaks the destination outside the proxy even when the data -/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables -/// TRR so GeckoView uses its native resolver, which — combined with -/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the -/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a -/// direct DoH lookup first. -/// -/// The previous TRR mode is captured on engage and restored when all -/// profiles stop. -/// -/// This is a side-effect-only provider: it must be `keepAlive: true` and is -/// explicitly listened-to from main.dart so the side effect runs without any -/// widget needing to depend on it. - -@ProviderFor(BrowserDnsLeakGuard) -final browserDnsLeakGuardProvider = BrowserDnsLeakGuardProvider._(); - -/// Watches the proxy runtime and **disables** GeckoView's TRR (sets -/// [DohSettingsMode.off]) while at least one profile is running. -/// -/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via -/// the system network, *before* any SOCKS connection is established — so a -/// DoH lookup leaks the destination outside the proxy even when the data -/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables -/// TRR so GeckoView uses its native resolver, which — combined with -/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the -/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a -/// direct DoH lookup first. -/// -/// The previous TRR mode is captured on engage and restored when all -/// profiles stop. -/// -/// This is a side-effect-only provider: it must be `keepAlive: true` and is -/// explicitly listened-to from main.dart so the side effect runs without any -/// widget needing to depend on it. -final class BrowserDnsLeakGuardProvider - extends $AsyncNotifierProvider { - /// Watches the proxy runtime and **disables** GeckoView's TRR (sets - /// [DohSettingsMode.off]) while at least one profile is running. - /// - /// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via - /// the system network, *before* any SOCKS connection is established — so a - /// DoH lookup leaks the destination outside the proxy even when the data - /// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables - /// TRR so GeckoView uses its native resolver, which — combined with - /// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the - /// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a - /// direct DoH lookup first. - /// - /// The previous TRR mode is captured on engage and restored when all - /// profiles stop. - /// - /// This is a side-effect-only provider: it must be `keepAlive: true` and is - /// explicitly listened-to from main.dart so the side effect runs without any - /// widget needing to depend on it. - BrowserDnsLeakGuardProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'browserDnsLeakGuardProvider', - isAutoDispose: false, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$browserDnsLeakGuardHash(); - - @$internal - @override - BrowserDnsLeakGuard create() => BrowserDnsLeakGuard(); -} - -String _$browserDnsLeakGuardHash() => - r'366aaa6ae8c163449dca50147be0451c766ba765'; - -/// Watches the proxy runtime and **disables** GeckoView's TRR (sets -/// [DohSettingsMode.off]) while at least one profile is running. -/// -/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via -/// the system network, *before* any SOCKS connection is established — so a -/// DoH lookup leaks the destination outside the proxy even when the data -/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables -/// TRR so GeckoView uses its native resolver, which — combined with -/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the -/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a -/// direct DoH lookup first. -/// -/// The previous TRR mode is captured on engage and restored when all -/// profiles stop. -/// -/// This is a side-effect-only provider: it must be `keepAlive: true` and is -/// explicitly listened-to from main.dart so the side effect runs without any -/// widget needing to depend on it. - -abstract class _$BrowserDnsLeakGuard extends $AsyncNotifier { - FutureOr build(); - @$mustCallSuper - @override - void runBuild() { - final ref = this.ref as $Ref, void>; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, void>, - AsyncValue, - Object?, - Object? - >; - element.handleCreate(ref, build); - } -} diff --git a/apps/weblibre/lib/main.dart b/apps/weblibre/lib/main.dart index e5102ce7..fe21a333 100644 --- a/apps/weblibre/lib/main.dart +++ b/apps/weblibre/lib/main.dart @@ -51,7 +51,6 @@ import 'package:weblibre/features/geckoview/features/preferences/data/repositori import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_settings_sync.dart'; import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart'; -import 'package:weblibre/features/proxy/domain/services/browser_dns_leak_guard.dart'; import 'package:weblibre/features/proxy/domain/services/singbox_proxy_endpoint_sync.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -282,12 +281,6 @@ class _MainWidget extends HookConsumerWidget { // Activate account callback deep link handler ref.read(accountCallbackHandlerProvider); - // Activate the proxy DNS leak guard: when any sing-box profile is - // running AND the user has opted in, GeckoView TRR is forced to - // off so browser DNS follows the SOCKS proxyDNS path instead of - // resolving outside the proxy. - ref.read(browserDnsLeakGuardProvider); - // Mirror the sing-box runtime's SOCKS endpoints into Gecko's // container-proxy registry. Side-effect-only notifier. ref.read(singboxProxyEndpointSyncProvider); diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/BackgroundMain.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/BackgroundMain.ts index 00858469..9c7dc881 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/BackgroundMain.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/BackgroundMain.ts @@ -10,6 +10,7 @@ import _OnBeforeRequestDetails = browser.webRequest._OnBeforeRequestDetails const localhosts = new Set(['localhost', '127.0.0.1', '[::1]']) const containerIdentifier = 'firefox-container-' +const defaultIdentifier = 'firefox-default' const privateIdentifier = 'firefox-private' type DoNotProxy = never[] @@ -32,6 +33,35 @@ export default class BackgroundMain { this.store = store } + private async tabForRequest(tabId?: number): Promise { + if (tabId === undefined || tabId <= -1) { + return null + } + + try { + return await browser.tabs.get(tabId) + } catch (e) { + return null + } + } + + private contextIdFromCookieStoreId(cookieStoreId?: string): string | null { + if (cookieStoreId === undefined || cookieStoreId.length === 0) { + return null + } + if (cookieStoreId.startsWith(containerIdentifier)) { + return cookieStoreId.substring(containerIdentifier.length) + } + if (cookieStoreId === privateIdentifier) { + return 'private' + } + if (cookieStoreId === defaultIdentifier) { + return 'general' + } + + return cookieStoreId + } + /* initializeAuthListener(cookieStoreId: string, proxy: HttpProxySettings | HttpsProxySettings): void { const listener: (details: _OnAuthRequiredDetails) => BlockingResponse = (details) => { @@ -60,72 +90,50 @@ export default class BackgroundMain { */ async onRequest(requestDetails: Pick<_OnRequestDetails, 'cookieStoreId' | 'url' | 'tabId'>): Promise { - const tab = (requestDetails.tabId > -1) ? (await browser.tabs.get(requestDetails.tabId)) : null + try { + const tab = await this.tabForRequest(requestDetails.tabId) + const contextId = this.contextIdFromCookieStoreId(tab?.cookieStoreId ?? requestDetails.cookieStoreId) - if (this.store.hasGeneralRelation() || - tab === null || - tab.cookieStoreId?.startsWith(containerIdentifier) === true || - tab.cookieStoreId === privateIdentifier - ) { - try { - let cookieStoreId: string + if (contextId === null) { + return doNotProxy + } - if (tab?.cookieStoreId?.startsWith(containerIdentifier) === true) { - cookieStoreId = tab.cookieStoreId.substring(containerIdentifier.length) - } else if (tab?.cookieStoreId === privateIdentifier) { - // Handle private tabs - use 'private' as identifier - cookieStoreId = 'private' - } else { - cookieStoreId = 'general' - } - - let proxies = this.store.getProxiesForContainer(cookieStoreId) - if ((proxies?.length ?? 0) == 0 && tab === null) { - //When no tab is specified and general relation doesnt exist, we get all proxies to avoid leakage - proxies = this.store.getAllProxies() - - if (proxies.length == 0) { - return doNotProxy - } - } else if (proxies === null) { - return doNotProxy - } - - if (proxies.length > 0) { - // proxies.forEach(p => { - // if (p.type === ProxyType.Http || p.type === ProxyType.Https) { - // this.initializeAuthListener(cookieStoreId, p) - // } - // }) - - const result: ProxyInfo[] = proxies.filter((p: ProxySettings) => { - try { - const documentUrl = new URL(requestDetails.url) - const isLocalhost = localhosts.has(documentUrl.hostname) - if (isLocalhost && p.doNotProxyLocal) { - return false - } - } catch (e) { - console.error(e) - } - - return true - }).map(p => p.asProxyInfo()) - - if (result.length === 0) { - return [emergencyBreak] - } - return result - } - - return [emergencyBreak] - } catch (e: unknown) { - console.error(`Error in onRequest listener: ${e as string}`) + const proxies = this.store.getProxiesForContainer(contextId) + if (proxies === null) { + return doNotProxy + } + if (proxies.length === 0) { return [emergencyBreak] } - } - return doNotProxy + // proxies.forEach(p => { + // if (p.type === ProxyType.Http || p.type === ProxyType.Https) { + // this.initializeAuthListener(cookieStoreId, p) + // } + // }) + + const result: ProxyInfo[] = proxies.filter((p: ProxySettings) => { + try { + const documentUrl = new URL(requestDetails.url) + const isLocalhost = localhosts.has(documentUrl.hostname) + if (isLocalhost && p.doNotProxyLocal) { + return false + } + } catch (e) { + console.error(e) + } + + return true + }).map(p => p.asProxyInfo()) + + if (result.length === 0) { + return doNotProxy + } + return result + } catch (e: unknown) { + console.error(`Error in onRequest listener: ${e as string}`) + return [emergencyBreak] + } } async onBeforeRequest(options: _OnBeforeRequestDetails, port: browser.runtime.Port): Promise { diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/BackgroundMain.test.ts b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/BackgroundMain.test.ts index e109876d..6756db4e 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/BackgroundMain.test.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/test/unit/BackgroundMain.test.ts @@ -9,11 +9,14 @@ const tryFromDao = ProxySettings.tryFromDao const chrome = require('sinon-chrome/extensions'); -const store = new Store() +let store: Store +let backgroundMain: BackgroundMain describe('BackgroundMain', function () { beforeEach(() => { global.browser = chrome + store = new Store() + backgroundMain = new BackgroundMain({ store }) }) afterEach(() => { @@ -21,10 +24,6 @@ describe('BackgroundMain', function () { delete global.browser }) - const backgroundMain = new BackgroundMain({ store: store }) - - // TODO: Add test for proxyDNS property - describe('onRequest', function () { it('should return empty array if no proxy is set up', async () => { const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: 0 }) @@ -33,7 +32,7 @@ describe('BackgroundMain', function () { }) it('should return proxy if proxy is set up', async () => { - await givenSomeProxyIsSetUpForContainer({ containerId: 'firefox-default', host: undefined, doNotProxyLocal: undefined }) + await givenSomeProxyIsSetUpForContainer({ containerId: 'general', host: undefined, doNotProxyLocal: undefined }) const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: 0 }) @@ -41,6 +40,31 @@ describe('BackgroundMain', function () { expect(result).to.be.not.empty }) + it('should not use an unrelated container proxy for default tabs', async () => { + await givenSomeProxyIsSetUpForContainer({ containerId: 'container1', host: undefined, doNotProxyLocal: undefined }) + + const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: -1 }) + + expect(result).to.be.deep.equal(doNotProxy) + }) + + it('should use request cookieStoreId when no tab is available', async () => { + await givenSomeProxyIsSetUpForContainer({ containerId: 'container1', host: undefined, doNotProxyLocal: undefined }) + + const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-container-container1', url: 'https://google.com', tabId: -1 }) + + expect(result).to.be.an('array') + expect(result).to.be.not.empty + }) + + it('should return empty array for tabless requests without a cookie store', async () => { + await givenSomeProxyIsSetUpForContainer({ containerId: 'container1', host: undefined, doNotProxyLocal: undefined }) + + const result = await backgroundMain.onRequest({ url: 'https://google.com', tabId: -1 }) + + expect(result).to.be.deep.equal(doNotProxy) + }) + it('should block if an assigned proxy no longer exists', async () => { const isolatedStore = new Store() const isolatedBackgroundMain = new BackgroundMain({ store: isolatedStore }) @@ -52,16 +76,24 @@ describe('BackgroundMain', function () { }) it('should remove doNotProxyLocal flag from proxy settings if proxy is set up', async () => { - await givenSomeProxyIsSetUpForContainer({ containerId: 'firefox-default', host: undefined, doNotProxyLocal: undefined }) + await givenSomeProxyIsSetUpForContainer({ containerId: 'general', host: undefined, doNotProxyLocal: undefined }) const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: 0 }) expect((result[0] as any).doNotProxyLocal).to.be.undefined }) + it('should preserve proxyDNS on SOCKS proxy settings', async () => { + await givenSomeProxyIsSetUpForContainer({ containerId: 'container1', host: undefined, doNotProxyLocal: undefined }) + + const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-container-container1', url: 'https://google.com', tabId: -1 }) + + expect((result[0] as any).proxyDNS).to.be.true + }) + it('should return proxy for the container if url is invalid', async () => { // To be more on a safe side - await givenSomeProxyIsSetUpForContainer({ containerId: 'firefox-default', host: undefined, doNotProxyLocal: undefined }) + await givenSomeProxyIsSetUpForContainer({ containerId: 'general', host: undefined, doNotProxyLocal: undefined }) const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'np-protocol-url.com', tabId: 0 }) @@ -88,7 +120,7 @@ describe('BackgroundMain', function () { it(`should return empty array if the address is local: ${url}`, async () => { await givenSomeProxyIsSetUpForContainer({ containerId: 'container1', host: undefined, doNotProxyLocal: true }) - const result = await backgroundMain.onRequest({ cookieStoreId: 'container1', url, tabId: 0 }) + const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-container-container1', url, tabId: -1 }) expect(result).to.be.deep.equal(doNotProxy) }) @@ -101,7 +133,7 @@ describe('BackgroundMain', function () { const host = 'proxyX.example.com' await givenSomeProxyIsSetUpForContainer({ host, containerId: 'container1', doNotProxyLocal: false }) - const result = await backgroundMain.onRequest({ cookieStoreId: 'container1', url, tabId: 0 }) + const result = await backgroundMain.onRequest({ cookieStoreId: 'firefox-container-container1', url, tabId: -1 }) expect(result[0].host).to.be.equal(host) })