feat: add secure per-server custom headers for Cloudflare Access (#1273)

This commit is contained in:
ThomasCrund authored and GitHub committed 2026-09-26 17:17:08 +03:00
1 parent 1ba1a62f31
commit 0bc0117c89
13 files changed
+693 -2

No files matched your search

+23 -1
View File
@@ -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
@@ -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)
@@ -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()
}
@@ -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<String, String>,
) {
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<Pair<String, String>>): CustomHeaders {
val result = linkedMapOf<String, String>()
val names = mutableSetOf<String>()
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<ScopedServerHeaders> = 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<Map<String, Map<String, String>>>(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())
}
}
@@ -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
@@ -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<Int?>(null) }
var deleting by remember { mutableStateOf<Int?>(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)) }
},
)
}
}
@@ -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) {
@@ -175,6 +175,7 @@ internal fun ConnectionSection(
onTokenChange = viewModel::onDialogProfileTokenChange,
onSave = viewModel::saveProfileFromDialog,
onDismiss = viewModel::closeProfileDialog,
onHeadersSaved = viewModel::onCustomHeadersSaved,
)
}
@@ -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,
+12
View File
@@ -1763,4 +1763,16 @@
<string name="kanban_attachment_error">Attachment error: %1$s</string>
<string name="kanban_active_workers">Active workers</string>
<string name="kanban_no_active_workers">No active workers</string>
<string name="custom_headers_title">Custom headers</string>
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required.</string>
<string name="custom_headers_name">Header name</string>
<string name="custom_headers_value">Header value</string>
<string name="custom_headers_add">Add header</string>
<string name="custom_headers_cloudflare">Add Cloudflare headers</string>
<string name="custom_headers_remove">Remove header</string>
<string name="custom_headers_remove_confirm">Remove this header? Save your changes to apply the removal.</string>
<string name="custom_headers_show">Show header values</string>
<string name="custom_headers_hide">Hide header values</string>
<string name="custom_headers_invalid">Use unique HTTP header names and single-line ASCII values. Authorization, Cookie, Host, and connection headers are managed by the app.</string>
<string name="custom_headers_save_failed">Could not save custom headers. Try again.</string>
</resources>
@@ -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)
@@ -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)
@@ -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<SharedPreferences>()
private val editor = mockk<SharedPreferences.Editor>()
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"))
}
}