From 0bc0117c89da10ed5ec1b017d95ccc40f7ef1146 Mon Sep 17 00:00:00 2001 From: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:17:08 +1000 Subject: [PATCH] feat: add secure per-server custom headers for Cloudflare Access (#1273) --- README.md | 24 +- .../hermescontrol/data/local/AuthManager.kt | 2 + .../data/remote/OkHttpProvider.kt | 4 + .../data/remote/ServerHeaders.kt | 136 ++++++++ .../ui/authlogin/AuthLoginScreen.kt | 9 +- .../ui/common/CustomHeadersButton.kt | 196 ++++++++++++ .../ui/settings/SettingsViewModel.kt | 9 + .../settings/components/ConnectionSection.kt | 1 + .../components/ProfileEditorDialog.kt | 3 + app/src/main/res/values/strings.xml | 12 + .../data/local/AuthManagerTest.kt | 1 + .../data/local/Issue647ProfileUrlTest.kt | 1 + .../data/remote/ServerHeadersTest.kt | 297 ++++++++++++++++++ 13 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt diff --git a/README.md b/README.md index 8fd98e94..76b4aefe 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,29 @@ Tap **Sign in** on the landing screen and enter the dashboard host and port. The | **Token only** | Dashboard on same machine (loopback) | **Token** — grab from `~/.hermes/dashboard-token.txt` or `~/.hermes/.env` (`HERMES_DASHBOARD_SESSION_TOKEN`). The app can also auto-extract it from the dashboard page | | **Basic auth** | Dashboard on LAN with password gate | **Username** + **Password** (default `admin` / `hermes`). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically | -> The app communicates over plain HTTP — it's designed for **trusted local networks only**. Do not expose your Hermes gateway to untrusted networks. +Use HTTPS for remote connections. Use HTTP only on a trusted local network. + +### Cloudflare Access and custom headers + +1. Enter your dashboard's HTTPS URL on the login screen. +2. Tap **Custom headers**, then **Add Cloudflare headers**. +3. Enter your service token's `CF-Access-Client-Id` and `CF-Access-Client-Secret` values. +4. Tap **Save**. The app probes the dashboard again, then shows the Hermes login fields. + +Your [Cloudflare Access policy](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/) +must accept the service token. These headers authenticate with Cloudflare; you still +need to complete Hermes authentication. + +You can add other custom header names and values in the same editor. To edit or +remove saved headers, open **Custom headers** from login or the saved connection's +edit dialog in Settings. Values are masked and stored in encrypted preferences. +Saving an empty list removes the headers for that URL. + +Headers are shared by profiles with the same server URL. They apply to probes, +login, API and media requests, and WebSocket handshakes. Each URL's scheme, host, +port, and path prefix limit where its headers are sent. Redirects outside that +scope do not receive the credentials; WebSocket redirects are not followed. +The app manages `Authorization`, `Cookie`, and transport headers itself. ### Connection profiles diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index d8d0fc33..464b130c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CookieManager import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.ServerHeaders import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset @@ -215,6 +216,7 @@ object AuthManager { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) migrateLegacyDefaultIfNeeded(p) + ServerHeaders.initialize(p) synchronized(this@AuthManager) { readyPrefs = p cachedToken = getTokenInternal(p) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt index bda2d5aa..99a004eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt @@ -33,6 +33,7 @@ object OkHttpProvider { OkHttpClient .Builder() .cookieJar(resolveCookieJar()) + .addNetworkInterceptor(ServerHeadersInterceptor()) .connectionPool(connectionPool) .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) @@ -48,6 +49,9 @@ object OkHttpProvider { .cookieJar(resolveCookieJar()) .readTimeout(0, TimeUnit.MILLISECONDS) .pingInterval(30, TimeUnit.SECONDS) + .addInterceptor(ServerHeadersInterceptor()) + .followRedirects(false) + .followSslRedirects(false) .build() } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt new file mode 100644 index 00000000..b0ead8c2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerHeaders.kt @@ -0,0 +1,136 @@ +package com.m57.hermescontrol.data.remote + +import android.content.SharedPreferences +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException +import java.util.Locale + +/** Validated header values. Never include credentials in diagnostics or toString(). */ +class CustomHeaders private constructor( + val entries: Map, +) { + override fun toString(): String = "CustomHeaders(${entries.size} entries)" + + companion object { + val EMPTY = CustomHeaders(emptyMap()) + private val namePattern = Regex("[!#$%&'*+.^_`|~0-9A-Za-z-]+") + private val reserved = + setOf( + "authorization", + "proxy-authorization", + "cookie", + "host", + "connection", + "content-length", + "transfer-encoding", + "upgrade", + "te", + "trailer", + ) + + fun parse(rows: List>): CustomHeaders { + val result = linkedMapOf() + val names = mutableSetOf() + rows.filterNot { it.first.isBlank() && it.second.isEmpty() }.forEach { (rawName, value) -> + val name = rawName.trim() + val normalized = name.lowercase(Locale.ROOT) + // Do not expose OkHttp's validation exceptions: they can contain secret values. + require(namePattern.matches(name)) { "Invalid header name" } + require(normalized !in reserved && !normalized.startsWith("sec-websocket-")) { + "Header is managed by the app" + } + require(names.add(normalized)) { "Duplicate header name" } + require(value.all { it == '\t' || it in ' '..'~' }) { "Invalid header value" } + result[name] = value + } + return CustomHeaders(result.toMap()) + } + } +} + +/** Headers belong to a canonical server URL, including its reverse-proxy path. */ +class ScopedServerHeaders( + val baseUrl: HttpUrl, + val headers: CustomHeaders, +) { + fun contains(url: HttpUrl): Boolean = + url.scheme == baseUrl.scheme && url.host == baseUrl.host && url.port == baseUrl.port && + ( + url.encodedPath.startsWith(baseUrl.encodedPath) || + url.encodedPath == baseUrl.encodedPath.removeSuffix("/") + ) +} + +/** Uses AuthManager's encrypted preferences, never the plaintext server_store.json. */ +object ServerHeaders { + private const val KEY = "server_custom_headers" + + @Volatile + private var configured: List = emptyList() + private var preferences: SharedPreferences? = null + + @Synchronized + fun initialize(encryptedPreferences: SharedPreferences) { + val stored = encryptedPreferences.getString(KEY, null) + configured = + if (stored == null) { + emptyList() + } else { + val decoded = + try { + Json.decodeFromString>>(stored) + } catch (_: SerializationException) { + // JSON parser exceptions include the input, which contains credentials. + throw IOException("Could not read custom headers") + } + decoded.map { (url, values) -> + ScopedServerHeaders(ServerEndpoint.parseForBuild(url).baseUrl, CustomHeaders.parse(values.toList())) + } + } + preferences = encryptedPreferences + } + + fun get(baseUrl: HttpUrl): CustomHeaders = + configured.firstOrNull { it.baseUrl == baseUrl }?.headers ?: CustomHeaders.EMPTY + + fun forRequest(url: HttpUrl): ScopedServerHeaders? = + configured.filter { it.contains(url) }.maxByOrNull { it.baseUrl.encodedPath.length } + + /** Call on IO. Publish only after the encrypted write succeeds. */ + @Synchronized + fun save( + baseUrl: HttpUrl, + headers: CustomHeaders, + ) { + val prefs = checkNotNull(preferences) { "Server headers are not initialized" } + val updated = configured.filterNot { it.baseUrl == baseUrl } + ScopedServerHeaders(baseUrl, headers) + val serialized = Json.encodeToString(updated.associate { it.baseUrl.toString() to it.headers.entries }) + if (!prefs.edit().putString(KEY, serialized).commit()) throw IOException("Could not save custom headers") + configured = updated + } +} + +/** + * HTTP uses this as a network interceptor so redirects are checked before every exchange. + * OkHttp skips network interceptors for WebSockets, which use it as an application interceptor + * with redirects disabled. Credentials never follow a request outside its original server scope. + */ +class ServerHeadersInterceptor( + private val resolve: (HttpUrl) -> ScopedServerHeaders? = ServerHeaders::forRequest, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val scope = resolve(chain.call().request().url) ?: return chain.proceed(request) + val builder = request.newBuilder() + val withinScope = scope.contains(request.url) && resolve(request.url)?.baseUrl == scope.baseUrl + scope.headers.entries.forEach { (name, value) -> + if (withinScope) builder.header(name, value) else builder.removeHeader(name) + } + return chain.proceed(builder.build()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt index d4c119f4..18c06a53 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/authlogin/AuthLoginScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.CustomHeadersButton @Composable fun AuthLoginScreen( @@ -162,7 +163,13 @@ fun AuthLoginScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - enabled = state.authMode == null, + enabled = state.authMode == null && !state.probing && !state.isLoading, + ) + + CustomHeadersButton( + baseUrl = state.baseUrl, + enabled = !state.probing && !state.isLoading, + onSaved = viewModel::probe, ) // Cleartext transport warning diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt new file mode 100644 index 00000000..aabcf604 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/CustomHeadersButton.kt @@ -0,0 +1,196 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.remote.CustomHeaders +import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.ServerHeaders +import com.m57.hermescontrol.theme.Spacing +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import java.io.IOException + +@Composable +fun CustomHeadersButton( + baseUrl: String, + enabled: Boolean = true, + onSaved: () -> Unit, +) { + val endpoint = remember(baseUrl) { runCatching { ServerEndpoint.parseForBuild(baseUrl) }.getOrNull() } + var editing by remember(baseUrl) { mutableStateOf(false) } + OutlinedButton( + onClick = { editing = true }, + enabled = enabled && endpoint != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.custom_headers_title)) + } + if (editing && endpoint != null) { + CustomHeadersDialog( + baseUrl = endpoint.baseUrl, + onDismiss = { editing = false }, + onSaved = { + editing = false + onSaved() + }, + ) + } +} + +@Composable +private fun CustomHeadersDialog( + baseUrl: HttpUrl, + onDismiss: () -> Unit, + onSaved: () -> Unit, +) { + var rows by remember { mutableStateOf(ServerHeaders.get(baseUrl).entries.toList()) } + var visible by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = { if (!saving) onDismiss() }, + title = { Text(stringResource(R.string.custom_headers_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Text(baseUrl.toString(), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.custom_headers_description)) + rows.forEachIndexed { index, (name, value) -> + OutlinedTextField( + value = name, + onValueChange = { new -> + rows = rows.mapIndexed { i, row -> if (i == index) new to row.second else row } + error = null + }, + label = { Text(stringResource(R.string.custom_headers_name)) }, + singleLine = true, + enabled = !saving, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = value, + onValueChange = { new -> + rows = rows.mapIndexed { i, row -> if (i == index) row.first to new else row } + error = null + }, + label = { Text(stringResource(R.string.custom_headers_value)) }, + singleLine = true, + enabled = !saving, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = + if (visible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { visible = !visible }) { + Icon( + imageVector = + if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = + stringResource( + if (visible) R.string.custom_headers_hide else R.string.custom_headers_show, + ), + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + TextButton(onClick = { deleting = index }, enabled = !saving) { + Text(stringResource(R.string.custom_headers_remove)) + } + } + OutlinedButton(onClick = { rows = rows + ("" to "") }, enabled = !saving) { + Text(stringResource(R.string.custom_headers_add)) + } + if (rows.isEmpty()) { + TextButton( + onClick = { + rows = listOf("CF-Access-Client-Id" to "", "CF-Access-Client-Secret" to "") + }, + enabled = !saving, + ) { + Text(stringResource(R.string.custom_headers_cloudflare)) + } + } + error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + Button( + enabled = !saving, + onClick = { + val headers = runCatching { CustomHeaders.parse(rows) }.getOrNull() + if (headers == null) { + error = R.string.custom_headers_invalid + } else { + saving = true + coroutineScope.launch { + try { + withContext(Dispatchers.IO) { ServerHeaders.save(baseUrl, headers) } + onSaved() + } catch (_: IOException) { + error = R.string.custom_headers_save_failed + } finally { + saving = false + } + } + } + }, + ) { Text(stringResource(R.string.action_save)) } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !saving) { Text(stringResource(R.string.action_cancel)) } + }, + ) + deleting?.let { index -> + AlertDialog( + onDismissRequest = { deleting = null }, + title = { Text(stringResource(R.string.custom_headers_remove)) }, + text = { Text(stringResource(R.string.custom_headers_remove_confirm)) }, + confirmButton = { + TextButton(onClick = { + rows = rows.filterIndexed { i, _ -> i != index } + deleting = null + error = null + }) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { + TextButton(onClick = { deleting = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 411f45bb..e2ebd227 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -265,6 +265,15 @@ class SettingsViewModel( ApiClient.rebuild() } + fun onCustomHeadersSaved() { + val editedEndpoint = ServerEndpoint.parseForBuild(_uiState.value.dialogProfileBaseUrl) + if (editedEndpoint.baseUrl == AuthManager.endpointForBuild().baseUrl) { + ApiClient.rebuild() + HermesWsClient.disconnect() + HermesWsClient.connect() + } + } + // ── Delete confirmation ────────────────────────────────────────────── fun requestDeleteProfile(profileId: String) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt index e1e86ed9..9200a2e7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ConnectionSection.kt @@ -175,6 +175,7 @@ internal fun ConnectionSection( onTokenChange = viewModel::onDialogProfileTokenChange, onSave = viewModel::saveProfileFromDialog, onDismiss = viewModel::closeProfileDialog, + onHeadersSaved = viewModel::onCustomHeadersSaved, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt index 64137ab9..ab0207a1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/ProfileEditorDialog.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.CustomHeadersButton @Composable internal fun ProfileEditorDialog( @@ -33,6 +34,7 @@ internal fun ProfileEditorDialog( onTokenChange: (String) -> Unit, onSave: () -> Unit, onDismiss: () -> Unit, + onHeadersSaved: () -> Unit, ) { val title = if (isEditing) { @@ -63,6 +65,7 @@ internal fun ProfileEditorDialog( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), ) + CustomHeadersButton(baseUrl = baseUrl, onSaved = onHeadersSaved) if (error != null) { Text( text = error, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fcd2512..0d0ff6d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1763,4 +1763,16 @@ Attachment error: %1$s Active workers No active workers + Custom headers + Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required. + Header name + Header value + Add header + Add Cloudflare headers + Remove header + Remove this header? Save your changes to apply the removal. + Show header values + Hide header values + Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app. + Could not save custom headers. Try again. diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt index 05c58e06..71449d7e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/AuthManagerTest.kt @@ -35,6 +35,7 @@ class AuthManagerTest { @Before fun setUp() { mockPrefs = mockk(relaxed = true) + every { mockPrefs.getString("server_custom_headers", null) } returns null mockEditor = mockk(relaxed = true) mockContext = mockk(relaxed = true) diff --git a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt index cf8fb9bc..afc5108a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/local/Issue647ProfileUrlTest.kt @@ -46,6 +46,7 @@ class Issue647ProfileUrlTest { @Before fun setUp() { mockPrefs = mockk(relaxed = true) + every { mockPrefs.getString("server_custom_headers", null) } returns null mockEditor = mockk(relaxed = true) mockContext = mockk(relaxed = true) diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt new file mode 100644 index 00000000..be8f1367 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerHeadersTest.kt @@ -0,0 +1,297 @@ +package com.m57.hermescontrol.data.remote + +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import okhttp3.CookieJar +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class ServerHeadersTest { + private val preferences = mockk() + private val editor = mockk() + private var saved: String? = null + private lateinit var server: MockWebServer + private val headers = + CustomHeaders.parse( + listOf( + "CF-Access-Client-Id" to "test-id", + "CF-Access-Client-Secret" to "secret", + ), + ) + + @Before + fun setUp() { + every { preferences.getString(any(), null) } answers { saved } + every { preferences.edit() } returns editor + every { editor.putString(any(), any()) } answers { + saved = secondArg() + editor + } + every { editor.commit() } returns true + ServerHeaders.initialize(preferences) + CookieManager.setJarForTest(buildFakePersistentCookieJar()) + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + saved = null + ServerHeaders.initialize(preferences) + server.shutdown() + } + + @Test + fun `protected probe login ticket API and media requests require both headers`() { + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + if (request.getHeader("CF-Access-Client-Id") == "test-id" && + request.getHeader("CF-Access-Client-Secret") == "secret" + ) { + MockResponse().setBody("{}") + } else { + MockResponse().setResponseCode(403) + } + } + val url = server.url("/hermes/") + val probe = Request.Builder().url(server.url("/hermes/api/status")).build() + OkHttpProvider.probe + .newCall(probe) + .execute() + .use { assertEquals(403, it.code) } + ServerHeaders.save(url, headers) + val paths = listOf("api/status", "auth/password-login", "api/auth/ws-ticket", "api/sessions", "files/image.png") + paths.forEach { path -> + val builder = Request.Builder().url(server.url("/hermes/$path")) + if (path.contains("login") || path.contains("ticket")) builder.post("{}".toRequestBody()) + listOf(OkHttpProvider.probe, OkHttpProvider.base).forEach { client -> + client.newCall(builder.build()).execute().use { assertEquals(200, it.code) } + } + } + ServerHeaders.save(url, CustomHeaders.EMPTY) + OkHttpProvider.probe + .newCall(probe) + .execute() + .use { assertEquals(403, it.code) } + } + + @Test + fun `custom headers coexist with Hermes bearer and cookie authentication`() { + ServerHeaders.save(server.url("/"), headers) + server.enqueue(MockResponse()) + val request = + Request + .Builder() + .url(server.url("/api/status")) + .header("Authorization", "Bearer hermes-token") + .header("Cookie", "hermes_session_at=session") + .build() + OkHttpProvider.base + .newBuilder() + .cookieJar(CookieJar.NO_COOKIES) + .build() + .newCall(request) + .execute() + .close() + val recorded = server.takeRequest() + assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id")) + assertEquals("Bearer hermes-token", recorded.getHeader("Authorization")) + assertEquals("hermes_session_at=session", recorded.getHeader("Cookie")) + } + + @Test + fun `WebSocket upgrade carries custom headers and receives a message`() { + ServerHeaders.save(server.url("/hermes/"), headers) + val message = CountDownLatch(1) + server.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + webSocket.send("connected") + } + }, + ), + ) + val socket = + OkHttpProvider.websocket.newWebSocket( + Request.Builder().url(server.url("/hermes/api/ws?ticket=test")).build(), + object : WebSocketListener() { + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + if (text == "connected") message.countDown() + } + }, + ) + try { + assertTrue(message.await(5, TimeUnit.SECONDS)) + val recorded = server.takeRequest() + assertEquals("test-id", recorded.getHeader("CF-Access-Client-Id")) + assertEquals("secret", recorded.getHeader("CF-Access-Client-Secret")) + assertEquals("websocket", recorded.getHeader("Upgrade")) + } finally { + socket.cancel() + } + } + + @Test + fun `HTTP redirect to another configured server sends neither servers credentials`() { + MockWebServer().use { other -> + other.start() + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(other.url("/"), CustomHeaders.parse(listOf("X-Other-Secret" to "other-secret"))) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/status"))) + other.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/api/status")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + val redirected = other.takeRequest() + assertNull(redirected.getHeader("CF-Access-Client-Secret")) + assertNull(redirected.getHeader("CF-Access-Client-Id")) + assertNull(redirected.getHeader("X-Other-Secret")) + } + } + + @Test + fun `same-server redirect preserves headers but escaping path prefix removes them`() { + ServerHeaders.save(server.url("/hermes/"), headers) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/hermes/next"))) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/outside"))) + server.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/hermes/start")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret")) + } + + @Test + fun `redirect into a separately configured path does not inherit parent credentials`() { + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", server.url("/public/api/status"))) + server.enqueue(MockResponse()) + OkHttpProvider.base + .newCall(Request.Builder().url(server.url("/start")).build()) + .execute() + .close() + assertEquals("secret", server.takeRequest().getHeader("CF-Access-Client-Secret")) + assertNull(server.takeRequest().getHeader("CF-Access-Client-Secret")) + } + + @Test + fun `WebSocket redirect is not followed`() { + MockWebServer().use { other -> + other.start() + ServerHeaders.save(server.url("/"), headers) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", other.url("/api/ws"))) + val failed = CountDownLatch(1) + val socket = + OkHttpProvider.websocket.newWebSocket( + Request.Builder().url(server.url("/api/ws")).build(), + object : WebSocketListener() { + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + if (response?.code == 302) failed.countDown() + } + }, + ) + try { + assertTrue(failed.await(5, TimeUnit.SECONDS)) + assertEquals(0, other.requestCount) + } finally { + socket.cancel() + } + } + } + + @Test + fun `scope matches scheme host port and complete path segment`() { + val scope = ScopedServerHeaders("https://hermes.example/app/".toHttpUrl(), headers) + assertTrue(scope.contains("https://hermes.example/app/api/status".toHttpUrl())) + assertTrue(scope.contains("https://hermes.example/app".toHttpUrl())) + listOf( + "http://hermes.example/app/api/status", + "https://other.example/app/api/status", + "https://hermes.example:8443/app/api/status", + "https://hermes.example/application/api/status", + ).forEach { assertFalse(scope.contains(it.toHttpUrl())) } + } + + @Test + fun `saved credentials survive reload and an empty child scope blocks parent credentials`() { + ServerHeaders.save(server.url("/"), headers) + ServerHeaders.save(server.url("/public/"), CustomHeaders.EMPTY) + ServerHeaders.initialize(preferences) + assertEquals("secret", ServerHeaders.get(server.url("/")).entries["CF-Access-Client-Secret"]) + assertTrue( + ServerHeaders + .forRequest(server.url("/public/api/status"))!! + .headers.entries + .isEmpty(), + ) + } + + @Test + fun `failed storage write does not publish new credentials`() { + every { editor.commit() } returns false + assertThrows(IOException::class.java) { ServerHeaders.save(server.url("/"), headers) } + assertTrue(ServerHeaders.get(server.url("/")).entries.isEmpty()) + } + + @Test + fun `malformed stored JSON does not expose credentials in errors`() { + saved = "{\"secret\": broken" + val error = assertThrows(IOException::class.java) { ServerHeaders.initialize(preferences) } + assertFalse(error.stackTraceToString().contains("secret")) + } + + @Test + fun `reject invalid duplicate and managed headers without revealing values`() { + listOf( + listOf("Bad Name" to "secret"), + listOf("X-Test" to "secret\r\nInjected: bad"), + listOf("X-Test" to "secret", "x-test" to "secret"), + listOf("Authorization" to "secret"), + listOf("Cookie" to "secret"), + listOf("Host" to "secret"), + listOf("Sec-WebSocket-Key" to "secret"), + ).forEach { rows -> + val error = assertThrows(IllegalArgumentException::class.java) { CustomHeaders.parse(rows) } + assertFalse(error.message.orEmpty().contains("secret")) + } + assertFalse(headers.toString().contains("secret")) + } +}