From 69f0417c51c75ae7f60d5a6e869999d2431f54a1 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 5 Jul 2026 16:09:31 +0200 Subject: [PATCH] container strict mode feature --- .../geckoview/domain/repositories/tab.dart | 8 ++ .../geckoview/domain/repositories/tab.g.dart | 2 +- .../services/proxy_settings_replication.dart | 8 ++ .../proxy_settings_replication.g.dart | 2 +- .../tabs/data/database/daos/container.dart | 4 + .../tabs/data/database/definitions.drift | 34 +++++++++ .../tabs/data/database/definitions.drift.dart | 19 +++++ .../tabs/data/models/container_data.dart | 29 ++++++- .../tabs/data/models/container_data.g.dart | 14 ++++ .../features/tabs/domain/providers.dart | 27 +++++++ .../features/tabs/domain/providers.g.dart | 60 +++++++++++++++ .../presentation/screens/container_edit.dart | 76 +++++++++++++------ .../domain/repositories/container_proxy.dart | 4 + .../repositories/container_proxy.g.dart | 2 +- apps/weblibre/lib/presentation/main_app.dart | 40 +++++++++- .../api/GeckoContainerProxyApiImpl.kt | 4 + .../feature/ContainerProxyFeature.kt | 3 +- .../pigeons/Gecko.g.kt | 48 ++++++++++-- .../src/background/BackgroundMain.ts | 70 +++++++++++++---- .../container_proxy/src/background/index.ts | 7 +- .../container_proxy/src/store/Store.ts | 33 ++++++++ .../services/gecko_container_proxy.dart | 4 + .../lib/src/pigeons/gecko.g.dart | 36 ++++++++- .../pigeons/gecko.dart | 16 ++++ 24 files changed, 498 insertions(+), 52 deletions(-) diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart index 3f9a9864..9e889700 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.dart @@ -900,6 +900,14 @@ class TabRepository extends _$TabRepository { final containerSiteAssignementSub = eventSerivce.siteAssignementEvent.listen( (event) async { + // Strict-mode blocks have no destination container to re-open into; the + // navigation was already cancelled natively and the user is notified via + // a snackbar (see the strict-block listener in the app shell). Nothing + // to reconcile here. + if (event.strict) { + return; + } + final tabId = event.tabId; if (tabId != null) { final tabState = ref.read(tabStatesProvider)[tabId]; diff --git a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart index e972d910..e9caacdc 100644 --- a/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/apps/weblibre/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'982b80b8ea7c694958bc10cc7e8e15310af6a238'; +String _$tabRepositoryHash() => r'dd2f9c776be1d5437141e94dbd0b7ef382093b63'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart index ef61d49f..3772a077 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart @@ -289,6 +289,14 @@ class ProxySettingsReplication extends _$ProxySettingsReplication { } }); + ref.listen(watchStrictContextAssignmentsProvider, (previous, next) async { + if (next.hasValue) { + await ref + .read(containerProxyRepositoryProvider.notifier) + .setStrictContexts(next.requireValue); + } + }); + ref.listen( fireImmediately: true, watchIsolatedContextContainerMapProvider.select( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart index f78ba811..e2acc1c2 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/proxy_settings_replication.g.dart @@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider } String _$proxySettingsReplicationHash() => - r'cdb624b7f7806a7ce4218da350253ed3d29986d0'; + r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83'; abstract class _$ProxySettingsReplication extends $Notifier { void build(); diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart index 3f589ef0..177bc9e1 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/daos/container.dart @@ -259,6 +259,10 @@ class ContainerDao extends DatabaseAccessor return db.definitionsDrift.containersToClearOnExit(); } + Selectable strictContextAssignments() { + return db.definitionsDrift.strictContextAssignments(); + } + SingleOrNullSelectable getContainerByContextualIdentity( String contextId, ) { diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift index 6edf6f22..0f861c8d 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -969,6 +969,40 @@ containersToClearOnExit: json_extract(container.metadata, '$.clearDataOnExit') = 1 AND container.metadata ->> '$.contextualIdentity' IS NOT NULL; +-- Strict-mode enforcement map for the container-proxy extension. Each row maps +-- a Gecko cookie-store context (`context_id`) that must be enforced to the +-- container's base contextualIdentity (`assignment_context_id`) that its site +-- assignments are keyed on. In strict mode a navigation is only allowed when +-- the target origin is assigned to `assignment_context_id` exactly (no proxy +-- equivalence). +-- +-- Two sources, unioned: +-- 1. The strict container's own base context (context_id == assignment). +-- 2. Every isolation context in use by an isolated tab (tab_mode = 2) whose +-- container is strict: those tabs load under `isolation_context_id` as +-- their cookie store, but their assignments live on the container's base +-- context, so they map isolation_context_id -> base context. +-- Null contextualIdentity is excluded: strictness needs a Gecko contextId. +strictContextAssignments: + SELECT + container.metadata ->> '$.contextualIdentity' AS context_id, + container.metadata ->> '$.contextualIdentity' AS assignment_context_id + FROM container + WHERE + json_extract(container.metadata, '$.strictMode') = 1 + AND container.metadata ->> '$.contextualIdentity' IS NOT NULL + UNION + SELECT DISTINCT + t.isolation_context_id AS context_id, + c.metadata ->> '$.contextualIdentity' AS assignment_context_id + FROM tab t + INNER JOIN container c ON c.id = t.container_id + WHERE + t.tab_mode = 2 + AND t.isolation_context_id IS NOT NULL + AND json_extract(c.metadata, '$.strictMode') = 1 + AND c.metadata ->> '$.contextualIdentity' IS NOT NULL; + tabsInIsolationGroup: SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = :contextId; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index f232d3c1..4c9970cc 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -6695,6 +6695,19 @@ class DefinitionsDrift extends i9.ModularAccessor { ).map((i0.QueryRow row) => row.readNullable('contextual_identity')); } + i0.Selectable strictContextAssignments() { + return customSelect( + 'SELECT container.metadata ->> \'\$.contextualIdentity\' AS context_id, container.metadata ->> \'\$.contextualIdentity\' AS assignment_context_id FROM container WHERE json_extract(container.metadata, \'\$.strictMode\') = 1 AND container.metadata ->> \'\$.contextualIdentity\' IS NOT NULL UNION SELECT DISTINCT t.isolation_context_id AS context_id, c.metadata ->> \'\$.contextualIdentity\' AS assignment_context_id FROM tab AS t INNER JOIN container AS c ON c.id = t.container_id WHERE t.tab_mode = 2 AND t.isolation_context_id IS NOT NULL AND json_extract(c.metadata, \'\$.strictMode\') = 1 AND c.metadata ->> \'\$.contextualIdentity\' IS NOT NULL', + variables: [], + readsFrom: {container, tab}, + ).map( + (i0.QueryRow row) => StrictContextAssignmentsResult( + contextId: row.readNullable('context_id'), + assignmentContextId: row.readNullable('assignment_context_id'), + ), + ); + } + i0.Selectable tabsInIsolationGroup({String? contextId}) { return customSelect( 'SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = ?1', @@ -6789,6 +6802,12 @@ class ContainerIdsByContextualIdentitiesResult { }); } +class StrictContextAssignmentsResult { + final String? contextId; + final String? assignmentContextId; + StrictContextAssignmentsResult({this.contextId, this.assignmentContextId}); +} + class IsolatedContextContainerPairsResult { final String? isolationContextId; final String? containerId; diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart index a5d45cdf..9369c040 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.dart @@ -73,6 +73,16 @@ class ContainerMetadata with FastEquatable { final List? assignedSites; + // When true, tabs in this container may only load origins listed in + // [assignedSites]; any other top-level navigation is blocked. Read on the + // native side via `json_extract(metadata, '$.strictMode')` (see the + // `strictContextAssignments` query in definitions.drift) and pushed to the + // container-proxy web extension. Requires a Gecko contextId — the extension + // keys strictness on the tab's cookieStoreId — so it is normalized to false + // when [contextualIdentity] is null (mirrors [excludeFromHistory]). + @JsonKey(defaultValue: false) + final bool strictMode; + ContainerMetadata({ required this.iconData, required this.contextualIdentity, @@ -83,6 +93,7 @@ class ContainerMetadata with FastEquatable { required this.bypassGlobalProxy, required this.useCustomColor, required this.assignedSites, + required this.strictMode, }); ContainerMetadata.withDefaults({ @@ -95,6 +106,7 @@ class ContainerMetadata with FastEquatable { bool? bypassGlobalProxy, bool? useCustomColor, List? assignedSites, + bool? strictMode, }) : this( iconData: iconData, contextualIdentity: contextualIdentity, @@ -112,6 +124,10 @@ class ContainerMetadata with FastEquatable { bypassGlobalProxy: bypassGlobalProxy ?? false, useCustomColor: useCustomColor ?? false, assignedSites: assignedSites, + // Strict mode needs a contextId (the extension keys on cookieStoreId); + // normalize away the invalid combination on read, and writers re-apply + // it via [sanitized]. + strictMode: (strictMode ?? false) && contextualIdentity != null, ); /// Enforce the [excludeFromHistory] invariant before persistence: it can only @@ -120,10 +136,16 @@ class ContainerMetadata with FastEquatable { /// The primary constructor can't normalize (copy_with_extension_gen requires /// params to map 1:1 to fields), so writers route through this. ContainerMetadata sanitized() { - if (excludeFromHistory && contextualIdentity == null) { - return copyWith(excludeFromHistory: false); + var result = this; + if (result.excludeFromHistory && result.contextualIdentity == null) { + result = result.copyWith(excludeFromHistory: false); } - return this; + // Strict mode is meaningless without a contextId: the extension keys + // strictness on the tab's cookieStoreId. + if (result.strictMode && result.contextualIdentity == null) { + result = result.copyWith(strictMode: false); + } + return result; } bool get usesTorProxy => proxyConnectionId is TorProxyConnectionId; @@ -144,6 +166,7 @@ class ContainerMetadata with FastEquatable { bypassGlobalProxy, useCustomColor, assignedSites, + strictMode, ]; } diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart index bce40c22..de2732e2 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/data/models/container_data.g.dart @@ -25,6 +25,8 @@ abstract class _$ContainerMetadataCWProxy { ContainerMetadata assignedSites(List? assignedSites); + ContainerMetadata strictMode(bool strictMode); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`. /// @@ -42,6 +44,7 @@ abstract class _$ContainerMetadataCWProxy { bool bypassGlobalProxy, bool useCustomColor, List? assignedSites, + bool strictMode, }); } @@ -87,6 +90,9 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ContainerMetadata assignedSites(List? assignedSites) => call(assignedSites: assignedSites); + @override + ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ContainerMetadata(...).copyWith.fieldName(value)`. @@ -105,6 +111,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { Object? bypassGlobalProxy = const $CopyWithPlaceholder(), Object? useCustomColor = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(), + Object? strictMode = const $CopyWithPlaceholder(), }) { return ContainerMetadata( iconData: iconData == const $CopyWithPlaceholder() @@ -153,6 +160,11 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy { ? _value.assignedSites // ignore: cast_nullable_to_non_nullable : assignedSites as List?, + strictMode: + strictMode == const $CopyWithPlaceholder() || strictMode == null + ? _value.strictMode + // ignore: cast_nullable_to_non_nullable + : strictMode as bool, ); } } @@ -295,6 +307,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map json) => assignedSites: (json['assignedSites'] as List?) ?.map((e) => Uri.parse(e as String)) .toList(), + strictMode: json['strictMode'] as bool? ?? false, ); Map _$ContainerMetadataToJson( @@ -312,6 +325,7 @@ Map _$ContainerMetadataToJson( 'bypassGlobalProxy': instance.bypassGlobalProxy, 'useCustomColor': instance.useCustomColor, 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), + 'strictMode': instance.strictMode, }; Value? _$JsonConverterFromJson( diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart index b1478672..3f7a5c9d 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.dart @@ -190,6 +190,33 @@ Stream> watchAllAssignedSites(Ref ref) { return db.containerDao.allAssignedSites().watch(); } +/// Strict-mode enforcement map: each Gecko cookie-store context that must be +/// enforced (a strict container's base context, plus the isolation contexts of +/// its isolated tabs) mapped to the base contextualIdentities its site +/// assignments are keyed on. Replicated to the container-proxy extension by +/// ProxySettingsReplication. +@Riverpod(keepAlive: true) +Stream>> watchStrictContextAssignments(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + return db.containerDao.strictContextAssignments().watch().map((rows) { + final assignments = >{}; + for (final row in rows) { + final contextId = row.contextId; + final assignmentContextId = row.assignmentContextId; + if (contextId == null || assignmentContextId == null) continue; + + assignments + .putIfAbsent(contextId, () => {}) + .add(assignmentContextId); + } + + return { + for (final entry in assignments.entries) + entry.key: entry.value.toList()..sort(), + }; + }); +} + /// Watches distinct (isolationContextId, containerId) pairs for isolated tabs /// assigned to containers. Used by ProxySettingsReplication to manage proxy /// aliases for isolated contexts. diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart index 1dcc180b..6f546003 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -1146,6 +1146,66 @@ final class WatchAllAssignedSitesProvider String _$watchAllAssignedSitesHash() => r'5f658b5733ee20192eb86d3aeb79aa9678dafba8'; +/// Strict-mode enforcement map: each Gecko cookie-store context that must be +/// enforced (a strict container's base context, plus the isolation contexts of +/// its isolated tabs) mapped to the base contextualIdentities its site +/// assignments are keyed on. Replicated to the container-proxy extension by +/// ProxySettingsReplication. + +@ProviderFor(watchStrictContextAssignments) +final watchStrictContextAssignmentsProvider = + WatchStrictContextAssignmentsProvider._(); + +/// Strict-mode enforcement map: each Gecko cookie-store context that must be +/// enforced (a strict container's base context, plus the isolation contexts of +/// its isolated tabs) mapped to the base contextualIdentities its site +/// assignments are keyed on. Replicated to the container-proxy extension by +/// ProxySettingsReplication. + +final class WatchStrictContextAssignmentsProvider + extends + $FunctionalProvider< + AsyncValue>>, + Map>, + Stream>> + > + with + $FutureModifier>>, + $StreamProvider>> { + /// Strict-mode enforcement map: each Gecko cookie-store context that must be + /// enforced (a strict container's base context, plus the isolation contexts of + /// its isolated tabs) mapped to the base contextualIdentities its site + /// assignments are keyed on. Replicated to the container-proxy extension by + /// ProxySettingsReplication. + WatchStrictContextAssignmentsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'watchStrictContextAssignmentsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchStrictContextAssignmentsHash(); + + @$internal + @override + $StreamProviderElement>> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream>> create(Ref ref) { + return watchStrictContextAssignments(ref); + } +} + +String _$watchStrictContextAssignmentsHash() => + r'01a92529a90baf955c96c35180a50ec21ff913a8'; + /// Watches distinct (isolationContextId, containerId) pairs for isolated tabs /// assigned to containers. Used by ProxySettingsReplication to manage proxy /// aliases for isolated contexts. diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index 6891a7ad..55ebacf5 100644 --- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -108,6 +108,7 @@ class ContainerEditScreen extends HookConsumerWidget { initialContainer.metadata.bypassGlobalProxy, ); final assignedSites = useState(initialContainer.metadata.assignedSites); + final strictMode = useState(initialContainer.metadata.strictMode); final isPinned = useState(initialContainer.isPinned); final textController = useTextEditingController( @@ -141,6 +142,10 @@ class ContainerEditScreen extends HookConsumerWidget { bypassGlobalProxy.value, useCustomColor: useCustomColor.value, assignedSites: assignedSites.value, + // Strict mode requires a Gecko contextId (the extension keys + // strictness on the tab's cookieStoreId). sanitized() enforces the + // same invariant defensively on write. + strictMode: strictMode.value && contextualIdentity.value != null, ).sanitized(), ); } @@ -593,31 +598,54 @@ class ContainerEditScreen extends HookConsumerWidget { margin: EdgeInsets.zero, color: colorScheme.surfaceContainer, clipBehavior: Clip.antiAlias, - child: ListTile( - leading: const Icon(Icons.web), - title: const Text('Assigned Sites'), - subtitle: assignedSiteCount > 0 - ? Text( - '$assignedSiteCount ${assignedSiteCount == 1 ? 'rule' : 'rules'} configured', - ) - : const Text( - 'Route matching origins into this container', - ), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - final result = await showDialog>( - context: context, - builder: (context) => ContainerSitesScreen( - initialSites: assignedSites.value?.toSet() ?? {}, - ), - ); + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.web), + title: const Text('Assigned Sites'), + subtitle: assignedSiteCount > 0 + ? Text( + '$assignedSiteCount ${assignedSiteCount == 1 ? 'rule' : 'rules'} configured', + ) + : const Text( + 'Route matching origins into this container', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final result = await showDialog>( + context: context, + builder: (context) => ContainerSitesScreen( + initialSites: + assignedSites.value?.toSet() ?? {}, + ), + ); - if (result == null || result.isEmpty) { - assignedSites.value = null; - } else { - assignedSites.value = result.toList(); - } - }, + if (result == null || result.isEmpty) { + assignedSites.value = null; + } else { + assignedSites.value = result.toList(); + } + }, + ), + const Divider(height: 1, indent: 56), + SwitchListTile.adaptive( + value: + contextualIdentity.value != null && + strictMode.value, + title: const Text('Strict Mode'), + subtitle: Text( + contextualIdentity.value != null + ? 'Only allow assigned sites to load; block everything else' + : 'Requires cookie isolation to be enabled', + ), + secondary: const Icon(MdiIcons.shieldLockOutline), + onChanged: (contextualIdentity.value != null) + ? (value) { + strictMode.value = value; + } + : null, + ), + ], ), ), ], diff --git a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart index 2a66a1e0..4ad770f6 100644 --- a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart +++ b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.dart @@ -119,6 +119,10 @@ class ContainerProxyRepository extends _$ContainerProxyRepository { ); } + Future setStrictContexts(Map> contexts) { + return _runLocked(body: () => _service.setStrictContexts(contexts)); + } + /// Serialises [body] behind the service lock after the underlying Gecko /// proxy service reports healthy. Replaces the per-method boilerplate that /// each public mutator used to repeat. diff --git a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart index c017d536..2f9b14ca 100644 --- a/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart +++ b/apps/weblibre/lib/features/proxy/domain/repositories/container_proxy.g.dart @@ -42,7 +42,7 @@ final class ContainerProxyRepositoryProvider } String _$containerProxyRepositoryHash() => - r'9201bb0cdda570d38adc5fc16d1c7719f3d719a2'; + r'9d9190edcb06eb1335318172183e354a66626f57'; abstract class _$ContainerProxyRepository extends $Notifier { void build(); diff --git a/apps/weblibre/lib/presentation/main_app.dart b/apps/weblibre/lib/presentation/main_app.dart index a9a17132..254d5ed2 100644 --- a/apps/weblibre/lib/presentation/main_app.dart +++ b/apps/weblibre/lib/presentation/main_app.dart @@ -103,7 +103,9 @@ class MainApp extends HookConsumerWidget { child: _SyncEventListener( child: _SandboxCaptureErrorListener( child: _DownloadStoppedListener( - child: child ?? const SizedBox.shrink(), + child: _StrictContainerBlockListener( + child: child ?? const SizedBox.shrink(), + ), ), ), ), @@ -206,6 +208,42 @@ class _DownloadStoppedListener extends HookConsumerWidget { } } +/// Surfaces a snackbar when the container-proxy extension cancels a navigation +/// because the active tab's container is in strict mode (only assigned sites +/// may load). Strict blocks carry no destination container — the load was +/// already cancelled natively — so this listener only notifies the user. +class _StrictContainerBlockListener extends HookConsumerWidget { + final Widget child; + + const _StrictContainerBlockListener({required this.child}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final siteAssignmentEvents = ref.watch( + eventServiceProvider.select((service) => service.siteAssignementEvent), + ); + + useOnStreamChange( + siteAssignmentEvents, + onData: (event) { + if (!event.strict) { + return; + } + + final host = Uri.tryParse(event.url)?.host; + ui_helper.showInfoMessage( + context, + host != null && host.isNotEmpty + ? '$host is not assigned to this container' + : 'This site is not assigned to this container', + ); + }, + ); + + return child; + } +} + MediaQueryData applyAppMediaQueryOverrides({ required MediaQueryData mediaQuery, required double uiScaleFactor, diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt index 73e023f6..9fc0c9fe 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoContainerProxyApiImpl.kt @@ -72,6 +72,10 @@ class GeckoContainerProxyApiImpl : GeckoContainerProxyApi { ContainerProxyFeature.scheduleRequest("setSiteAssignments", JSONObject(assignments)) } + override fun setStrictContexts(contexts: Map>) { + ContainerProxyFeature.scheduleRequest("setStrictContexts", JSONObject(contexts)) + } + override fun healthcheck(callback: (Result) -> Unit) { ContainerProxyFeature.scheduleRequestWithResponse("healthcheck", Unit, object : ResultConsumer { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ContainerProxyFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ContainerProxyFeature.kt index 9aec7c5e..fe437313 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ContainerProxyFeature.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ContainerProxyFeature.kt @@ -122,7 +122,8 @@ object ContainerProxyFeature { tabId = components.core.store.state.selectedTabId, originUrl = details.tryGetString("originUrl"), url = details.getString("url"), - blocked = details.getBoolean("blocked") + blocked = details.getBoolean("blocked"), + strict = details.optBoolean("strict", false) ) ) { _ -> } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index b9014e39..570ebc8d 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -4658,7 +4658,14 @@ data class ContainerSiteAssignment ( val tabId: String? = null, val originUrl: String? = null, val url: String, - val blocked: Boolean + val blocked: Boolean, + /** + * True when the navigation was cancelled because the tab's container is in + * strict mode and the target origin is not assigned to it (as opposed to an + * ordinary re-open-in-assigned-container block). Strict blocks have no + * destination container; the app just surfaces a message. + */ + val strict: Boolean ) { companion object { @@ -4668,7 +4675,8 @@ data class ContainerSiteAssignment ( val originUrl = pigeonVar_list[2] as String? val url = pigeonVar_list[3] as String val blocked = pigeonVar_list[4] as Boolean - return ContainerSiteAssignment(requestId, tabId, originUrl, url, blocked) + val strict = pigeonVar_list[5] as Boolean + return ContainerSiteAssignment(requestId, tabId, originUrl, url, blocked, strict) } } fun toList(): List { @@ -4678,6 +4686,7 @@ data class ContainerSiteAssignment ( originUrl, url, blocked, + strict, ) } override fun equals(other: Any?): Boolean { @@ -4688,7 +4697,7 @@ data class ContainerSiteAssignment ( return true } val other = other as ContainerSiteAssignment - return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.originUrl, other.originUrl) && GeckoPigeonUtils.deepEquals(this.url, other.url) && GeckoPigeonUtils.deepEquals(this.blocked, other.blocked) + return GeckoPigeonUtils.deepEquals(this.requestId, other.requestId) && GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.originUrl, other.originUrl) && GeckoPigeonUtils.deepEquals(this.url, other.url) && GeckoPigeonUtils.deepEquals(this.blocked, other.blocked) && GeckoPigeonUtils.deepEquals(this.strict, other.strict) } override fun hashCode(): Int { @@ -4698,6 +4707,7 @@ data class ContainerSiteAssignment ( result = 31 * result + GeckoPigeonUtils.deepHash(this.originUrl) result = 31 * result + GeckoPigeonUtils.deepHash(this.url) result = 31 * result + GeckoPigeonUtils.deepHash(this.blocked) + result = 31 * result + GeckoPigeonUtils.deepHash(this.strict) return result } } @@ -8622,6 +8632,16 @@ interface GeckoContainerProxyApi { fun clearContainerProxy(contextId: String) fun removeContainerProxyRelation(contextId: String, proxyId: String) fun setSiteAssignments(assignments: Map) + /** + * Strict-mode enforcement map. Keys are Gecko cookie-store contexts to + * enforce (a strict container's base context plus its isolated tabs' + * isolation contexts); each value contains the container base contexts that + * site assignments are keyed on. Tabs in these contexts may only load + * origins assigned to one of the mapped base contexts (exact match, no proxy + * equivalence); any other top-level navigation is cancelled and reported + * back with `strict = true`. + */ + fun setStrictContexts(contexts: Map>) fun healthcheck(callback: (Result) -> Unit) companion object { @@ -8816,6 +8836,24 @@ interface GeckoContainerProxyApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setStrictContexts$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val contextsArg = args[0] as Map> + val wrapped: List = try { + api.setStrictContexts(contextsArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$separatedMessageChannelSuffix", codec) if (api != null) { @@ -8980,7 +9018,7 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val } } else { callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) - } + } } } fun onEngineReadyStateChange(sequenceArg: Long, stateArg: Boolean, callback: (Result) -> Unit) @@ -10375,7 +10413,7 @@ class GeckoHistoryEvents(private val binaryMessenger: BinaryMessenger, private v } } else { callback(Result.failure(GeckoPigeonUtils.createConnectionError(channelName))) - } + } } } } 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 638f984e..ba223934 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 @@ -149,19 +149,36 @@ export default class BackgroundMain { } const url = URL.parse(options.url); - if (url !== null && this.store.isSiteOriginAssigned(url)) { - let cookieStoreId: string + if (url === null) { + return {}; + } - 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 cookieStoreId: string - if (cookieStoreId == 'private' || this.store.isSiteOriginInSameContext(url, cookieStoreId)) { + 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' + } + + // Private tabs are never strict. For other tabs, strict enforcement is + // keyed on the tab's cookie-store context (base container context, or an + // isolation context of a strict container's isolated tab). + const isStrict = cookieStoreId !== 'private' && + this.store.isContextStrict(cookieStoreId) + + if (this.store.isSiteOriginAssigned(url)) { + // In a strict context the site must be assigned to *this* container's own + // base context (exact match, no proxy equivalence). Non-strict contexts + // keep the looser proxy/direct-equivalence matching used for routing. + const allowed = isStrict + ? this.store.isSiteOriginStrictlyAllowed(url, cookieStoreId) + : (cookieStoreId == 'private' || this.store.isSiteOriginInSameContext(url, cookieStoreId)) + + if (allowed) { if (tab.highlighted) { port.postMessage({ "type": "assignedSiteRequested", @@ -171,7 +188,8 @@ export default class BackgroundMain { "originUrl": options.originUrl, "url": options.url, "tabId": options.tabId, - "blocked": false + "blocked": false, + "strict": false } }); } @@ -180,6 +198,9 @@ export default class BackgroundMain { // even if the tab is not highlighted. return {}; } else { + // Assigned to a different container: cancel here and let the app re-open + // it in its assigned container. This applies to strict contexts too — + // the site still doesn't load here, it just routes to where it belongs. //Only send events when tab is selected if (tab.highlighted) { port.postMessage({ @@ -190,7 +211,8 @@ export default class BackgroundMain { "originUrl": options.originUrl, "url": options.url, "tabId": options.tabId, - "blocked": true + "blocked": true, + "strict": false } }); } @@ -199,6 +221,28 @@ export default class BackgroundMain { cancel: true, }; } + } else if (isStrict) { + // Strict mode: this container may only load origins assigned to it. The + // origin is not assigned to any container, so cancel the navigation and + // report it as a strict block (no destination container). + if (tab.highlighted) { + port.postMessage({ + "type": "assignedSiteRequested", + "id": options.requestId, + "status": "success", + "result": { + "originUrl": options.originUrl, + "url": options.url, + "tabId": options.tabId, + "blocked": true, + "strict": true + } + }); + } + + return { + cancel: true, + }; } return {}; diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts index 723363ed..0e1dd098 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/background/index.ts @@ -18,7 +18,8 @@ interface Message { 'clearContainerProxy' | 'removeContainerProxyRelation' | 'healthcheck' | - 'setSiteAssignments'; + 'setSiteAssignments' | + 'setStrictContexts'; args: any; } @@ -92,6 +93,10 @@ port.onMessage.addListener((raw: unknown): void => { console.log('set site assignments ' + JSON.stringify(message.args)) store.setSiteAssignments(entries); break + case "setStrictContexts": + console.log('set strict contexts ' + JSON.stringify(message.args)) + store.setStrictContexts(new Map(Object.entries(message.args)) as Map); + break case "healthcheck": port.postMessage({ "type": "healthcheck", diff --git a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts index 52509c36..ecdadfec 100644 --- a/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts +++ b/packages/flutter_mozilla_components/javascript/container_proxy/src/store/Store.ts @@ -89,6 +89,39 @@ export class Store { private siteAssignments: Map = new Map() private wildcardAssignments: WildcardAssignment[] = [] + // Maps an enforced cookie-store context (a strict container's base context, + // or an isolation context of one of its isolated tabs) to the base contexts + // its site assignments are keyed on. + private strictContexts: Map> = new Map>() + + setStrictContexts(contexts: Map): void { + const next = new Map>() + for (const [contextId, assignmentContexts] of contexts) { + next.set(contextId, new Set(assignmentContexts)) + } + this.strictContexts = next + } + + isContextStrict(contextId: string): boolean { + return this.strictContexts.has(contextId) + } + + /** + * True when [uri]'s origin is assigned to a base context that [contextId] + * enforces. Strict mode requires an exact assignment match (via + * [lookupAssignment], which handles exact + wildcard entries) against the + * container's base contexts — proxy/direct equivalence is deliberately NOT + * consulted, so a site assigned only to a different (even proxy-equivalent) + * container does not load here. + */ + isSiteOriginStrictlyAllowed(uri: URL, contextId: string): boolean { + const assignmentContexts = this.strictContexts.get(contextId) + const assignedContext = this.lookupAssignment(uri) + return assignedContext !== undefined && + assignmentContexts !== undefined && + assignmentContexts.has(assignedContext) + } + setSiteAssignments(sites: Map): void { const exact = new Map() const wildcard: WildcardAssignment[] = [] diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart index 33dcd2f0..8f79d0a5 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_container_proxy.dart @@ -55,6 +55,10 @@ class GeckoContainerProxyService { return _apiInstance.setSiteAssignments(assignments); } + Future setStrictContexts(Map> contexts) { + return _apiInstance.setStrictContexts(contexts); + } + Future healthcheck() async { try { return await _apiInstance.healthcheck().timeout( diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index 33d413ca..95a3c51e 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -4928,6 +4928,7 @@ class ContainerSiteAssignment { this.originUrl, required this.url, required this.blocked, + required this.strict, }); String requestId; @@ -4940,6 +4941,12 @@ class ContainerSiteAssignment { bool blocked; + /// True when the navigation was cancelled because the tab's container is in + /// strict mode and the target origin is not assigned to it (as opposed to an + /// ordinary re-open-in-assigned-container block). Strict blocks have no + /// destination container; the app just surfaces a message. + bool strict; + List _toList() { return [ requestId, @@ -4947,6 +4954,7 @@ class ContainerSiteAssignment { originUrl, url, blocked, + strict, ]; } @@ -4961,6 +4969,7 @@ class ContainerSiteAssignment { originUrl: result[2] as String?, url: result[3]! as String, blocked: result[4]! as bool, + strict: result[5]! as bool, ); } @@ -4973,7 +4982,7 @@ class ContainerSiteAssignment { if (identical(this, other)) { return true; } - return _deepEquals(requestId, other.requestId) && _deepEquals(tabId, other.tabId) && _deepEquals(originUrl, other.originUrl) && _deepEquals(url, other.url) && _deepEquals(blocked, other.blocked); + return _deepEquals(requestId, other.requestId) && _deepEquals(tabId, other.tabId) && _deepEquals(originUrl, other.originUrl) && _deepEquals(url, other.url) && _deepEquals(blocked, other.blocked) && _deepEquals(strict, other.strict); } @override @@ -8634,6 +8643,31 @@ class GeckoContainerProxyApi { ; } + /// Strict-mode enforcement map. Keys are Gecko cookie-store contexts to + /// enforce (a strict container's base context plus its isolated tabs' + /// isolation contexts); each value contains the container base contexts that + /// site assignments are keyed on. Tabs in these contexts may only load + /// origins assigned to one of the mapped base contexts (exact match, no proxy + /// equivalence); any other top-level navigation is cancelled and reported + /// back with `strict = true`. + Future setStrictContexts(Map> contexts) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setStrictContexts$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([contexts]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + Future healthcheck() async { final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$pigeonVar_messageChannelSuffix'; final pigeonVar_channel = BasicMessageChannel( diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index e215d4da..dd5f3b9e 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1927,6 +1927,15 @@ abstract class GeckoContainerProxyApi { void removeContainerProxyRelation(String contextId, String proxyId); void setSiteAssignments(Map assignments); + /// Strict-mode enforcement map. Keys are Gecko cookie-store contexts to + /// enforce (a strict container's base context plus its isolated tabs' + /// isolation contexts); each value contains the container base contexts that + /// site assignments are keyed on. Tabs in these contexts may only load + /// origins assigned to one of the mapped base contexts (exact match, no proxy + /// equivalence); any other top-level navigation is cancelled and reported + /// back with `strict = true`. + void setStrictContexts(Map> contexts); + @async bool healthcheck(); } @@ -1985,12 +1994,19 @@ class ContainerSiteAssignment { final String url; final bool blocked; + /// True when the navigation was cancelled because the tab's container is in + /// strict mode and the target origin is not assigned to it (as opposed to an + /// ordinary re-open-in-assigned-container block). Strict blocks have no + /// destination container; the app just surfaces a message. + final bool strict; + ContainerSiteAssignment({ required this.requestId, required this.tabId, required this.originUrl, required this.url, required this.blocked, + required this.strict, }); }