container strict mode feature

This commit is contained in:
Fabian Freund
2026-07-05 16:09:31 +02:00
parent a46bafc189
commit 69f0417c51
24 changed files with 498 additions and 52 deletions
@@ -900,6 +900,14 @@ class TabRepository extends _$TabRepository {
final containerSiteAssignementSub = eventSerivce.siteAssignementEvent.listen( final containerSiteAssignementSub = eventSerivce.siteAssignementEvent.listen(
(event) async { (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; final tabId = event.tabId;
if (tabId != null) { if (tabId != null) {
final tabState = ref.read(tabStatesProvider)[tabId]; final tabState = ref.read(tabStatesProvider)[tabId];
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
} }
} }
String _$tabRepositoryHash() => r'982b80b8ea7c694958bc10cc7e8e15310af6a238'; String _$tabRepositoryHash() => r'dd2f9c776be1d5437141e94dbd0b7ef382093b63';
abstract class _$TabRepository extends $Notifier<void> { abstract class _$TabRepository extends $Notifier<void> {
void build(); void build();
@@ -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( ref.listen(
fireImmediately: true, fireImmediately: true,
watchIsolatedContextContainerMapProvider.select( watchIsolatedContextContainerMapProvider.select(
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
} }
String _$proxySettingsReplicationHash() => String _$proxySettingsReplicationHash() =>
r'cdb624b7f7806a7ce4218da350253ed3d29986d0'; r'69787c85c94ff165e3eeb0f0a3f3fc83e88a1b83';
abstract class _$ProxySettingsReplication extends $Notifier<void> { abstract class _$ProxySettingsReplication extends $Notifier<void> {
void build(); void build();
@@ -259,6 +259,10 @@ class ContainerDao extends DatabaseAccessor<TabDatabase>
return db.definitionsDrift.containersToClearOnExit(); return db.definitionsDrift.containersToClearOnExit();
} }
Selectable<StrictContextAssignmentsResult> strictContextAssignments() {
return db.definitionsDrift.strictContextAssignments();
}
SingleOrNullSelectable<ContainerData> getContainerByContextualIdentity( SingleOrNullSelectable<ContainerData> getContainerByContextualIdentity(
String contextId, String contextId,
) { ) {
@@ -969,6 +969,40 @@ containersToClearOnExit:
json_extract(container.metadata, '$.clearDataOnExit') = 1 json_extract(container.metadata, '$.clearDataOnExit') = 1
AND container.metadata ->> '$.contextualIdentity' IS NOT NULL; 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: tabsInIsolationGroup:
SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = :contextId; SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = :contextId;
@@ -6695,6 +6695,19 @@ class DefinitionsDrift extends i9.ModularAccessor {
).map((i0.QueryRow row) => row.readNullable<String>('contextual_identity')); ).map((i0.QueryRow row) => row.readNullable<String>('contextual_identity'));
} }
i0.Selectable<StrictContextAssignmentsResult> 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<String>('context_id'),
assignmentContextId: row.readNullable<String>('assignment_context_id'),
),
);
}
i0.Selectable<int> tabsInIsolationGroup({String? contextId}) { i0.Selectable<int> tabsInIsolationGroup({String? contextId}) {
return customSelect( return customSelect(
'SELECT COUNT(*) AS count FROM tab WHERE isolation_context_id = ?1', '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 { class IsolatedContextContainerPairsResult {
final String? isolationContextId; final String? isolationContextId;
final String? containerId; final String? containerId;
@@ -73,6 +73,16 @@ class ContainerMetadata with FastEquatable {
final List<Uri>? assignedSites; final List<Uri>? 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({ ContainerMetadata({
required this.iconData, required this.iconData,
required this.contextualIdentity, required this.contextualIdentity,
@@ -83,6 +93,7 @@ class ContainerMetadata with FastEquatable {
required this.bypassGlobalProxy, required this.bypassGlobalProxy,
required this.useCustomColor, required this.useCustomColor,
required this.assignedSites, required this.assignedSites,
required this.strictMode,
}); });
ContainerMetadata.withDefaults({ ContainerMetadata.withDefaults({
@@ -95,6 +106,7 @@ class ContainerMetadata with FastEquatable {
bool? bypassGlobalProxy, bool? bypassGlobalProxy,
bool? useCustomColor, bool? useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
bool? strictMode,
}) : this( }) : this(
iconData: iconData, iconData: iconData,
contextualIdentity: contextualIdentity, contextualIdentity: contextualIdentity,
@@ -112,6 +124,10 @@ class ContainerMetadata with FastEquatable {
bypassGlobalProxy: bypassGlobalProxy ?? false, bypassGlobalProxy: bypassGlobalProxy ?? false,
useCustomColor: useCustomColor ?? false, useCustomColor: useCustomColor ?? false,
assignedSites: assignedSites, 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 /// 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 /// The primary constructor can't normalize (copy_with_extension_gen requires
/// params to map 1:1 to fields), so writers route through this. /// params to map 1:1 to fields), so writers route through this.
ContainerMetadata sanitized() { ContainerMetadata sanitized() {
if (excludeFromHistory && contextualIdentity == null) { var result = this;
return copyWith(excludeFromHistory: false); 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; bool get usesTorProxy => proxyConnectionId is TorProxyConnectionId;
@@ -144,6 +166,7 @@ class ContainerMetadata with FastEquatable {
bypassGlobalProxy, bypassGlobalProxy,
useCustomColor, useCustomColor,
assignedSites, assignedSites,
strictMode,
]; ];
} }
@@ -25,6 +25,8 @@ abstract class _$ContainerMetadataCWProxy {
ContainerMetadata assignedSites(List<Uri>? assignedSites); ContainerMetadata assignedSites(List<Uri>? assignedSites);
ContainerMetadata strictMode(bool strictMode);
/// Creates a new instance with the provided field values. /// 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)`. /// 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 bypassGlobalProxy,
bool useCustomColor, bool useCustomColor,
List<Uri>? assignedSites, List<Uri>? assignedSites,
bool strictMode,
}); });
} }
@@ -87,6 +90,9 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
ContainerMetadata assignedSites(List<Uri>? assignedSites) => ContainerMetadata assignedSites(List<Uri>? assignedSites) =>
call(assignedSites: assignedSites); call(assignedSites: assignedSites);
@override
ContainerMetadata strictMode(bool strictMode) => call(strictMode: strictMode);
@override @override
/// Creates a new instance with the provided field values. /// 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)`. /// 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? bypassGlobalProxy = const $CopyWithPlaceholder(),
Object? useCustomColor = const $CopyWithPlaceholder(), Object? useCustomColor = const $CopyWithPlaceholder(),
Object? assignedSites = const $CopyWithPlaceholder(), Object? assignedSites = const $CopyWithPlaceholder(),
Object? strictMode = const $CopyWithPlaceholder(),
}) { }) {
return ContainerMetadata( return ContainerMetadata(
iconData: iconData == const $CopyWithPlaceholder() iconData: iconData == const $CopyWithPlaceholder()
@@ -153,6 +160,11 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
? _value.assignedSites ? _value.assignedSites
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
: assignedSites as List<Uri>?, : assignedSites as List<Uri>?,
strictMode:
strictMode == const $CopyWithPlaceholder() || strictMode == null
? _value.strictMode
// ignore: cast_nullable_to_non_nullable
: strictMode as bool,
); );
} }
} }
@@ -295,6 +307,7 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
assignedSites: (json['assignedSites'] as List<dynamic>?) assignedSites: (json['assignedSites'] as List<dynamic>?)
?.map((e) => Uri.parse(e as String)) ?.map((e) => Uri.parse(e as String))
.toList(), .toList(),
strictMode: json['strictMode'] as bool? ?? false,
); );
Map<String, dynamic> _$ContainerMetadataToJson( Map<String, dynamic> _$ContainerMetadataToJson(
@@ -312,6 +325,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
'bypassGlobalProxy': instance.bypassGlobalProxy, 'bypassGlobalProxy': instance.bypassGlobalProxy,
'useCustomColor': instance.useCustomColor, 'useCustomColor': instance.useCustomColor,
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(), 'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
'strictMode': instance.strictMode,
}; };
Value? _$JsonConverterFromJson<Json, Value>( Value? _$JsonConverterFromJson<Json, Value>(
@@ -190,6 +190,33 @@ Stream<List<SiteAssignment>> watchAllAssignedSites(Ref ref) {
return db.containerDao.allAssignedSites().watch(); 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<Map<String, List<String>>> watchStrictContextAssignments(Ref ref) {
final db = ref.watch(tabDatabaseProvider);
return db.containerDao.strictContextAssignments().watch().map((rows) {
final assignments = <String, Set<String>>{};
for (final row in rows) {
final contextId = row.contextId;
final assignmentContextId = row.assignmentContextId;
if (contextId == null || assignmentContextId == null) continue;
assignments
.putIfAbsent(contextId, () => <String>{})
.add(assignmentContextId);
}
return {
for (final entry in assignments.entries)
entry.key: entry.value.toList()..sort(),
};
});
}
/// Watches distinct (isolationContextId, containerId) pairs for isolated tabs /// Watches distinct (isolationContextId, containerId) pairs for isolated tabs
/// assigned to containers. Used by ProxySettingsReplication to manage proxy /// assigned to containers. Used by ProxySettingsReplication to manage proxy
/// aliases for isolated contexts. /// aliases for isolated contexts.
@@ -1146,6 +1146,66 @@ final class WatchAllAssignedSitesProvider
String _$watchAllAssignedSitesHash() => String _$watchAllAssignedSitesHash() =>
r'5f658b5733ee20192eb86d3aeb79aa9678dafba8'; 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<String, List<String>>>,
Map<String, List<String>>,
Stream<Map<String, List<String>>>
>
with
$FutureModifier<Map<String, List<String>>>,
$StreamProvider<Map<String, List<String>>> {
/// 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<Map<String, List<String>>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<String, List<String>>> create(Ref ref) {
return watchStrictContextAssignments(ref);
}
}
String _$watchStrictContextAssignmentsHash() =>
r'01a92529a90baf955c96c35180a50ec21ff913a8';
/// Watches distinct (isolationContextId, containerId) pairs for isolated tabs /// Watches distinct (isolationContextId, containerId) pairs for isolated tabs
/// assigned to containers. Used by ProxySettingsReplication to manage proxy /// assigned to containers. Used by ProxySettingsReplication to manage proxy
/// aliases for isolated contexts. /// aliases for isolated contexts.
@@ -108,6 +108,7 @@ class ContainerEditScreen extends HookConsumerWidget {
initialContainer.metadata.bypassGlobalProxy, initialContainer.metadata.bypassGlobalProxy,
); );
final assignedSites = useState(initialContainer.metadata.assignedSites); final assignedSites = useState(initialContainer.metadata.assignedSites);
final strictMode = useState(initialContainer.metadata.strictMode);
final isPinned = useState(initialContainer.isPinned); final isPinned = useState(initialContainer.isPinned);
final textController = useTextEditingController( final textController = useTextEditingController(
@@ -141,6 +142,10 @@ class ContainerEditScreen extends HookConsumerWidget {
bypassGlobalProxy.value, bypassGlobalProxy.value,
useCustomColor: useCustomColor.value, useCustomColor: useCustomColor.value,
assignedSites: assignedSites.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(), ).sanitized(),
); );
} }
@@ -593,31 +598,54 @@ class ContainerEditScreen extends HookConsumerWidget {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
color: colorScheme.surfaceContainer, color: colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: ListTile( child: Column(
leading: const Icon(Icons.web), children: [
title: const Text('Assigned Sites'), ListTile(
subtitle: assignedSiteCount > 0 leading: const Icon(Icons.web),
? Text( title: const Text('Assigned Sites'),
'$assignedSiteCount ${assignedSiteCount == 1 ? 'rule' : 'rules'} configured', subtitle: assignedSiteCount > 0
) ? Text(
: const Text( '$assignedSiteCount ${assignedSiteCount == 1 ? 'rule' : 'rules'} configured',
'Route matching origins into this container', )
), : const Text(
trailing: const Icon(Icons.chevron_right), 'Route matching origins into this container',
onTap: () async { ),
final result = await showDialog<Set<Uri>>( trailing: const Icon(Icons.chevron_right),
context: context, onTap: () async {
builder: (context) => ContainerSitesScreen( final result = await showDialog<Set<Uri>>(
initialSites: assignedSites.value?.toSet() ?? {}, context: context,
), builder: (context) => ContainerSitesScreen(
); initialSites:
assignedSites.value?.toSet() ?? {},
),
);
if (result == null || result.isEmpty) { if (result == null || result.isEmpty) {
assignedSites.value = null; assignedSites.value = null;
} else { } else {
assignedSites.value = result.toList(); 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,
),
],
), ),
), ),
], ],
@@ -119,6 +119,10 @@ class ContainerProxyRepository extends _$ContainerProxyRepository {
); );
} }
Future<void> setStrictContexts(Map<String, List<String>> contexts) {
return _runLocked(body: () => _service.setStrictContexts(contexts));
}
/// Serialises [body] behind the service lock after the underlying Gecko /// Serialises [body] behind the service lock after the underlying Gecko
/// proxy service reports healthy. Replaces the per-method boilerplate that /// proxy service reports healthy. Replaces the per-method boilerplate that
/// each public mutator used to repeat. /// each public mutator used to repeat.
@@ -42,7 +42,7 @@ final class ContainerProxyRepositoryProvider
} }
String _$containerProxyRepositoryHash() => String _$containerProxyRepositoryHash() =>
r'9201bb0cdda570d38adc5fc16d1c7719f3d719a2'; r'9d9190edcb06eb1335318172183e354a66626f57';
abstract class _$ContainerProxyRepository extends $Notifier<void> { abstract class _$ContainerProxyRepository extends $Notifier<void> {
void build(); void build();
+39 -1
View File
@@ -103,7 +103,9 @@ class MainApp extends HookConsumerWidget {
child: _SyncEventListener( child: _SyncEventListener(
child: _SandboxCaptureErrorListener( child: _SandboxCaptureErrorListener(
child: _DownloadStoppedListener( 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({ MediaQueryData applyAppMediaQueryOverrides({
required MediaQueryData mediaQuery, required MediaQueryData mediaQuery,
required double uiScaleFactor, required double uiScaleFactor,
@@ -72,6 +72,10 @@ class GeckoContainerProxyApiImpl : GeckoContainerProxyApi {
ContainerProxyFeature.scheduleRequest("setSiteAssignments", JSONObject(assignments)) ContainerProxyFeature.scheduleRequest("setSiteAssignments", JSONObject(assignments))
} }
override fun setStrictContexts(contexts: Map<String, List<String>>) {
ContainerProxyFeature.scheduleRequest("setStrictContexts", JSONObject(contexts))
}
override fun healthcheck(callback: (Result<Boolean>) -> Unit) { override fun healthcheck(callback: (Result<Boolean>) -> Unit) {
ContainerProxyFeature.scheduleRequestWithResponse("healthcheck", Unit, object : ContainerProxyFeature.scheduleRequestWithResponse("healthcheck", Unit, object :
ResultConsumer<JSONObject> { ResultConsumer<JSONObject> {
@@ -122,7 +122,8 @@ object ContainerProxyFeature {
tabId = components.core.store.state.selectedTabId, tabId = components.core.store.state.selectedTabId,
originUrl = details.tryGetString("originUrl"), originUrl = details.tryGetString("originUrl"),
url = details.getString("url"), url = details.getString("url"),
blocked = details.getBoolean("blocked") blocked = details.getBoolean("blocked"),
strict = details.optBoolean("strict", false)
) )
) { _ -> } ) { _ -> }
} }
@@ -4658,7 +4658,14 @@ data class ContainerSiteAssignment (
val tabId: String? = null, val tabId: String? = null,
val originUrl: String? = null, val originUrl: String? = null,
val url: String, 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 { companion object {
@@ -4668,7 +4675,8 @@ data class ContainerSiteAssignment (
val originUrl = pigeonVar_list[2] as String? val originUrl = pigeonVar_list[2] as String?
val url = pigeonVar_list[3] as String val url = pigeonVar_list[3] as String
val blocked = pigeonVar_list[4] as Boolean 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<Any?> { fun toList(): List<Any?> {
@@ -4678,6 +4686,7 @@ data class ContainerSiteAssignment (
originUrl, originUrl,
url, url,
blocked, blocked,
strict,
) )
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -4688,7 +4697,7 @@ data class ContainerSiteAssignment (
return true return true
} }
val other = other as ContainerSiteAssignment 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 { override fun hashCode(): Int {
@@ -4698,6 +4707,7 @@ data class ContainerSiteAssignment (
result = 31 * result + GeckoPigeonUtils.deepHash(this.originUrl) result = 31 * result + GeckoPigeonUtils.deepHash(this.originUrl)
result = 31 * result + GeckoPigeonUtils.deepHash(this.url) result = 31 * result + GeckoPigeonUtils.deepHash(this.url)
result = 31 * result + GeckoPigeonUtils.deepHash(this.blocked) result = 31 * result + GeckoPigeonUtils.deepHash(this.blocked)
result = 31 * result + GeckoPigeonUtils.deepHash(this.strict)
return result return result
} }
} }
@@ -8622,6 +8632,16 @@ interface GeckoContainerProxyApi {
fun clearContainerProxy(contextId: String) fun clearContainerProxy(contextId: String)
fun removeContainerProxyRelation(contextId: String, proxyId: String) fun removeContainerProxyRelation(contextId: String, proxyId: String)
fun setSiteAssignments(assignments: Map<String, String>) fun setSiteAssignments(assignments: Map<String, String>)
/**
* 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<String, List<String>>)
fun healthcheck(callback: (Result<Boolean>) -> Unit) fun healthcheck(callback: (Result<Boolean>) -> Unit)
companion object { companion object {
@@ -8816,6 +8836,24 @@ interface GeckoContainerProxyApi {
channel.setMessageHandler(null) channel.setMessageHandler(null)
} }
} }
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setStrictContexts$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val contextsArg = args[0] as Map<String, List<String>>
val wrapped: List<Any?> = try {
api.setStrictContexts(contextsArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run { run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$separatedMessageChannelSuffix", codec) val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$separatedMessageChannelSuffix", codec)
if (api != null) { if (api != null) {
@@ -149,19 +149,36 @@ export default class BackgroundMain {
} }
const url = URL.parse(options.url); const url = URL.parse(options.url);
if (url !== null && this.store.isSiteOriginAssigned(url)) { if (url === null) {
let cookieStoreId: string return {};
}
if (tab.cookieStoreId?.startsWith(containerIdentifier) === true) { let cookieStoreId: string
cookieStoreId = tab.cookieStoreId.substring(containerIdentifier.length)
} else if (tab.cookieStoreId === privateIdentifier) {
// Handle private tabs - use 'private' as identifier
cookieStoreId = 'private'
} else {
cookieStoreId = 'general'
}
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) { if (tab.highlighted) {
port.postMessage({ port.postMessage({
"type": "assignedSiteRequested", "type": "assignedSiteRequested",
@@ -171,7 +188,8 @@ export default class BackgroundMain {
"originUrl": options.originUrl, "originUrl": options.originUrl,
"url": options.url, "url": options.url,
"tabId": options.tabId, "tabId": options.tabId,
"blocked": false "blocked": false,
"strict": false
} }
}); });
} }
@@ -180,6 +198,9 @@ export default class BackgroundMain {
// even if the tab is not highlighted. // even if the tab is not highlighted.
return {}; return {};
} else { } 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 //Only send events when tab is selected
if (tab.highlighted) { if (tab.highlighted) {
port.postMessage({ port.postMessage({
@@ -190,7 +211,8 @@ export default class BackgroundMain {
"originUrl": options.originUrl, "originUrl": options.originUrl,
"url": options.url, "url": options.url,
"tabId": options.tabId, "tabId": options.tabId,
"blocked": true "blocked": true,
"strict": false
} }
}); });
} }
@@ -199,6 +221,28 @@ export default class BackgroundMain {
cancel: true, 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 {}; return {};
@@ -18,7 +18,8 @@ interface Message {
'clearContainerProxy' | 'clearContainerProxy' |
'removeContainerProxyRelation' | 'removeContainerProxyRelation' |
'healthcheck' | 'healthcheck' |
'setSiteAssignments'; 'setSiteAssignments' |
'setStrictContexts';
args: any; args: any;
} }
@@ -92,6 +93,10 @@ port.onMessage.addListener((raw: unknown): void => {
console.log('set site assignments ' + JSON.stringify(message.args)) console.log('set site assignments ' + JSON.stringify(message.args))
store.setSiteAssignments(entries); store.setSiteAssignments(entries);
break break
case "setStrictContexts":
console.log('set strict contexts ' + JSON.stringify(message.args))
store.setStrictContexts(new Map(Object.entries(message.args)) as Map<string, string[]>);
break
case "healthcheck": case "healthcheck":
port.postMessage({ port.postMessage({
"type": "healthcheck", "type": "healthcheck",
@@ -89,6 +89,39 @@ export class Store {
private siteAssignments: Map<string, string> = new Map<string, string>() private siteAssignments: Map<string, string> = new Map<string, string>()
private wildcardAssignments: WildcardAssignment[] = [] 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<string, Set<string>> = new Map<string, Set<string>>()
setStrictContexts(contexts: Map<string, string[]>): void {
const next = new Map<string, Set<string>>()
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<string, unknown>): void { setSiteAssignments(sites: Map<string, unknown>): void {
const exact = new Map<string, string>() const exact = new Map<string, string>()
const wildcard: WildcardAssignment[] = [] const wildcard: WildcardAssignment[] = []
@@ -55,6 +55,10 @@ class GeckoContainerProxyService {
return _apiInstance.setSiteAssignments(assignments); return _apiInstance.setSiteAssignments(assignments);
} }
Future<void> setStrictContexts(Map<String, List<String>> contexts) {
return _apiInstance.setStrictContexts(contexts);
}
Future<bool> healthcheck() async { Future<bool> healthcheck() async {
try { try {
return await _apiInstance.healthcheck().timeout( return await _apiInstance.healthcheck().timeout(
@@ -4928,6 +4928,7 @@ class ContainerSiteAssignment {
this.originUrl, this.originUrl,
required this.url, required this.url,
required this.blocked, required this.blocked,
required this.strict,
}); });
String requestId; String requestId;
@@ -4940,6 +4941,12 @@ class ContainerSiteAssignment {
bool blocked; 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<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[
requestId, requestId,
@@ -4947,6 +4954,7 @@ class ContainerSiteAssignment {
originUrl, originUrl,
url, url,
blocked, blocked,
strict,
]; ];
} }
@@ -4961,6 +4969,7 @@ class ContainerSiteAssignment {
originUrl: result[2] as String?, originUrl: result[2] as String?,
url: result[3]! as String, url: result[3]! as String,
blocked: result[4]! as bool, blocked: result[4]! as bool,
strict: result[5]! as bool,
); );
} }
@@ -4973,7 +4982,7 @@ class ContainerSiteAssignment {
if (identical(this, other)) { if (identical(this, other)) {
return true; 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 @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<void> setStrictContexts(Map<String, List<String>> contexts) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setStrictContexts$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[contexts]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
Future<bool> healthcheck() async { Future<bool> healthcheck() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$pigeonVar_messageChannelSuffix'; final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.healthcheck$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>( final pigeonVar_channel = BasicMessageChannel<Object?>(
@@ -1927,6 +1927,15 @@ abstract class GeckoContainerProxyApi {
void removeContainerProxyRelation(String contextId, String proxyId); void removeContainerProxyRelation(String contextId, String proxyId);
void setSiteAssignments(Map<String, String> assignments); void setSiteAssignments(Map<String, String> 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<String, List<String>> contexts);
@async @async
bool healthcheck(); bool healthcheck();
} }
@@ -1985,12 +1994,19 @@ class ContainerSiteAssignment {
final String url; final String url;
final bool blocked; 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({ ContainerSiteAssignment({
required this.requestId, required this.requestId,
required this.tabId, required this.tabId,
required this.originUrl, required this.originUrl,
required this.url, required this.url,
required this.blocked, required this.blocked,
required this.strict,
}); });
} }