From c21e3e9ed037b267f5b2edc158180ee13259a6d5 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:47:50 +0300 Subject: [PATCH] fix(#259): validate host in pairing string to prevent SSRF (#344) 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. --- .../ui/connect/ConnectViewModel.kt | 68 ++++++++++++++----- app/src/main/res/values/strings.xml | 1 + .../ui/connect/ConnectViewModelTest.kt | 4 +- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index 055ed8e6..171214ae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -39,6 +39,22 @@ class ConnectViewModel(private val app: Application) : ViewModel() { 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() { val savedToken = AuthManager.getToken() ?: "" val savedHost = AuthManager.getHost() @@ -267,15 +283,25 @@ class ConnectViewModel(private val app: Application) : ViewModel() { val port = uri.getQueryParameter("port") val token = uri.getQueryParameter("token") if (host != null && port != null && token != null) { - _uiState.update { - it.copy( - host = host, - port = port, - token = token, - errorMessage = null, - ) + viewModelScope.launch { + if (!isHostAllowed(host)) { + _uiState.update { + it.copy( + errorMessage = app.getString(R.string.connect_error_invalid_host), + ) + } + return@launch + } + _uiState.update { + it.copy( + host = host, + port = port, + token = token, + errorMessage = null, + ) + } + connect() } - connect() return } } @@ -293,15 +319,25 @@ class ConnectViewModel(private val app: Application) : ViewModel() { val port = json.get("port")?.asInt?.toString() ?: json.get("port")?.asString val token = json.get("token")?.asString if (host != null && port != null && token != null) { - _uiState.update { - it.copy( - host = host, - port = port, - token = token, - errorMessage = null, - ) + viewModelScope.launch { + if (!isHostAllowed(host)) { + _uiState.update { + it.copy( + errorMessage = app.getString(R.string.connect_error_invalid_host), + ) + } + return@launch + } + _uiState.update { + it.copy( + host = host, + port = port, + token = token, + errorMessage = null, + ) + } + connect() } - connect() return } // Decoded valid JSON but missing required fields diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 726baa29..9b24cfbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -178,6 +178,7 @@ Connection refused – is Hermes running? Could not resolve host Connection failed: %1$s + Invalid or forbidden host in pairing string Malformed pairing string — missing host, port, or token Malformed pairing string — expected URL or Base64-encoded JSON Failed to parse pairing string: %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt index 8883e87c..7061d915 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectViewModelTest.kt @@ -612,12 +612,14 @@ class ConnectViewModelTest { val viewModel = ConnectViewModel(mockApp) 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 assertEquals("192.168.1.1", state.host) assertEquals("8888", state.port) assertEquals("abc123", state.token) // Should have triggered connect - advanceUntilIdle() assertTrue("connection should succeed after pairing", viewModel.uiState.value.connectionSuccess) } }