diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 9c0cf2cf..ee4c29d7 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/extensions/extension_support.dart'; @@ -12,6 +13,7 @@ import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/core/security/archive_path_guard.dart'; +import 'marketplace_download_policy.dart'; import 'marketplace_repository.dart'; /// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. @@ -22,13 +24,46 @@ class HttpMarketplaceRepository implements MarketplaceRepository { HttpMarketplaceRepository({ this.baseUrl = 'http://localhost:8000/api/v1', http.Client? client, - }) : _client = client ?? http.Client(); + Iterable extraTrustedDownloadHosts = const [], + bool allowLocalhostInDebug = kDebugMode, + }) : _client = client ?? http.Client(), + _allowLocalhostInDebug = allowLocalhostInDebug, + _trustedDownloadHosts = MarketplaceDownloadPolicy.trustedHostsFor( + apiBaseUrl: baseUrl, + extraTrustedHosts: extraTrustedDownloadHosts, + ) { + _validateApiBaseUrl(); + } final String baseUrl; final http.Client _client; + final bool _allowLocalhostInDebug; + final Set _trustedDownloadHosts; + + void _validateApiBaseUrl() { + if (!MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + baseUrl, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + throw MarketplaceException( + 'Marketplace API base URL is not allowed: $baseUrl', + ); + } + } + + void _validateDownloadUrl(Uri uri) { + if (!MarketplaceDownloadPolicy.isAllowedDownloadUrl( + uri, + trustedHosts: _trustedDownloadHosts, + allowLocalhostInDebug: _allowLocalhostInDebug, + )) { + throw MarketplaceException('Download URL is not allowed: $uri'); + } + } @override Future> getTrending({ExtensionType? type}) async { + _validateApiBaseUrl(); final uri = Uri.parse('$baseUrl/extensions/trending').replace( queryParameters: type != null ? {'type': type.value} : null, ); @@ -42,6 +77,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { @override Future> search(String query, {ExtensionType? type}) async { + _validateApiBaseUrl(); final uri = Uri.parse('$baseUrl/extensions/search').replace( queryParameters: { 'q': query.trim(), @@ -62,6 +98,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository { if (uri == null) { throw MarketplaceException('Invalid download URL: $url'); } + _validateDownloadUrl(uri); final request = http.Request('GET', uri); final response = await _client.send(request).timeout(const Duration(seconds: 30)); diff --git a/lib/core/market/marketplace_download_policy.dart b/lib/core/market/marketplace_download_policy.dart new file mode 100644 index 00000000..be5edb04 --- /dev/null +++ b/lib/core/market/marketplace_download_policy.dart @@ -0,0 +1,86 @@ +import 'package:flutter/foundation.dart'; + +import '../theme/theme_remote_install_policy.dart'; + +/// HTTPS and host allowlist rules for marketplace API and artifact downloads. +abstract final class MarketplaceDownloadPolicy { + /// Hosts permitted for extension archive downloads (API host + extras). + static Set trustedHostsFor({ + required String apiBaseUrl, + Iterable extraTrustedHosts = const [], + }) { + final hosts = {}; + final apiHost = Uri.tryParse(apiBaseUrl.trim())?.host.toLowerCase(); + if (apiHost != null && apiHost.isNotEmpty) { + hosts.add(apiHost); + } + for (final host in extraTrustedHosts) { + final normalized = host.trim().toLowerCase(); + if (normalized.isNotEmpty) { + hosts.add(normalized); + } + } + return hosts; + } + + /// Whether [baseUrl] may be used for MarketApi REST calls. + static bool isAllowedApiBaseUrl( + String baseUrl, { + bool allowLocalhostInDebug = kDebugMode, + }) { + final uri = Uri.tryParse(baseUrl.trim()); + if (uri == null || !uri.hasAuthority || uri.host.isEmpty) { + return false; + } + + if (uri.scheme == 'https') { + return ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: allowLocalhostInDebug, + ); + } + + if (allowLocalhostInDebug && uri.scheme == 'http') { + return _isDebugLocalHttpHost(uri.host); + } + + return false; + } + + /// Whether [uri] may be used to download an extension archive. + static bool isAllowedDownloadUrl( + Uri uri, { + required Set trustedHosts, + bool allowLocalhostInDebug = kDebugMode, + }) { + if (!uri.hasAuthority || uri.host.isEmpty) { + return false; + } + + final host = uri.host.toLowerCase(); + if (!trustedHosts.contains(host)) { + return false; + } + + if (uri.scheme == 'https') { + return ThemeRemoteInstallPolicy.isAllowedUrl( + uri, + allowLocalhostInDebug: allowLocalhostInDebug, + ); + } + + if (allowLocalhostInDebug && uri.scheme == 'http') { + return _isDebugLocalHttpHost(host); + } + + return false; + } + + static bool _isDebugLocalHttpHost(String host) { + final probe = Uri.parse('https://$host/'); + return ThemeRemoteInstallPolicy.isAllowedUrl( + probe, + allowLocalhostInDebug: true, + ); + } +} diff --git a/test/core/market/marketplace_download_policy_test.dart b/test/core/market/marketplace_download_policy_test.dart new file mode 100644 index 00000000..547e7490 --- /dev/null +++ b/test/core/market/marketplace_download_policy_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/market/marketplace_download_policy.dart'; + +void main() { + group('MarketplaceDownloadPolicy', () { + test('trustedHostsFor includes API host and extras', () { + expect( + MarketplaceDownloadPolicy.trustedHostsFor( + apiBaseUrl: 'https://api.example.com/api/v1', + extraTrustedHosts: ['cdn.example.com'], + ), + {'api.example.com', 'cdn.example.com'}, + ); + }); + + test('isAllowedApiBaseUrl allows public https API', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'https://api.example.com/api/v1', + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + + test('isAllowedApiBaseUrl rejects cleartext in release mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'http://localhost:8000/api/v1', + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedApiBaseUrl allows localhost http in debug mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedApiBaseUrl( + 'http://localhost:8000/api/v1', + allowLocalhostInDebug: true, + ), + isTrue, + ); + }); + + test('isAllowedDownloadUrl rejects untrusted host', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://evil.example.com/pkg.zip'), + trustedHosts: {'api.example.com'}, + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl rejects private IPs in release mode', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://192.168.1.10/pkg.zip'), + trustedHosts: {'192.168.1.10'}, + allowLocalhostInDebug: false, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl rejects file scheme', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('file:///etc/passwd'), + trustedHosts: {'localhost'}, + allowLocalhostInDebug: true, + ), + isFalse, + ); + }); + + test('isAllowedDownloadUrl allows trusted public https host', () { + expect( + MarketplaceDownloadPolicy.isAllowedDownloadUrl( + Uri.parse('https://cdn.example.com/pkg.zip'), + trustedHosts: {'cdn.example.com'}, + allowLocalhostInDebug: false, + ), + isTrue, + ); + }); + }); +} diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index e3aeeda1..4e23cfd5 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -355,6 +355,35 @@ void main() { )), ); }); + + test('download rejects disallowed URLs when release policy is enforced', + () async { + final repo = HttpMarketplaceRepository( + baseUrl: 'https://cdn.example.com/api/v1', + extraTrustedDownloadHosts: ['cdn.example.com'], + allowLocalhostInDebug: false, + client: MockClient((request) async => http.Response('', 200)), + ); + + expect( + () => repo.download('http://cdn.example.com/test.zip'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Download URL is not allowed'), + )), + ); + + expect( + () => repo.download('https://127.0.0.1/test.zip'), + throwsA(isA()), + ); + + expect( + () => repo.download('file:///tmp/test.zip'), + throwsA(isA()), + ); + }); }); }