Skip to content

Commit 7666adb

Browse files
committed
fix(android): harden Internet Sharing auth, port handling and credential hygiene
Service (GooseRelayVpnService.kt): - Add RFC 1929 user/pass auth to the sharing SOCKS5 proxy; no-auth path is kept for open home-network use (both creds blank). - Enforce both-or-neither on creds at startInternetSharing: half-blank throws IllegalStateException before any server starts, while the VPN core SOCKS5 stays untouched. The outer connectJob catch surfaces the message to the Logs screen. - Harmonize HTTP proxy auth to the same AND condition so SOCKS5 and HTTP can no longer disagree on whether auth is on. - Replace constant-time-unsafe String.equals with MessageDigest.isEqual via a new constantTimeEquals helper used by both SOCKS5 and HTTP Basic auth paths. - Rewrite port-collision handling: ensureSharingPortFree only cancels the app's own sharing jobs/servers (stopSharingServers) and never calls mobile.Mobile.stopClient(); if the busy port equals the VPN core SOCKS5 port it throws an explicit message asking the user to pick a different port. - Build ServerSocket with the no-arg constructor and set reuseAddress before bind (previously set after bind, which silently did nothing). - Drop dead (accept() ?: continue) branches and add a post-accept isActive guard so spawn races on shutdown don't leak client sockets. - Re-throw CancellationException from sharing proxy loops to respect coroutine cancellation semantics; log client/upstream errors to VpnManager.appendLog so users see failure reasons on the Logs screen. - Rename httpProxyJob to sharingHttpJob for parity with sharingSocksJob and run both server closes through runCatching so a close failure on one cannot block the other from starting. UI (GlobalSettingsScreen.kt): - Mark the empty field as isError (only the empty one) when username/password are in a one-set-one-blank state, with specific supporting text plus an overall 'Set both or leave both empty' message; both-blank or both-set stay neutral so red does not flash on a field the user is actively typing into. - Add port clamping (1025..65535, defaults 8090/8091) to normalize() so an imported clipboard draft with bad ports cannot bypass the typed-input validation. - Strip internetSharingUser and internetSharingPass before exporting settings to the clipboard and notify the user that credentials were excluded. Strings (strings.xml): - Add global_auth_mismatch, global_username_missing, global_password_missing.
1 parent 737c9c3 commit 7666adb

3 files changed

Lines changed: 207 additions & 45 deletions

File tree

android/app/src/main/java/com/gooserelay/gooserelayvpn/service/GooseRelayVpnService.kt

