diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart index bc46df40..8bee4262 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -25,11 +26,43 @@ import 'package:http/http.dart' as http; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; part 'browser_addon.g.dart'; const _signatureRequiredPref = 'xpinstall.signatures.required'; +@Riverpod(keepAlive: true) +class AllowUnsignedExtensions extends _$AllowUnsignedExtensions { + Future setAllowUnsigned({required bool allow}) async { + final fixator = ref.read(preferenceFixatorProvider.notifier); + if (allow) { + await fixator.register(_signatureRequiredPref, false); + } else { + await fixator.unregister(_signatureRequiredPref); + await GeckoPrefService().applyPrefs({_signatureRequiredPref: true}); + } + state = AsyncData(allow); + } + + @override + FutureOr build() async { + final prefs = + await GeckoPrefService().getPrefs([_signatureRequiredPref]); + final pref = prefs[_signatureRequiredPref]; + final allowUnsigned = pref?.value == false; + + // Re-register with fixator to prevent Gecko from resetting + if (allowUnsigned) { + await ref + .read(preferenceFixatorProvider.notifier) + .register(_signatureRequiredPref, false); + } + + return allowUnsigned; + } +} + @Riverpod(keepAlive: true) class BrowserAddonService extends _$BrowserAddonService { Future getAddonXpiUrl(String guid) async { @@ -68,12 +101,7 @@ class BrowserAddonService extends _$BrowserAddonService { } } - Future installFromFile( - String filePath, { - bool allowUnsigned = false, - }) async { - final prefService = GeckoPrefService(); - + Future installFromFile(String filePath) async { try { // Validate file exists and has .xpi extension final file = File(filePath); @@ -86,25 +114,13 @@ class BrowserAddonService extends _$BrowserAddonService { throw Exception('Invalid file type. Expected .xpi file'); } - // Temporarily disable signature requirement if user allows unsigned - if (allowUnsigned) { - await prefService.applyPrefs({_signatureRequiredPref: false}); - } + // Create file:// URI and install + final fileUri = Uri.file(filePath); + if (!ref.mounted) return false; - try { - // Create file:// URI and install - final fileUri = Uri.file(filePath); - if (!ref.mounted) return false; + await ref.read(addonServiceProvider).installAddon(fileUri); - await ref.read(addonServiceProvider).installAddon(fileUri); - - return true; - } finally { - // Always restore signature requirement - if (allowUnsigned) { - await prefService.applyPrefs({_signatureRequiredPref: true}); - } - } + return true; } catch (e, s) { logger.e( 'Failed installing from file: $filePath', diff --git a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart index 879d2a97..de3dfba0 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/browser_addon.g.dart @@ -9,6 +9,51 @@ part of 'browser_addon.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +@ProviderFor(AllowUnsignedExtensions) +final allowUnsignedExtensionsProvider = AllowUnsignedExtensionsProvider._(); + +final class AllowUnsignedExtensionsProvider + extends $AsyncNotifierProvider { + AllowUnsignedExtensionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'allowUnsignedExtensionsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$allowUnsignedExtensionsHash(); + + @$internal + @override + AllowUnsignedExtensions create() => AllowUnsignedExtensions(); +} + +String _$allowUnsignedExtensionsHash() => + r'3b67f51cabf8e2f37d992320c5b93bb61c6dbe1f'; + +abstract class _$AllowUnsignedExtensions extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, bool>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, bool>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} + @ProviderFor(BrowserAddonService) final browserAddonServiceProvider = BrowserAddonServiceProvider._(); @@ -42,7 +87,7 @@ final class BrowserAddonServiceProvider } String _$browserAddonServiceHash() => - r'1485fd056ce32e142e342e920affb82a5c77a370'; + r'b90e69ce77b846d6056c800f232a8aa50b4b21d0'; abstract class _$BrowserAddonService extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart index 3271f2e9..5760470e 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart @@ -23,6 +23,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart'; import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; @@ -286,6 +287,9 @@ class EngineSettingsReplicationService .read(preferenceFixatorProvider.notifier) .register('intl.accept_languages', settings.locales.join(',')); + // Initialize unsigned extensions fixator from Gecko pref + await ref.read(allowUnsignedExtensionsProvider.future); + initialSettingsSent = true; } } diff --git a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart index c81595da..74f8b29b 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.g.dart @@ -44,7 +44,7 @@ final class EngineSettingsReplicationServiceProvider } String _$engineSettingsReplicationServiceHash() => - r'a2fe25ca6458dd22d705affee74036e1ba379972'; + r'81443c37d18ad82f0694cd0e1115ccdf8a0f4c06'; abstract class _$EngineSettingsReplicationService extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart index 99b070c3..01c58f5f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart @@ -40,7 +40,6 @@ class _InstallLocalAddonSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selectedFile = useState(null); - final allowUnsigned = useState(false); final isInstalling = useState(false); final errorMessage = useState(null); @@ -74,10 +73,7 @@ class _InstallLocalAddonSheet extends HookConsumerWidget { try { await ref .read(browserAddonServiceProvider.notifier) - .installFromFile( - selectedFile.value!, - allowUnsigned: allowUnsigned.value, - ); + .installFromFile(selectedFile.value!); if (context.mounted) { showInfoMessage(context, 'Extension installed successfully'); @@ -88,7 +84,7 @@ class _InstallLocalAddonSheet extends HookConsumerWidget { if (errorString.contains('NotSigned') || errorString.contains('SIGNEDSTATE')) { errorMessage.value = - 'This extension is not signed by Mozilla. Enable "Allow unsigned extensions" to install it.'; + 'This extension is not signed by Mozilla. Enable "Allow unsigned extensions" in Extensions settings to install it.'; } else { errorMessage.value = 'Installation failed: $errorString'; } @@ -153,53 +149,6 @@ class _InstallLocalAddonSheet extends HookConsumerWidget { ], ), ), - const SizedBox(height: 16), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Allow unsigned extensions'), - subtitle: const Text( - 'Unsigned extensions have not been verified by Mozilla', - ), - value: allowUnsigned.value, - onChanged: isInstalling.value - ? null - : (value) => allowUnsigned.value = value, - ), - if (allowUnsigned.value) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.errorContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).colorScheme.error, - ), - ), - child: Row( - children: [ - Icon( - Icons.warning_amber, - color: Theme.of(context).colorScheme.error, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Only install unsigned extensions from sources you trust. ' - 'They may contain malicious code.', - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - fontSize: 12, - ), - ), - ), - ], - ), - ), - ], if (errorMessage.value != null) ...[ const SizedBox(height: 8), Container( diff --git a/app/lib/features/settings/presentation/screens/extensions_settings.dart b/app/lib/features/settings/presentation/screens/extensions_settings.dart index aac45719..3404e347 100644 --- a/app/lib/features/settings/presentation/screens/extensions_settings.dart +++ b/app/lib/features/settings/presentation/screens/extensions_settings.dart @@ -17,10 +17,15 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; + import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/install_local_addon_dialog.dart'; import 'package:weblibre/features/settings/presentation/widgets/custom_list_tile.dart'; import 'package:weblibre/features/settings/presentation/widgets/sections.dart'; @@ -43,6 +48,8 @@ class ExtensionsSettingsScreen extends StatelessWidget { SettingSection(name: 'Extensions'), _InstallLocalAddonTile(), _AddonCollectionTile(), + SettingSection(name: 'Security'), + _AllowUnsignedExtensionsTile(), ], ); }, @@ -105,3 +112,154 @@ class _AddonCollectionTile extends StatelessWidget { ); } } + +class _AllowUnsignedExtensionsTile extends ConsumerWidget { + const _AllowUnsignedExtensionsTile(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final allowUnsigned = ref.watch(allowUnsignedExtensionsProvider); + + return Column( + children: [ + SwitchListTile.adaptive( + title: const Text('Allow unsigned extensions'), + subtitle: const Text( + 'Unsigned extensions have not been verified by Mozilla', + ), + secondary: const Icon(Icons.extension_off), + value: allowUnsigned.value ?? false, + onChanged: allowUnsigned.isLoading + ? null + : (value) async { + if (value) { + final confirmed = + await _showAllowUnsignedConfirmationDialog(context); + if (confirmed != true) return; + } + await ref + .read(allowUnsignedExtensionsProvider.notifier) + .setAllowUnsigned(allow: value); + }, + ), + if (allowUnsigned.value == true) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Theme.of(context).colorScheme.error), + ), + child: Row( + children: [ + Icon( + Icons.warning_amber, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Only install unsigned extensions from sources you trust. ' + 'They may contain malicious code.', + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +Future _showAllowUnsignedConfirmationDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => const _AllowUnsignedConfirmationDialog(), + ); +} + +class _AllowUnsignedConfirmationDialog extends HookWidget { + const _AllowUnsignedConfirmationDialog(); + + static const _countdownSeconds = 15; + + @override + Widget build(BuildContext context) { + final remaining = useState(_countdownSeconds); + + useEffect(() { + final timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (remaining.value > 0) { + remaining.value--; + } + }); + return timer.cancel; + }, []); + + final theme = Theme.of(context); + final canConfirm = remaining.value == 0; + + return AlertDialog( + icon: Icon( + Icons.warning_amber_rounded, + color: theme.colorScheme.error, + size: 40, + ), + title: const Text('Allow unsigned extensions?'), + content: Text.rich( + TextSpan( + children: [ + TextSpan( + text: + "Warning: This significantly weakens your browser's security." + '\n\n', + style: TextStyle( + fontWeight: FontWeight.bold, + color: theme.colorScheme.error, + ), + ), + const TextSpan( + text: + "Unsigned extensions bypass Mozilla's safety review process. " + 'Malicious extensions can:\n\n' + '\u2022 Read and modify everything you see on any website\n' + '\u2022 Steal passwords, banking details, and personal data\n' + '\u2022 Monitor your browsing activity silently\n' + '\u2022 Install additional malware on your device\n\n', + ), + const TextSpan( + text: + 'Only enable this if you are a developer installing your own ' + 'extension or absolutely trust the source.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: canConfirm ? () => Navigator.of(context).pop(true) : null, + style: FilledButton.styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + child: Text(canConfirm ? 'Allow' : 'Allow (${remaining.value})'), + ), + ], + ); + } +} +