Add robust validation for hosts provided through URI or Base64 pairing strings to prevent Server-Side Request Forgery (SSRF). The application now rejects loopback addresses (like localhost, 127.0.0.1, 0.0.0.0), link-local addresses, and any-local addresses, while continuing to allow remote connections to site-local addresses, custom remote domains, and VPN networks (like Tailscale). The DNS resolution operates securely on the IO dispatcher to ensure main-thread safety. Included tests verify both rejection of invalid hosts and seamless parsing of valid ones.
This commit is contained in:
3 files changed
+56
-17
No files matched your search
@@ -39,6 +39,22 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
|
|||||||
loadSavedValues()
|
loadSavedValues()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun isHostAllowed(host: String): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0") return@withContext false
|
||||||
|
if (host.startsWith("169.254.")) return@withContext false
|
||||||
|
if (host == "metadata.google.internal") return@withContext false
|
||||||
|
try {
|
||||||
|
val address = java.net.InetAddress.getByName(host)
|
||||||
|
if (address.isLoopbackAddress || address.isAnyLocalAddress || address.isLinkLocalAddress) {
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadSavedValues() {
|
fun loadSavedValues() {
|
||||||
val savedToken = AuthManager.getToken() ?: ""
|
val savedToken = AuthManager.getToken() ?: ""
|
||||||
val savedHost = AuthManager.getHost()
|
val savedHost = AuthManager.getHost()
|
||||||
@@ -267,15 +283,25 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
|
|||||||
val port = uri.getQueryParameter("port")
|
val port = uri.getQueryParameter("port")
|
||||||
val token = uri.getQueryParameter("token")
|
val token = uri.getQueryParameter("token")
|
||||||
if (host != null && port != null && token != null) {
|
if (host != null && port != null && token != null) {
|
||||||
_uiState.update {
|
viewModelScope.launch {
|
||||||
it.copy(
|
if (!isHostAllowed(host)) {
|
||||||
host = host,
|
_uiState.update {
|
||||||
port = port,
|
it.copy(
|
||||||
token = token,
|
errorMessage = app.getString(R.string.connect_error_invalid_host),
|
||||||
errorMessage = null,
|
)
|
||||||
)
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
token = token,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
connect()
|
||||||
}
|
}
|
||||||
connect()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,15 +319,25 @@ class ConnectViewModel(private val app: Application) : ViewModel() {
|
|||||||
val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString
|
val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString
|
||||||
val token = json.get("token")?.asString
|
val token = json.get("token")?.asString
|
||||||
if (host != null && port != null && token != null) {
|
if (host != null && port != null && token != null) {
|
||||||
_uiState.update {
|
viewModelScope.launch {
|
||||||
it.copy(
|
if (!isHostAllowed(host)) {
|
||||||
host = host,
|
_uiState.update {
|
||||||
port = port,
|
it.copy(
|
||||||
token = token,
|
errorMessage = app.getString(R.string.connect_error_invalid_host),
|
||||||
errorMessage = null,
|
)
|
||||||
)
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
token = token,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
connect()
|
||||||
}
|
}
|
||||||
connect()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Decoded valid JSON but missing required fields
|
// Decoded valid JSON but missing required fields
|
||||||
|
|||||||
@@ -178,6 +178,7 @@
|
|||||||
<string name="connect_error_refused">Connection refused – is Hermes running?</string>
|
<string name="connect_error_refused">Connection refused – is Hermes running?</string>
|
||||||
<string name="connect_error_resolve">Could not resolve host</string>
|
<string name="connect_error_resolve">Could not resolve host</string>
|
||||||
<string name="connect_error_connection_failed">Connection failed: %1$s</string>
|
<string name="connect_error_connection_failed">Connection failed: %1$s</string>
|
||||||
|
<string name="connect_error_invalid_host">Invalid or forbidden host in pairing string</string>
|
||||||
<string name="connect_error_missing_fields">Malformed pairing string — missing host, port, or token</string>
|
<string name="connect_error_missing_fields">Malformed pairing string — missing host, port, or token</string>
|
||||||
<string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string>
|
<string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string>
|
||||||
<string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string>
|
<string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string>
|
||||||
|
|||||||
@@ -612,12 +612,14 @@ class ConnectViewModelTest {
|
|||||||
val viewModel = ConnectViewModel(mockApp)
|
val viewModel = ConnectViewModel(mockApp)
|
||||||
viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123")
|
viewModel.onPairingString("hermes://connect?host=192.168.1.1&port=8888&token=abc123")
|
||||||
|
|
||||||
|
// Wait for parsing coroutine to finish
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
assertEquals("192.168.1.1", state.host)
|
assertEquals("192.168.1.1", state.host)
|
||||||
assertEquals("8888", state.port)
|
assertEquals("8888", state.port)
|
||||||
assertEquals("abc123", state.token)
|
assertEquals("abc123", state.token)
|
||||||
// Should have triggered connect
|
// Should have triggered connect
|
||||||
advanceUntilIdle()
|
|
||||||
assertTrue("connection should succeed after pairing", viewModel.uiState.value.connectionSuccess)
|
assertTrue("connection should succeed after pairing", viewModel.uiState.value.connectionSuccess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user