Lines changed: 158 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class GooseRelayVpnService : VpnService() {
6161
private var connectJob: Job? = null
6262
private var vpnInterface: ParcelFileDescriptor? = null
6363
private var goClientJob: Job? = null
64-
private var httpProxyJob: Job? = null
64+
private var sharingHttpJob: Job? = null
6565
private var sharingSocksJob: Job? = null
6666
private var sharingSocksServer: java.net.ServerSocket? = null
6767
private var sharingHttpServer: java.net.ServerSocket? = null
@@ -192,11 +192,11 @@ class GooseRelayVpnService : VpnService() {
192192

193193
// Start Internet Sharing proxies if enabled
194194
if (globalSettings.internetSharingEnabled) {
195-
val socksPort = globalSettings.internetSharingSocksPort
196-
val httpPort = globalSettings.internetSharingHttpPort
195+
val sharingSocksPort = globalSettings.internetSharingSocksPort
196+
val sharingHttpPort = globalSettings.internetSharingHttpPort
197197
val user = globalSettings.internetSharingUser
198198
val pass = globalSettings.internetSharingPass
199-
startInternetSharing(socksPort, httpPort, user, pass)
199+
startInternetSharing(sharingSocksPort, sharingHttpPort, activeLocalSocksPort, user, pass)
200200
}
201201

202202
if (proxyMode) {
@@ -462,8 +462,8 @@ class GooseRelayVpnService : VpnService() {
462462
// Cancel coroutines
463463
VpnManager.appendLog("Stopping Android session jobs...")
464464
goClientJob?.cancel()
465-
httpProxyJob?.cancel()
466465
sharingSocksJob?.cancel()
466+
sharingHttpJob?.cancel()
467467
keepaliveJob?.cancel()
468468
logTailJob?.cancel()
469469

@@ -734,74 +734,194 @@ class GooseRelayVpnService : VpnService() {
734734
}
735735
}
736736

737-
private suspend fun startInternetSharing(socksPort: Int, httpPort: Int, username: String, password: String) {
738-
// Ensure ports are available before starting
739-
if (isLocalPortInUse(socksPort) || isLocalPortInUse(httpPort)) {
740-
VpnManager.appendLog("Sharing ports in use, attempting to free...")
741-
if (mobile.Mobile.isRunning()) {
742-
runCatching { mobile.Mobile.stopClient() }
743-
}
744-
delay(500L)
737+
private suspend fun startInternetSharing(
738+
socksPort: Int,
739+
httpPort: Int,
740+
coreSocksPort: Int,
741+
username: String,
742+
password: String
743+
) {
744+
stopSharingServers()
745+
746+
// ponytail: both-or-neither — half-blank creds are rejected instead of falling back to
747+
// an open or locked proxy. UI flags this too; this is the trust-boundary enforcement.
748+
val userBlank = username.isBlank()
749+
val passBlank = password.isBlank()
750+
if (userBlank != passBlank) {
751+
throw IllegalStateException(
752+
"Internet Sharing requires both username and password, or neither. Set both in Settings."
753+
)
745754
}
746-
747-
sharingSocksJob?.cancel()
748-
sharingSocksServer?.close()
749-
sharingSocksServer = null
755+
val authEnabled = !userBlank && !passBlank
756+
ensureSharingPortFree(socksPort, coreSocksPort)
757+
ensureSharingPortFree(httpPort, coreSocksPort)
750758

751759
sharingSocksJob = serviceScope.launch {
752760
try {
753-
val server = java.net.ServerSocket(socksPort, 50, InetAddress.getByName("0.0.0.0"))
754-
server.reuseAddress = true
761+
val server = java.net.ServerSocket().apply {
762+
reuseAddress = true
763+
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), socksPort), 50)
764+
}
755765
sharingSocksServer = server
756-
VpnManager.appendLog("Sharing SOCKS5 proxy ready on 0.0.0.0:$socksPort")
766+
VpnManager.appendLog(
767+
"Sharing SOCKS5 proxy ready on 0.0.0.0:$socksPort" +
768+
if (authEnabled) " (auth enabled)" else " (open, no auth)"
769+
)
757770
while (isActive) {
758-
val client = server.accept() ?: continue
771+
val client = server.accept()
772+
if (!isActive) { runCatching { client.close() }; break }
759773
launch(Dispatchers.IO) {
760-
handleSharingSocksClient(client)
774+
handleSharingSocksClient(client, coreSocksPort, username, password)
761775
}
762776
}
763777
} catch (e: Exception) {
778+
if (e is CancellationException) throw e
764779
Log.e(TAG, "Sharing SOCKS5 proxy error", e)
765780
VpnManager.appendLog("Sharing SOCKS5 proxy error: ${e.message}")
766781
}
767782
}
768783

769-
httpProxyJob?.cancel()
770-
sharingHttpServer?.close()
771-
sharingHttpServer = null
772-
773-
httpProxyJob = serviceScope.launch {
784+
sharingHttpJob = serviceScope.launch {
774785
try {
775-
val server = java.net.ServerSocket(httpPort, 50, InetAddress.getByName("0.0.0.0"))
776-
server.reuseAddress = true
786+
val server = java.net.ServerSocket().apply {
787+
reuseAddress = true
788+
bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), httpPort), 50)
789+
}
777790
sharingHttpServer = server
778-
VpnManager.appendLog("HTTP proxy ready on 0.0.0.0:$httpPort")
791+
VpnManager.appendLog(
792+
"HTTP proxy ready on 0.0.0.0:$httpPort" +
793+
if (authEnabled) " (auth enabled)" else " (open, no auth)"
794+
)
779795
while (isActive) {
780-
val client = server.accept() ?: continue
796+
val client = server.accept()
797+
if (!isActive) { runCatching { client.close() }; break }
781798
launch(Dispatchers.IO) {
782799
handleHttpProxyClient(client, socksPort, username, password)
783800
}
784801
}
785802
} catch (e: Exception) {
803+
if (e is CancellationException) throw e
786804
Log.e(TAG, "HTTP proxy error", e)
787805
VpnManager.appendLog("HTTP proxy error: ${e.message}")
788806
}
789807
}
790808
}
791809

