Skip to content

Commit 24e700e

Browse files
Merge pull request #406 from QueryaHub/issue/397-marketplace-download-urls
fix(marketplace): restrict extension download URLs (HTTPS allowlist)
2 parents 57e27e2 + e7372ab commit 24e700e

4 files changed

Lines changed: 243 additions & 1 deletion

File tree

‎lib/core/market/http_marketplace_repository.dart‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'dart:io';
44
import 'package:archive/archive.dart';
55
import 'package:crypto/crypto.dart';
6+
import 'package:flutter/foundation.dart';
67
import 'package:http/http.dart' as http;
78
import 'package:path/path.dart' as p;
89
import 'package:querya_desktop/core/extensions/extension_support.dart';
@@ -12,6 +13,7 @@ import 'package:querya_desktop/core/extensions/local_extension_registry.dart';
1213
import 'package:querya_desktop/core/extensions/models/extension_manifest.dart';
1314
import 'package:querya_desktop/core/extensions/models/extension_type.dart';
1415
import 'package:querya_desktop/core/security/archive_path_guard.dart';
16+
import 'marketplace_download_policy.dart';
1517
import 'marketplace_repository.dart';
1618

1719
/// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend.
@@ -22,13 +24,46 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
2224
HttpMarketplaceRepository({
2325
this.baseUrl = 'http://localhost:8000/api/v1',
2426
http.Client? client,
25-
}) : _client = client ?? http.Client();
27+
Iterable<String> extraTrustedDownloadHosts = const [],
28+
bool allowLocalhostInDebug = kDebugMode,
29+
}) : _client = client ?? http.Client(),
30+
_allowLocalhostInDebug = allowLocalhostInDebug,
31+
_trustedDownloadHosts = MarketplaceDownloadPolicy.trustedHostsFor(
32+
apiBaseUrl: baseUrl,
33+
extraTrustedHosts: extraTrustedDownloadHosts,
34+
) {
35+
_validateApiBaseUrl();
36+
}
2637

2738
final String baseUrl;
2839
final http.Client _client;
40+
final bool _allowLocalhostInDebug;
41+
final Set<String> _trustedDownloadHosts;
42+
43+
void _validateApiBaseUrl() {
44+
if (!MarketplaceDownloadPolicy.isAllowedApiBaseUrl(
45+
baseUrl,
46+
allowLocalhostInDebug: _allowLocalhostInDebug,
47+
)) {
48+
throw MarketplaceException(
49+
'Marketplace API base URL is not allowed: $baseUrl',
50+
);
51+
}
52+
}
53+
54+
void _validateDownloadUrl(Uri uri) {
55+
if (!MarketplaceDownloadPolicy.isAllowedDownloadUrl(
56+
uri,
57+
trustedHosts: _trustedDownloadHosts,
58+
allowLocalhostInDebug: _allowLocalhostInDebug,
59+
)) {
60+
throw MarketplaceException('Download URL is not allowed: $uri');
61+
}
62+
}
2963

