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)
}
}