Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion lib/core/market/http_marketplace_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand All @@ -22,13 +24,46 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
HttpMarketplaceRepository({
this.baseUrl = 'http://localhost:8000/api/v1',
http.Client? client,
}) : _client = client ?? http.Client();
Iterable<String> 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<String> _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<List<ExtensionManifest>> getTrending({ExtensionType? type}) async {
_validateApiBaseUrl();
final uri = Uri.parse('$baseUrl/extensions/trending').replace(
queryParameters: type != null ? {'type': type.value} : null,
);
Expand All @@ -42,6 +77,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository {

@override
Future<List<ExtensionManifest>> search(String query, {ExtensionType? type}) async {
_validateApiBaseUrl();
final uri = Uri.parse('$baseUrl/extensions/search').replace(
queryParameters: {
'q': query.trim(),
Expand All @@ -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));
Expand Down
86 changes: 86 additions & 0 deletions lib/core/market/marketplace_download_policy.dart
Original file line number Diff line number Diff line change
@@ -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<String> trustedHostsFor({
required String apiBaseUrl,
Iterable<String> extraTrustedHosts = const [],
}) {
final hosts = <String>{};
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<String> 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,
);
}
}
90 changes: 90 additions & 0 deletions test/core/market/marketplace_download_policy_test.dart
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
}
29 changes: 29 additions & 0 deletions test/core/market/marketplace_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<MarketplaceException>().having(
(e) => e.message,
'message',
contains('Download URL is not allowed'),
)),
);

expect(
() => repo.download('https://127.0.0.1/test.zip'),
throwsA(isA<MarketplaceException>()),
);

expect(
() => repo.download('file:///tmp/test.zip'),
throwsA(isA<MarketplaceException>()),
);
});
});
}

Expand Down
Loading