From c2c4d4abef9d14070116343c156d8b1d05c15a13 Mon Sep 17 00:00:00 2001 From: Naveax Date: Fri, 4 Sep 2026 01:47:24 +0300 Subject: [PATCH] fix(app): harden Hivemind control-plane trust boundaries Bound and nonce-bind control responses, reject redirects, and pin session tunnel coordinates so control-plane compromise cannot redirect VPN traffic. --- lib/logic/hivemind_service.dart | 256 ++++++++++++++++++++++++++++---- 1 file changed, 223 insertions(+), 33 deletions(-) diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index e1f3d8a..3791d2b 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -13,6 +13,7 @@ class HivemindService { static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36'; + static const int _maxControlResponseBytes = 256 * 1024; static const _pollBackoff = [ Duration(seconds: 1), Duration(seconds: 2), @@ -24,10 +25,61 @@ class HivemindService { Uri uri, { Duration timeout = const Duration(seconds: 5), }) { - return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout); + return controlGet(uri, timeout: timeout); } - static void cancel() => _currentCallId++; + static Future controlGet( + Uri uri, { + Duration timeout = const Duration(seconds: 5), + }) async { + final client = http.Client(); + final request = http.Request('GET', uri) + ..headers['User-Agent'] = _ua + ..followRedirects = false; + + Future read() async { + final streamed = await client.send(request); + final declaredLength = streamed.contentLength; + if (declaredLength != null && declaredLength > _maxControlResponseBytes) { + throw const FormatException('Control response is too large.'); + } + + final bytes = []; + await for (final chunk in streamed.stream) { + if (bytes.length + chunk.length > _maxControlResponseBytes) { + throw const FormatException('Control response is too large.'); + } + bytes.addAll(chunk); + } + + return http.Response.bytes( + bytes, + streamed.statusCode, + headers: streamed.headers, + isRedirect: streamed.isRedirect, + persistentConnection: streamed.persistentConnection, + reasonPhrase: streamed.reasonPhrase, + request: request, + ); + } + + try { + return await read().timeout( + timeout, + onTimeout: () { + client.close(); + throw TimeoutException('Control request timed out.', timeout); + }, + ); + } finally { + client.close(); + } + } + + static void cancel() { + _currentCallId++; + _expectedNonce = null; + } static void setExpectedNonce(String nonce) { _expectedNonce = nonce; @@ -37,13 +89,19 @@ class HivemindService { void Function(int attempt, int total)? onAttempt, bool skipAdBypass = false, }) async { - final deviceId = await CryptoService.getDeviceId(); + // Capture the generation before the first await. Otherwise a disconnect + // that lands while secure storage is resolving the device ID can be lost + // and the cancelled call can resume network work with a fresh generation. final callId = ++_currentCallId; + final deviceId = await CryptoService.getDeviceId(); + _throwIfCancelled(callId); + final nonce = _newNonce(); _expectedNonce = nonce; if (!skipAdBypass) { await _runConfiguredBypass(deviceId, nonce); + _throwIfCancelled(callId); } const maxAttempts = 5; @@ -53,6 +111,7 @@ class HivemindService { try { final session = await _fetchActiveSession(deviceId, nonce); + _throwIfCancelled(callId); if (session != null) { _expectedNonce = null; return session.toVlessUrl(); @@ -73,7 +132,7 @@ class HivemindService { static Future checkHealth() async { try { - final response = await directGet( + final response = await controlGet( _publicUrl('/health'), timeout: const Duration(seconds: 3), ); @@ -109,15 +168,15 @@ class HivemindService { final statusUri = _publicUrl('/session/status').replace( queryParameters: {'device_id': deviceId}, ); - final response = await directGet(statusUri); + final response = await controlGet(statusUri); if (response.statusCode != 200) return null; final decoded = jsonDecode(response.body); if (decoded is! Map) return null; final serverNonce = decoded['nonce']; - if (_expectedNonce != null && - serverNonce is String && + if (_expectedNonce != nonce || + serverNonce is! String || serverNonce != nonce) { return null; } @@ -141,6 +200,13 @@ class HivemindService { } class _HivemindSessionConfig { + static final RegExp _uuidPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + caseSensitive: false, + ); + static final RegExp _shortIdPattern = + RegExp(r'^[0-9a-f]{0,16}$', caseSensitive: false); + final String uuid; final String host; final int port; @@ -162,50 +228,174 @@ class _HivemindSessionConfig { }); factory _HivemindSessionConfig.fromJson(Map json) { - final uuid = _requiredString(json, 'vless_uuid'); - final host = _stringOr(json['vless_ip'], AppConfig.serverIp); - final port = _portOr(json['vless_port'], 443); - final sni = _requiredString(json, 'reality_sni'); + final uuid = _requiredString(json, 'vless_uuid', maxLength: 64); + if (!_uuidPattern.hasMatch(uuid)) { + throw const FormatException('Invalid VLESS UUID'); + } + + // The API may rotate credentials and Reality parameters, but it must never + // choose the tunnel destination. Keep that trust anchor compiled into the + // app so an API/domain compromise can cause denial of service, not redirect + // VPN traffic to an attacker-controlled server. + final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp'); + final advertisedHost = json['vless_ip']; + if (advertisedHost != null) { + if (advertisedHost is! String || advertisedHost.trim() != pinnedHost) { + throw const FormatException( + 'Session VLESS host does not match compiled server pin', + ); + } + } + final host = pinnedHost; + + final advertisedPort = json['vless_port']; + if (advertisedPort != null && + (advertisedPort is! num || advertisedPort.toInt() != 443)) { + throw const FormatException('Session VLESS port does not match compiled pin'); + } + const port = 443; + final sni = _validatedHost( + _requiredString(json, 'reality_sni', maxLength: 253), + 'reality_sni', + ); + final publicKey = _requiredString(json, 'reality_pbk', maxLength: 128); + final shortId = _stringOr(json['reality_sid'], '').trim(); + if (!_shortIdPattern.hasMatch(shortId)) { + throw const FormatException('Invalid Reality short ID'); + } + + final fingerprint = + _boundedString(json['reality_fp'], AppConfig.realityFp, 32, 'reality_fp'); + final path = + _boundedString(json['xhttp_path'], AppConfig.vlessPath, 2048, 'xhttp_path'); + if (!path.startsWith('/')) { + throw const FormatException('Invalid XHTTP path'); + } return _HivemindSessionConfig( uuid: uuid, host: host, port: port, - publicKey: _stringOr(json['reality_pbk'], ''), - shortId: _stringOr(json['reality_sid'], ''), + publicKey: publicKey, + shortId: shortId, sni: sni, - fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp), - path: _stringOr(json['xhttp_path'], AppConfig.vlessPath), + fingerprint: fingerprint, + path: path, ); } String toVlessUrl() { - return 'vless://$uuid@$host:$port' - '?security=${AppConfig.vlessSecurity}' - '&type=${AppConfig.vlessType}' - '&path=$path' - '&pbk=${Uri.encodeComponent(publicKey)}' - '&sni=${Uri.encodeComponent(sni)}' - '&sid=${Uri.encodeComponent(shortId)}' - '&fp=${Uri.encodeComponent(fingerprint)}' - '#Revolt VPN'; + return Uri( + scheme: 'vless', + userInfo: uuid, + host: host, + port: port, + queryParameters: { + 'security': AppConfig.vlessSecurity, + 'type': AppConfig.vlessType, + 'path': path, + 'pbk': publicKey, + 'sni': sni, + 'sid': shortId, + 'fp': fingerprint, + }, + fragment: 'Revolt VPN', + ).toString(); } - static String _requiredString(Map json, String key) { + static String _requiredString( + Map json, + String key, { + int maxLength = 4096, + }) { final value = json[key]; - if (value is String && value.trim().isNotEmpty) return value.trim(); - throw FormatException('Missing required session field: $key'); + if (value is! String) { + throw FormatException('Missing required session field: $key'); + } + final trimmed = value.trim(); + if (trimmed.isEmpty || trimmed.length > maxLength) { + throw FormatException('Invalid session field: $key'); + } + return trimmed; } static String _stringOr(Object? value, String fallback) { - return value is String && value.isNotEmpty ? value : fallback; + return value is String && value.trim().isNotEmpty ? value.trim() : fallback; } - static int _portOr(Object? value, int fallback) { - final parsed = value is num ? value.toInt() : fallback; - if (parsed <= 0 || parsed > 65535) { - throw const FormatException('Invalid VLESS port'); + static String _boundedString( + Object? value, + String fallback, + int maxLength, + String field, + ) { + final result = _stringOr(value, fallback); + if (result.isEmpty || result.length > maxLength) { + throw FormatException('Invalid session field: $field'); } - return parsed; + return result; + } + + static String _validatedHost(String input, String field) { + final value = input.trim(); + if (value.isEmpty || + value.length > 253 || + value.contains(RegExp(r'[\s/@?#]'))) { + throw FormatException('Invalid session host: $field'); + } + try { + final probe = Uri(scheme: 'https', host: value); + if (probe.host.isEmpty) { + throw FormatException('Invalid session host: $field'); + } + } on FormatException { + throw FormatException('Invalid session host: $field'); + } + return value; + } + + static String _validatedPublicIp(String input, String field) { + final value = input.trim().toLowerCase(); + if (_isPublicIpv4(value) || _isPublicIpv6(value)) return value; + throw FormatException('Invalid public IP pin: $field'); + } + + static bool _isPublicIpv4(String value) { + final parts = value.split('.'); + if (parts.length != 4) return false; + final octets = []; + for (final part in parts) { + if (part.isEmpty || (part.length > 1 && part.startsWith('0'))) return false; + final parsed = int.tryParse(part); + if (parsed == null || parsed < 0 || parsed > 255) return false; + octets.add(parsed); + } + final a = octets[0]; + final b = octets[1]; + if (a == 0 || a == 10 || a == 127 || a >= 224) return false; + if (a == 100 && b >= 64 && b <= 127) return false; + if (a == 169 && b == 254) return false; + if (a == 172 && b >= 16 && b <= 31) return false; + if (a == 192 && b == 168) return false; + if (a == 192 && b == 0 && octets[2] == 2) return false; + if (a == 198 && b == 51 && octets[2] == 100) return false; + if (a == 203 && b == 0 && octets[2] == 113) return false; + return true; + } + + static bool _isPublicIpv6(String value) { + if (!value.contains(':') || value.contains('.')) return false; + try { + final parsed = Uri.parse('https://[$value]/'); + if (parsed.host.isEmpty || !parsed.host.contains(':')) return false; + } on FormatException { + return false; + } + if (value == '::' || value == '::1') return false; + if (value.startsWith('fc') || value.startsWith('fd')) return false; + if (value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb')) return false; + if (value.startsWith('ff')) return false; + if (value.startsWith('2001:db8:') || value == '2001:db8::') return false; + return true; } }