make allow unsigned permanent

This commit is contained in:
Fabian Freund
2026-03-26 18:32:56 +01:00
parent 0c6d669185
commit 05c52668df
6 changed files with 250 additions and 78 deletions
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<void> 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<bool> 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<Uri> getAddonXpiUrl(String guid) async {
@@ -68,12 +101,7 @@ class BrowserAddonService extends _$BrowserAddonService {
}
}
Future<bool> installFromFile(
String filePath, {
bool allowUnsigned = false,
}) async {
final prefService = GeckoPrefService();
Future<bool> 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',
@@ -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<AllowUnsignedExtensions, bool> {
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<bool> {
FutureOr<bool> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<bool>, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<bool>, bool>,
AsyncValue<bool>,
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> {
void build();
@@ -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;
}
}
@@ -44,7 +44,7 @@ final class EngineSettingsReplicationServiceProvider
}
String _$engineSettingsReplicationServiceHash() =>
r'a2fe25ca6458dd22d705affee74036e1ba379972';
r'81443c37d18ad82f0694cd0e1115ccdf8a0f4c06';
abstract class _$EngineSettingsReplicationService extends $Notifier<void> {
void build();
@@ -40,7 +40,6 @@ class _InstallLocalAddonSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedFile = useState<String?>(null);
final allowUnsigned = useState(false);
final isInstalling = useState(false);
final errorMessage = useState<String?>(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(
@@ -17,10 +17,15 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<bool?> _showAllowUnsignedConfirmationDialog(BuildContext context) {
return showDialog<bool>(
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})'),
),
],
);
}
}