fix(app): enforce authenticated ephemeral local SOCKS5 #9

Merged
Naveax merged 1 commits from pr/enforce-authenticated-local-socks into main 2026-09-06 11:18:35 +03:00
2 changed files with 82 additions and 69 deletions
Showing only changes of commit c7092531f3 - Show all commits

No files matched your search

+60 -36
View File
@@ -20,13 +20,13 @@ abstract final class LocalSocksTester {
/// Fast local readiness probe used while starting the VPN. /// Fast local readiness probe used while starting the VPN.
/// ///
/// This stops after SOCKS5 authentication. Startup must not depend on an /// This stops after authenticated SOCKS5 negotiation. Startup must not depend
/// unrelated external host being reachable. /// on an unrelated external host being reachable.
static Future<LocalSocksTestResult> testListener({ static Future<LocalSocksTestResult> testListener({
String host = '127.0.0.1', String host = '127.0.0.1',
int port = 10807, required int port,
String? username, required String username,
String? password, required String password,
}) async { }) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
Socket? socket; Socket? socket;
@@ -86,26 +86,40 @@ abstract final class LocalSocksTester {
} }
} }
/// Full user-facing test: authenticated local SOCKS handshake plus outbound /// Full user-facing test: authenticated local SOCKS handshake plus an
/// CONNECT. Credentials are optional so the helper remains usable for legacy /// outbound TCP CONNECT through the active ReVolt route.
/// no-auth diagnostic builds.
static Future<LocalSocksTestResult> test({ static Future<LocalSocksTestResult> test({
String host = '127.0.0.1', String host = '127.0.0.1',
int port = 10807, required int port,
String? username, required String username,
String? password, required String password,
String targetHost = 'paladinvpn.duckdns.org', String targetHost = 'example.com',
int targetPort = 443, int targetPort = 443,
}) async { }) async {
const totalTimeout = Duration(seconds: 8);
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
Socket? socket; Socket? socket;
_SocketReader? reader; _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 { try {
if (targetPort <= 0 || targetPort > 65535) {
return const LocalSocksTestResult(
ok: false,
latencyMs: null,
message: 'SOCKS5 test target is invalid.',
);
}
socket = await Socket.connect( socket = await Socket.connect(
host, host,
port, port,
timeout: const Duration(seconds: 3), timeout: remaining(),
); );
socket.setOption(SocketOption.tcpNoDelay, true); socket.setOption(SocketOption.tcpNoDelay, true);
reader = _SocketReader(socket); reader = _SocketReader(socket);
@@ -115,6 +129,7 @@ abstract final class LocalSocksTester {
socket, socket,
username: username, username: username,
password: password, password: password,
timeout: remaining(),
); );
if (!authenticated) { if (!authenticated) {
return const LocalSocksTestResult( return const LocalSocksTestResult(
@@ -145,7 +160,7 @@ abstract final class LocalSocksTester {
]); ]);
await socket.flush(); await socket.flush();
final replyHead = await reader.readExactly(4); final replyHead = await reader.readExactly(4, timeout: remaining());
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) { if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
return const LocalSocksTestResult( return const LocalSocksTestResult(
ok: false, ok: false,
@@ -155,7 +170,7 @@ abstract final class LocalSocksTester {
); );
} }
await _consumeAddress(reader, replyHead[3]); await _consumeAddress(reader, replyHead[3], timeout: remaining());
stopwatch.stop(); stopwatch.stop();
return LocalSocksTestResult( return LocalSocksTestResult(
ok: true, ok: true,
@@ -189,23 +204,10 @@ abstract final class LocalSocksTester {
static Future<bool> _authenticate( static Future<bool> _authenticate(
_SocketReader reader, _SocketReader reader,
Socket socket, { Socket socket, {
required String? username, required String username,
required String? password, required String password,
Duration timeout = const Duration(seconds: 4), Duration timeout = const Duration(seconds: 4),
}) async { }) async {
final passwordAuth = username != null && password != null;
if ((username == null) != (password == null)) return false;
socket.add(passwordAuth
? const <int>[0x05, 0x01, 0x02]
: const <int>[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 userBytes = utf8.encode(username);
final passBytes = utf8.encode(password); final passBytes = utf8.encode(password);
if (userBytes.isEmpty || if (userBytes.isEmpty ||
@@ -215,6 +217,15 @@ abstract final class LocalSocksTester {
return false; 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 <int>[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(<int>[ socket.add(<int>[
0x01, 0x01,
userBytes.length, userBytes.length,
@@ -229,18 +240,24 @@ abstract final class LocalSocksTester {
} }
static Future<void> _consumeAddress( static Future<void> _consumeAddress(
_SocketReader reader, int addressType) async { _SocketReader reader,
int addressType, {
required Duration timeout,
}) async {
if (addressType == 0x01) { if (addressType == 0x01) {
await reader.readExactly(4 + 2); await reader.readExactly(4 + 2, timeout: timeout);
return; return;
} }
if (addressType == 0x04) { if (addressType == 0x04) {
await reader.readExactly(16 + 2); await reader.readExactly(16 + 2, timeout: timeout);
return; return;
} }
if (addressType == 0x03) { if (addressType == 0x03) {
final length = (await reader.readExactly(1)).first; final length = (await reader.readExactly(1, timeout: timeout)).first;
await reader.readExactly(length + 2); if (length == 0) {
throw const FormatException('Invalid SOCKS5 domain length');
}
await reader.readExactly(length + 2, timeout: timeout);
return; return;
} }
throw const FormatException('Unsupported SOCKS5 address type'); throw const FormatException('Unsupported SOCKS5 address type');
@@ -257,8 +274,15 @@ class _SocketReader {
int count, { int count, {
Duration timeout = const Duration(seconds: 4), Duration timeout = const Duration(seconds: 4),
}) async { }) async {
final elapsed = Stopwatch()..start();
while (_buffer.length < count) { 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) { if (!hasNext) {
throw const SocketException('Socket closed early'); throw const SocketException('Socket closed early');
} }
+22 -33
View File
@@ -39,40 +39,29 @@ class SecureSocksSession {
final username = _token(16); final username = _token(16);
final password = _token(32); final password = _token(32);
final sourceInbounds = decoded['inbounds']; // ReVolt owns local ingress. Never accept listeners supplied by a remote
final inbounds = <dynamic>[]; // or imported Xray config; the client exposes exactly one authenticated
if (sourceInbounds is List) { // loopback SOCKS5 listener for the current ephemeral session.
for (final value in sourceInbounds) { decoded['inbounds'] = <Map<String, dynamic>>[
if (value is Map) { <String, dynamic>{
final protocol = value['protocol']?.toString().toLowerCase(); 'tag': inboundTag,
// ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP 'port': port,
// listeners so a stale no-auth proxy cannot remain reachable beside 'listen': '127.0.0.1',
// the authenticated session listener. 'protocol': 'socks',
if (protocol == 'socks' || protocol == 'http') continue; 'settings': <String, dynamic>{
} 'auth': 'password',
inbounds.add(value); 'udp': true,
} 'ip': '127.0.0.1',
} 'users': <Map<String, String>>[
<String, String>{'user': username, 'pass': password},
inbounds.add(<String, dynamic>{ ],
'tag': inboundTag, },
'port': port, 'sniffing': <String, dynamic>{
'listen': '127.0.0.1', 'enabled': true,
'protocol': 'socks', 'destOverride': <String>['http', 'tls'],
'settings': <String, dynamic>{ },
'auth': 'password',
'udp': true,
'ip': '127.0.0.1',
'users': <Map<String, String>>[
<String, String>{'user': username, 'pass': password},
],
}, },
'sniffing': <String, dynamic>{ ];
'enabled': true,
'destOverride': <String>['http', 'tls'],
},
});
decoded['inbounds'] = inbounds;
return SecureSocksSession._( return SecureSocksSession._(
port: port, port: port,