792-
private suspend fun handleSharingSocksClient(client: java.net.Socket) {
810+
private fun stopSharingServers() {
811+
sharingSocksJob?.cancel()
812+
sharingHttpJob?.cancel()
813+
runCatching { sharingSocksServer?.close() }
814+
runCatching { sharingHttpServer?.close() }
815+
sharingSocksServer = null
816+
sharingHttpServer = null
817+
}
818+
819+
private suspend fun ensureSharingPortFree(port: Int, coreSocksPort: Int) {
820+
if (!isLocalPortInUse(port)) return
821+
VpnManager.appendLog("Sharing port $port is busy; freeing our own resources...")
822+
// Cancel our sharing jobs and close our servers; never touch the Go core.
823+
stopSharingServers()
824+
repeat(15) {
825+
delay(200L)
826+
if (!isLocalPortInUse(port)) {
827+
VpnManager.appendLog("Sharing port $port is now free")
828+
return
829+
}
830+
}
831+
if (port == coreSocksPort) {
832+
throw IllegalStateException(
833+
"Sharing port $port is the VPN's internal SOCKS5 port. Pick a different sharing port in Settings."
834+
)
835+
}
836+
throw IllegalStateException(
837+
"Sharing port $port is in use by another app. Change it in Settings."
838+
)
839+
}
840+
841+
private suspend fun handleSharingSocksClient(client: java.net.Socket, coreSocksPort: Int, username: String, password: String) {
793842
var upstream: java.net.Socket? = null
794843
try {
795-
upstream = java.net.Socket("127.0.0.1", activeLocalSocksPort)
844+
client.soTimeout = 15000
845+
val input = client.getInputStream()
846+
val output = client.getOutputStream()
847+
848+
val authRequired = username.isNotBlank() && password.isNotBlank()
849+
850+
// --- SOCKS5 greeting (RFC 1928) ---
851+
val header = ByteArray(2)
852+
readFully(input, header, 0, 2)
853+
if (header[0] != 0x05.toByte()) return
854+
val nMethods = header[1].toInt() and 0xFF
855+
if (nMethods == 0) return
856+
val methods = ByteArray(nMethods)
857+
readFully(input, methods, 0, nMethods)
858+
859+
if (authRequired) {
860+
if (!methods.any { it == 0x02.toByte() }) {
861+
output.write(byteArrayOf(0x05, 0xFF.toByte())); output.flush(); return
862+
}
863+
output.write(byteArrayOf(0x05, 0x02)); output.flush()
864+
// --- RFC 1929 user/pass sub-negotiation ---
865+
val subVersion = input.read()
866+
if (subVersion != 0x01) { output.write(byteArrayOf(0x01, 0x01)); output.flush(); return }
867+
val ulen = input.read()
868+
if (ulen < 0) return
869+
val ub = ByteArray(ulen)
870+
readFully(input, ub, 0, ulen)
871+
val plen = input.read()
872+
if (plen < 0) return
873+
val pb = ByteArray(plen)
874+
readFully(input, pb, 0, plen)
875+
val ok = constantTimeEquals(ub, username.toByteArray(Charsets.UTF_8)) &&
876+
constantTimeEquals(pb, password.toByteArray(Charsets.UTF_8))
877+
output.write(byteArrayOf(0x01, if (ok) 0x00 else 0x01))
878+
output.flush()
879+
if (!ok) return
880+
} else {
881+
output.write(byteArrayOf(0x05, 0x00)); output.flush()
882+
}
883+
884+
// --- SOCKS5 request ---
885+
val req = ByteArray(4)
886+
readFully(input, req, 0, 4)
887+
if (req[0] != 0x05.toByte()) return
888+
if (req[1] != 0x01.toByte()) {
889+
// 0x07 = command not supported
890+
output.write(byteArrayOf(0x05, 0x07, 0x00)); output.flush(); return
891+
}
892+
val host = when (req[3].toInt() and 0xFF) {
893+
0x01 -> { val b = ByteArray(4); readFully(input, b, 0, 4); b.joinToString(".") { (it.toInt() and 0xFF).toString() } }
894+
0x03 -> { val l = input.read(); if (l < 0) return; val b = ByteArray(l); readFully(input, b, 0, l); String(b, Charsets.UTF_8) }
895+
0x04 -> { val b = ByteArray(16); readFully(input, b, 0, 16); java.net.InetAddress.getByAddress(b).hostAddress ?: return }
896+
else -> { output.write(byteArrayOf(0x05, 0x08, 0x00)); output.flush(); return }
897+
}
898+
val portBytes = ByteArray(2); readFully(input, portBytes, 0, 2)
899+
val port = ((portBytes[0].toInt() and 0xFF) shl 8) or (portBytes[1].toInt() and 0xFF)
900+
901+
upstream = try { createSocks5Tunnel(coreSocksPort, host, port) } catch (e: Exception) {
902+
VpnManager.appendLog("Sharing SOCKS5 upstream to $host:$port failed: ${e.message}")
903+
output.write(byteArrayOf(0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0)); output.flush()
904+
return
905+
}
796906
upstream.soTimeout = 30000
907+
// 0x05 0x00 0x00 0x01 + 4-byte bind addr + 2-byte bind port
908+
output.write(byteArrayOf(0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0)); output.flush()
909+
797910
bridgeBidirectional(client, upstream)
798-
} catch (_: Exception) {
911+
} catch (e: Exception) {
912+
if (e is CancellationException) throw e
913+
VpnManager.appendLog("Sharing SOCKS5 client error: ${e.message}")
799914
} finally {
800915
runCatching { upstream?.close() }
801916
runCatching { client.close() }
802917
}
803918
}
804919

920+
private fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean {
921+
if (a.size != b.size) return false
922+
return java.security.MessageDigest.isEqual(a, b)
923+
}
924+
805925
private fun readLineUnbuffered(input: java.io.InputStream): String? {
806926
val bytes = ArrayList<Byte>()
807927
while (true) {
@@ -846,7 +966,7 @@ class GooseRelayVpnService : VpnService() {
846966
}
847967
}
848968

849-
val requiresAuth = username.isNotBlank() || password.isNotBlank()
969+
val requiresAuth = username.isNotBlank() && password.isNotBlank()
850970
if (requiresAuth && !isValidBasicProxyAuth(authHeader, username, password)) {
851971
output.write(
852972
"HTTP/1.1 407 Proxy Authentication Required\r\n" +
@@ -969,16 +1089,16 @@ class GooseRelayVpnService : VpnService() {
9691089
}
9701090

9711091
private fun isValidBasicProxyAuth(header: String?, username: String, password: String): Boolean {
1092+
// ponytail: both-or-neither enforced at the UI; service treats blank-blank as open.
9721093
if (username.isBlank() && password.isBlank()) return true
9731094
val value = header?.trim().orEmpty()
9741095
if (!value.startsWith("Basic ", ignoreCase = true)) return false
9751096
val encoded = value.substringAfter(" ", "").trim()
9761097
if (encoded.isBlank()) return false
9771098
val decoded = runCatching {
978-
val bytes = android.util.Base64.decode(encoded, android.util.Base64.DEFAULT)
979-
String(bytes, Charsets.UTF_8)
1099+
android.util.Base64.decode(encoded, android.util.Base64.DEFAULT)
9801100
}.getOrNull() ?: return false
981-
return decoded == "$username:$password"
1101+
return constantTimeEquals(decoded, "$username:$password".toByteArray(Charsets.UTF_8))
9821102
}
9831103

9841104
private fun readFully(input: java.io.InputStream, buffer: ByteArray, offset: Int, length: Int) {

android/app/src/main/java/com/gooserelay/gooserelayvpn/ui/settings/GlobalSettingsScreen.kt

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,16 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) {
140140
text = { Text("Export to Clipboard") },
141141
onClick = {
142142
menuExpanded = false
143-
val json = gson.toJson(draft)
143+
// ponytail: never export credentials. AGENTS.md forbids
144+
// leaking socksUser/socksPass/tunnelKey/scriptKeysText; the
145+
// sharing creds are in the same class — strip before serialize.
146+
val redacted = draft.copy(
147+
internetSharingUser = "",
148+
internetSharingPass = ""
149+
)
150+
val json = gson.toJson(redacted)
144151
clipboardManager.setText(AnnotatedString(json))
145-
scope.launch { snackbarHostState.showSnackbar("Settings copied to clipboard") }
152+
scope.launch { snackbarHostState.showSnackbar("Settings copied (credentials excluded)") }
146153
}
147154
)
148155
DropdownMenuItem(
@@ -396,10 +403,22 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) {
396403
)
397404
}
398405

406+
val userBlank = draft.internetSharingUser.isBlank()
407+
val passBlank = draft.internetSharingPass.isBlank()
408+
val mismatch = userBlank xor passBlank
409+
399410
OutlinedTextField(
400411
value = draft.internetSharingUser,
401412
onValueChange = { draft = draft.copy(internetSharingUser = it) },
402413
label = { Text(stringResource(R.string.global_username)) },
414+
singleLine = true,
415+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
416+
isError = mismatch && userBlank,
417+
supportingText = {
418+
if (mismatch && userBlank) {
419+
Text(stringResource(R.string.global_username_missing), color = MaterialTheme.colorScheme.error)
420+
}
421+
},
403422
modifier = Modifier.fillMaxWidth()
404423
)
405424

@@ -408,14 +427,30 @@ fun GlobalSettingsScreen(vm: GlobalSettingsViewModel = viewModel()) {
408427
onValueChange = { draft = draft.copy(internetSharingPass = it) },
409428
label = { Text(stringResource(R.string.global_password)) },
410429
visualTransformation = PasswordVisualTransformation(),
430+
singleLine = true,
431+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
432+
isError = mismatch && passBlank,
433+
supportingText = {
434+
if (mismatch && passBlank) {
435+
Text(stringResource(R.string.global_password_missing), color = MaterialTheme.colorScheme.error)
436+
}
437+
},
411438
modifier = Modifier.fillMaxWidth()
412439
)
413440

414-
Text(
415-
stringResource(R.string.global_sharing_help),
416-
style = MaterialTheme.typography.bodySmall,
417-
color = MdvColor.OnSurfaceVariant
418-
)
441+
if (mismatch) {
442+
Text(
443+
stringResource(R.string.global_auth_mismatch),
444+
style = MaterialTheme.typography.bodySmall,
445+
color = MaterialTheme.colorScheme.error
446+
)
447+
} else {
448+
Text(
449+
stringResource(R.string.global_sharing_help),
450+
style = MaterialTheme.typography.bodySmall,
451+
color = MdvColor.OnSurfaceVariant
452+
)
453+
}
419454
}
420455
}
421456
}
@@ -684,8 +719,12 @@ private fun parseCsv(value: String): Set<String> {
684719
}
685720