3064
@override
3165
Future<List<ExtensionManifest>> getTrending({ExtensionType? type}) async {
66+
_validateApiBaseUrl();
3267
final uri = Uri.parse('$baseUrl/extensions/trending').replace(
3368
queryParameters: type != null ? {'type': type.value} : null,
3469
);
@@ -42,6 +77,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
4277

4378
@override
4479
Future<List<ExtensionManifest>> search(String query, {ExtensionType? type}) async {
80+
_validateApiBaseUrl();
4581
final uri = Uri.parse('$baseUrl/extensions/search').replace(
4682
queryParameters: {
4783
'q': query.trim(),
@@ -62,6 +98,7 @@ class HttpMarketplaceRepository implements MarketplaceRepository {
6298
if (uri == null) {
6399
throw MarketplaceException('Invalid download URL: $url');
64100
}
101+
_validateDownloadUrl(uri);
65102

66103
final request = http.Request('GET', uri);
67104
final response = await _client.send(request).timeout(const Duration(seconds: 30));
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import 'package:flutter/foundation.dart';
2+
3+
import '../theme/theme_remote_install_policy.dart';
4+
5+
/// HTTPS and host allowlist rules for marketplace API and artifact downloads.
6+
abstract final class MarketplaceDownloadPolicy {
7+
/// Hosts permitted for extension archive downloads (API host + extras).
8+
static Set<String> trustedHostsFor({
9+
required String apiBaseUrl,
10+
Iterable<String> extraTrustedHosts = const [],
11+
}) {
12+
final hosts = <String>{};
13+
final apiHost = Uri.tryParse(apiBaseUrl.trim())?.host.toLowerCase();
14+
if (apiHost != null && apiHost.isNotEmpty) {
15+
hosts.add(apiHost);
16+
}
17+
for (final host in extraTrustedHosts) {
18+
final normalized = host.trim().toLowerCase();
19+
if (normalized.isNotEmpty) {
20+
hosts.add(normalized);
21+
}
22+
}
23+
return hosts;
24+
}
25+
26+
/// Whether [baseUrl] may be used for MarketApi REST calls.
27+
static bool isAllowedApiBaseUrl(
28+
String baseUrl, {
29+
bool allowLocalhostInDebug = kDebugMode,
30+
}) {
31+
final uri = Uri.tryParse(baseUrl.trim());
32+
if (uri == null || !uri.hasAuthority || uri.host.isEmpty) {
33+
return false;
34+
}
35+
36+
if (uri.scheme == 'https') {
37+
return ThemeRemoteInstallPolicy.isAllowedUrl(
38+
uri,
39+
allowLocalhostInDebug: allowLocalhostInDebug,
40+
);
41+
}
42+
43+
if (allowLocalhostInDebug && uri.scheme == 'http') {
44+
return _isDebugLocalHttpHost(uri.host);
45+
}
46+
47+
return false;
48+
}
49+
50+
/// Whether [uri] may be used to download an extension archive.
51+
static bool isAllowedDownloadUrl(
52+
Uri uri, {
53+
required Set<String> trustedHosts,
54+
bool allowLocalhostInDebug = kDebugMode,
55+
}) {
56+
if (!uri.hasAuthority || uri.host.isEmpty) {
57+
return false;
58+
}
59+
60+
final host = uri.host.toLowerCase();
61+
if (!trustedHosts.contains(host)) {
62+
return false;
63+
}
64+
65+
if (uri.scheme == 'https') {
66+
return ThemeRemoteInstallPolicy.isAllowedUrl(
67+
uri,
68+
allowLocalhostInDebug: allowLocalhostInDebug,
69+
);
70+
}
71+
72+
if (allowLocalhostInDebug && uri.scheme == 'http') {
73+
return _isDebugLocalHttpHost(host);
74+
}
75+
76+
return false;
77+
}
78+
79+
static bool _isDebugLocalHttpHost(String host) {
80+
final probe = Uri.parse('https://$host/');
81+
return ThemeRemoteInstallPolicy.isAllowedUrl(
82+
probe,
83+
allowLocalhostInDebug: true,
84+
);
85+
}
86+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:querya_desktop/core/market/marketplace_download_policy.dart';
3+
4+
void main() {
5+
group('MarketplaceDownloadPolicy', () {
6+
test('trustedHostsFor includes API host and extras', () {
7+
expect(
8+
MarketplaceDownloadPolicy.trustedHostsFor(
9+
apiBaseUrl: 'https://api.example.com/api/v1',
10+
extraTrustedHosts: ['cdn.example.com'],
11+
),
12+
{'api.example.com', 'cdn.example.com'},
13+
);
14+
});
15+
16+
test('isAllowedApiBaseUrl allows public https API', () {
17+
expect(
18+
MarketplaceDownloadPolicy.isAllowedApiBaseUrl(
19+
'https://api.example.com/api/v1',
20+
allowLocalhostInDebug: false,
21+
),
22+
isTrue,
23+
);
24+
});
25+
26+
test('isAllowedApiBaseUrl rejects cleartext in release mode', () {
27+
expect(
28+
MarketplaceDownloadPolicy.isAllowedApiBaseUrl(
29+
'http://localhost:8000/api/v1',
30+
allowLocalhostInDebug: false,
31+
),
32+
isFalse,
33+
);
34+
});
35+
36+
test('isAllowedApiBaseUrl allows localhost http in debug mode', () {
37+
expect(
38+
MarketplaceDownloadPolicy.isAllowedApiBaseUrl(
39+
'http://localhost:8000/api/v1',
40+
allowLocalhostInDebug: true,
41+
),
42+
isTrue,
43+
);
44+
});
45+
46+
test('isAllowedDownloadUrl rejects untrusted host', () {
47+
expect(
48+
MarketplaceDownloadPolicy.isAllowedDownloadUrl(
49+
Uri.parse('https://evil.example.com/pkg.zip'),
50+
trustedHosts: {'api.example.com'},
51+
allowLocalhostInDebug: false,
52+
),
53+
isFalse,
54+
);
55+
});
56+
57+
test('isAllowedDownloadUrl rejects private IPs in release mode', () {
58+
expect(
59+
MarketplaceDownloadPolicy.isAllowedDownloadUrl(
60+
Uri.parse('https://192.168.1.10/pkg.zip'),
61+
trustedHosts: {'192.168.1.10'},
62+
allowLocalhostInDebug: false,
63+
),
64+
isFalse,
65+
);
66+
});
67+
68+
test('isAllowedDownloadUrl rejects file scheme', () {
69+
expect(
70+
MarketplaceDownloadPolicy.isAllowedDownloadUrl(
71+
Uri.parse('file:///etc/passwd'),
72+
trustedHosts: {'localhost'},
73+
allowLocalhostInDebug: true,
74+
),
75+
isFalse,
76+
);
77+
});
78+
79+
test('isAllowedDownloadUrl allows trusted public https host', () {
80+
expect(
81+
MarketplaceDownloadPolicy.isAllowedDownloadUrl(
82+
Uri.parse('https://cdn.example.com/pkg.zip'),
83+
trustedHosts: {'cdn.example.com'},
84+
allowLocalhostInDebug: false,
85+
),
86+
isTrue,
87+
);
88+
});
89+
});
90+
}

‎test/core/market/marketplace_repository_test.dart‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,35 @@ void main() {
355355
)),
356356
);
357357
});
358+
359+
test('download rejects disallowed URLs when release policy is enforced',
360+
() async {
361+
final repo = HttpMarketplaceRepository(
362+
baseUrl: 'https://cdn.example.com/api/v1',
363+
extraTrustedDownloadHosts: ['cdn.example.com'],
364+
allowLocalhostInDebug: false,
365+
client: MockClient((request) async => http.Response('', 200)),
366+
);
367+
368+
expect(
369+
() => repo.download('http://cdn.example.com/test.zip'),
370+
throwsA(isA<MarketplaceException>().having(
371+
(e) => e.message,
372+
'message',
373+
contains('Download URL is not allowed'),
374+
)),
375+
);
376+
377+
expect(
378+
() => repo.download('https://127.0.0.1/test.zip'),
379+
throwsA(isA<MarketplaceException>()),
380+
);
381+
382+
expect(
383+
() => repo.download('file:///tmp/test.zip'),
384+
throwsA(isA<MarketplaceException>()),
385+
);
386+
});
358387
});
359388
}
360389

0 commit comments

Comments
 (0)