From efe300b7cc763dff9769abe498ee225033aad25d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Mon, 27 Jul 2026 06:45:32 +0300 Subject: [PATCH] fix(theme): require SHA256 checksum for remote theme install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #400 — remote theme URLs fail closed without checksum; document policy in docs/security.md. --- docs/security.md | 4 +++ .../theme/theme_remote_install_service.dart | 8 +++++- .../theme_remote_install_service_test.dart | 28 ++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/security.md b/docs/security.md index 9b9d44a7..82754881 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,3 +21,7 @@ On upgrade from older databases, existing plaintext secrets in SQLite are **migr ## Tests Automated tests use an **in-memory** secrets backend (see `test/flutter_test_config.dart`) so CI does not require a desktop keyring. + +## Remote theme install integrity + +`ThemeRemoteInstallService` requires a **SHA256 checksum** for every remote theme URL install. Pass `sha256Checksum` to the API or append `?sha256=` to the theme URL. Installs without a checksum fail closed before download. This matches the marketplace extension install policy (#396). 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,