686721
private fun normalize(settings: GlobalSettings): GlobalSettings {
722+
fun clampPort(p: Int, default: Int): Int =
723+
if (p in 1025..65535) p else default
687724
return settings.copy(
688725
connectionMode = settings.connectionMode.uppercase(),
726+
internetSharingSocksPort = clampPort(settings.internetSharingSocksPort, 8090),
727+
internetSharingHttpPort = clampPort(settings.internetSharingHttpPort, 8091),
689728
splitPackagesCsv = settings.splitPackagesCsv
690729
.split(",")
691730
.map { it.trim() }

android/app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@
126126
<string name="global_username">Username</string>
127127
<string name="global_password">Password</string>
128128
<string name="global_sharing_help">Use these endpoints to share your VPN connection with other devices or apps on the same network.</string>
129+
<string name="global_auth_mismatch">Set both username and password, or leave both empty.</string>
130+
<string name="global_username_missing">Username missing (pair with password, or leave both empty).</string>
131+
<string name="global_password_missing">Password missing (pair with username, or leave both empty).</string>
129132
<string name="info_app_name_title">GooseRelayVPN</string>
130133
<string name="info_overview_subtitle">Project overview and build details</string>
131134
<string name="info_build_information">Build Information</string>

0 commit comments

Comments
 (0)