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

@@ -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,