diff --git a/docs/security.md b/docs/security.md index 545fbabf..580174e1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -22,6 +22,7 @@ On upgrade from older databases, existing plaintext secrets in SQLite are **migr Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring. + ## Archive install limits (extensions and updates) Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app updater extraction use `SafeZipExtractor` (`lib/core/security/safe_zip_extractor.dart`) with shared default limits: @@ -35,3 +36,4 @@ Marketplace downloads, local extension sideload (`.zip` / `.qext`), and in-app u | Max compression ratio (uncompressed รท compressed) | 100:1 | Archives exceeding these bounds fail closed before files are written to disk. Path traversal checks remain in `archive_path_guard.dart`. + diff --git a/lib/core/theme/theme_remote_install_service.dart b/lib/core/theme/theme_remote_install_service.dart index fa958c89..7b50bde6 100644 --- a/lib/core/theme/theme_remote_install_service.dart +++ b/lib/core/theme/theme_remote_install_service.dart @@ -75,6 +75,12 @@ class ThemeRemoteInstallService { final expectedChecksum = _normalizeSha256( sha256Checksum ?? uri.queryParameters['sha256'], ); + if (expectedChecksum == null) { + return const ThemeDefinitionImportFailure( + 'SHA256 checksum is required for remote theme install. ' + 'Add ?sha256= to the URL or pass sha256Checksum.', + ); + } File? tempFile; try { @@ -92,7 +98,7 @@ class ThemeRemoteInstallService { } final actualChecksum = sha256.convert(utf8.encode(body)).toString(); - if (expectedChecksum != null && expectedChecksum != actualChecksum) { + if (expectedChecksum != actualChecksum) { return const ThemeDefinitionImportFailure( 'Checksum mismatch. Theme was not installed.', ); diff --git a/test/core/theme/theme_remote_install_service_test.dart b/test/core/theme/theme_remote_install_service_test.dart index ad80b0cb..8f10ac5a 100644 --- a/test/core/theme/theme_remote_install_service_test.dart +++ b/test/core/theme/theme_remote_install_service_test.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; @@ -26,6 +28,8 @@ Future _fixtureAssetLoader(String assetPath) async { return File(p.join('test/fixtures/themes', fileName)).readAsString(); } +String _sha256Hex(String body) => sha256.convert(utf8.encode(body)).toString(); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -100,6 +104,7 @@ void main() { final result = await service.installFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), ); expect(result, isA()); @@ -136,18 +141,37 @@ void main() { expect(await ExtensionPaths.mockExtensionsDirectory!.list().length, 0); }); + test('rejects install when SHA256 checksum is missing', () async { + final service = ThemeRemoteInstallService( + registry, + allowLocalhostInDebug: false, + ); + + final result = await service.installFromUrl( + 'https://cdn.example.com/themes/querya_custom_dark.json', + ); + + expect(result, isA()); + expect( + (result as ThemeDefinitionImportFailure).message, + contains('SHA256 checksum is required'), + ); + }); + test('rejects invalid JSON without writing to themes folder', () async { + const brokenBody = '{ not valid json'; final service = ThemeRemoteInstallService( registry, allowLocalhostInDebug: false, httpGet: (_) async => const RemoteThemeHttpResponse( statusCode: 200, - body: '{ not valid json', + body: brokenBody, ), ); final result = await service.installFromUrl( 'https://cdn.example.com/themes/broken.json', + sha256Checksum: _sha256Hex(brokenBody), ); expect(result, isA()); @@ -219,6 +243,7 @@ void main() { final result = await service.installFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), ); expect(result, isA()); @@ -235,6 +260,7 @@ void main() { final result = await controller.importRegistryThemeFromUrl( 'https://cdn.example.com/themes/querya_custom_dark.json', + sha256Checksum: _sha256Hex(raw), remoteInstallService: ThemeRemoteInstallService( registry, allowLocalhostInDebug: false,