From d2283c07cc8a0a0c6c2e08b70a8c2cbbc326e850 Mon Sep 17 00:00:00 2001 From: Naveax Date: Sun, 6 Sep 2026 11:18:35 +0300 Subject: [PATCH] fix(app): enforce authenticated ephemeral local SOCKS5 (#9) Require the active per-session loopback SOCKS credentials and remove unauthenticated fallback behavior from the client diagnostic path. --- lib/logic/local_socks_tester.dart | 96 ++++++++++++++++++----------- lib/logic/secure_socks_session.dart | 55 +++++++---------- 2 files changed, 82 insertions(+), 69 deletions(-) diff --git a/lib/logic/local_socks_tester.dart b/lib/logic/local_socks_tester.dart index 6e405bc..b672e88 100644 --- a/lib/logic/local_socks_tester.dart +++ b/lib/logic/local_socks_tester.dart @@ -20,13 +20,13 @@ abstract final class LocalSocksTester { /// Fast local readiness probe used while starting the VPN. /// - /// This stops after SOCKS5 authentication. Startup must not depend on an - /// unrelated external host being reachable. + /// This stops after authenticated SOCKS5 negotiation. Startup must not depend + /// on an unrelated external host being reachable. static Future testListener({ String host = '127.0.0.1', - int port = 10807, - String? username, - String? password, + required int port, + required String username, + required String password, }) async { final stopwatch = Stopwatch()..start(); Socket? socket; @@ -86,26 +86,40 @@ abstract final class LocalSocksTester { } } - /// Full user-facing test: authenticated local SOCKS handshake plus outbound - /// CONNECT. Credentials are optional so the helper remains usable for legacy - /// no-auth diagnostic builds. + /// Full user-facing test: authenticated local SOCKS handshake plus an + /// outbound TCP CONNECT through the active ReVolt route. static Future test({ String host = '127.0.0.1', - int port = 10807, - String? username, - String? password, - String targetHost = 'paladinvpn.duckdns.org', + required int port, + required String username, + required String password, + String targetHost = 'example.com', int targetPort = 443, }) async { + const totalTimeout = Duration(seconds: 8); final stopwatch = Stopwatch()..start(); Socket? socket; _SocketReader? reader; + Duration remaining() { + final micros = totalTimeout.inMicroseconds - stopwatch.elapsedMicroseconds; + if (micros <= 0) throw TimeoutException('SOCKS5 test timed out.', totalTimeout); + return Duration(microseconds: micros); + } + try { + if (targetPort <= 0 || targetPort > 65535) { + return const LocalSocksTestResult( + ok: false, + latencyMs: null, + message: 'SOCKS5 test target is invalid.', + ); + } + socket = await Socket.connect( host, port, - timeout: const Duration(seconds: 3), + timeout: remaining(), ); socket.setOption(SocketOption.tcpNoDelay, true); reader = _SocketReader(socket); @@ -115,6 +129,7 @@ abstract final class LocalSocksTester { socket, username: username, password: password, + timeout: remaining(), ); if (!authenticated) { return const LocalSocksTestResult( @@ -145,7 +160,7 @@ abstract final class LocalSocksTester { ]); await socket.flush(); - final replyHead = await reader.readExactly(4); + final replyHead = await reader.readExactly(4, timeout: remaining()); if (replyHead[0] != 0x05 || replyHead[1] != 0x00) { return const LocalSocksTestResult( ok: false, @@ -155,7 +170,7 @@ abstract final class LocalSocksTester { ); } - await _consumeAddress(reader, replyHead[3]); + await _consumeAddress(reader, replyHead[3], timeout: remaining()); stopwatch.stop(); return LocalSocksTestResult( ok: true, @@ -189,23 +204,10 @@ abstract final class LocalSocksTester { static Future _authenticate( _SocketReader reader, Socket socket, { - required String? username, - required String? password, + required String username, + required String password, Duration timeout = const Duration(seconds: 4), }) async { - final passwordAuth = username != null && password != null; - if ((username == null) != (password == null)) return false; - - socket.add(passwordAuth - ? const [0x05, 0x01, 0x02] - : const [0x05, 0x01, 0x00]); - await socket.flush(); - - final greeting = await reader.readExactly(2, timeout: timeout); - if (greeting[0] != 0x05) return false; - if (!passwordAuth) return greeting[1] == 0x00; - if (greeting[1] != 0x02) return false; - final userBytes = utf8.encode(username); final passBytes = utf8.encode(password); if (userBytes.isEmpty || @@ -215,6 +217,15 @@ abstract final class LocalSocksTester { return false; } + // ReVolt never accepts unauthenticated local SOCKS. Advertising only RFC + // 1929 username/password auth makes a future credential-plumbing regression + // fail closed instead of silently falling back to method 0x00. + socket.add(const [0x05, 0x01, 0x02]); + await socket.flush(); + + final greeting = await reader.readExactly(2, timeout: timeout); + if (greeting[0] != 0x05 || greeting[1] != 0x02) return false; + socket.add([ 0x01, userBytes.length, @@ -229,18 +240,24 @@ abstract final class LocalSocksTester { } static Future _consumeAddress( - _SocketReader reader, int addressType) async { + _SocketReader reader, + int addressType, { + required Duration timeout, + }) async { if (addressType == 0x01) { - await reader.readExactly(4 + 2); + await reader.readExactly(4 + 2, timeout: timeout); return; } if (addressType == 0x04) { - await reader.readExactly(16 + 2); + await reader.readExactly(16 + 2, timeout: timeout); return; } if (addressType == 0x03) { - final length = (await reader.readExactly(1)).first; - await reader.readExactly(length + 2); + final length = (await reader.readExactly(1, timeout: timeout)).first; + if (length == 0) { + throw const FormatException('Invalid SOCKS5 domain length'); + } + await reader.readExactly(length + 2, timeout: timeout); return; } throw const FormatException('Unsupported SOCKS5 address type'); @@ -257,8 +274,15 @@ class _SocketReader { int count, { Duration timeout = const Duration(seconds: 4), }) async { + final elapsed = Stopwatch()..start(); while (_buffer.length < count) { - final hasNext = await _iterator.moveNext().timeout(timeout); + final remainingMicros = timeout.inMicroseconds - elapsed.elapsedMicroseconds; + if (remainingMicros <= 0) { + throw TimeoutException('Socket read timed out', timeout); + } + final hasNext = await _iterator.moveNext().timeout( + Duration(microseconds: remainingMicros), + ); if (!hasNext) { throw const SocketException('Socket closed early'); } diff --git a/lib/logic/secure_socks_session.dart b/lib/logic/secure_socks_session.dart index 2a88247..8c31a31 100644 --- a/lib/logic/secure_socks_session.dart +++ b/lib/logic/secure_socks_session.dart @@ -39,40 +39,29 @@ class SecureSocksSession { final username = _token(16); final password = _token(32); - final sourceInbounds = decoded['inbounds']; - final inbounds = []; - if (sourceInbounds is List) { - for (final value in sourceInbounds) { - if (value is Map) { - final protocol = value['protocol']?.toString().toLowerCase(); - // ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP - // listeners so a stale no-auth proxy cannot remain reachable beside - // the authenticated session listener. - if (protocol == 'socks' || protocol == 'http') continue; - } - inbounds.add(value); - } - } - - inbounds.add({ - 'tag': inboundTag, - 'port': port, - 'listen': '127.0.0.1', - 'protocol': 'socks', - 'settings': { - 'auth': 'password', - 'udp': true, - 'ip': '127.0.0.1', - 'users': >[ - {'user': username, 'pass': password}, - ], + // ReVolt owns local ingress. Never accept listeners supplied by a remote + // or imported Xray config; the client exposes exactly one authenticated + // loopback SOCKS5 listener for the current ephemeral session. + decoded['inbounds'] = >[ + { + 'tag': inboundTag, + 'port': port, + 'listen': '127.0.0.1', + 'protocol': 'socks', + 'settings': { + 'auth': 'password', + 'udp': true, + 'ip': '127.0.0.1', + 'users': >[ + {'user': username, 'pass': password}, + ], + }, + 'sniffing': { + 'enabled': true, + 'destOverride': ['http', 'tls'], + }, }, - 'sniffing': { - 'enabled': true, - 'destOverride': ['http', 'tls'], - }, - }); - decoded['inbounds'] = inbounds; + ]; return SecureSocksSession._( port: port,