fix(app): enforce authenticated ephemeral local SOCKS5 #9
No files matched your search
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user