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)
})