feat: add secure per-server custom headers for Cloudflare Access (#1273)
This commit is contained in:
@@ -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 |
|
| **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 |
|
| **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
|
### 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.CleartextPolicy
|
||||||
import com.m57.hermescontrol.data.remote.CookieManager
|
import com.m57.hermescontrol.data.remote.CookieManager
|
||||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
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.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
@@ -215,6 +216,7 @@ object AuthManager {
|
|||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
)
|
)
|
||||||
migrateLegacyDefaultIfNeeded(p)
|
migrateLegacyDefaultIfNeeded(p)
|
||||||
|
ServerHeaders.initialize(p)
|
||||||
synchronized(this@AuthManager) {
|
synchronized(this@AuthManager) {
|
||||||
readyPrefs = p
|
readyPrefs = p
|
||||||
cachedToken = getTokenInternal(p)
|
cachedToken = getTokenInternal(p)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ object OkHttpProvider {
|
|||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
.cookieJar(resolveCookieJar())
|
.cookieJar(resolveCookieJar())
|
||||||
|
.addNetworkInterceptor(ServerHeadersInterceptor())
|
||||||
.connectionPool(connectionPool)
|
.connectionPool(connectionPool)
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
@@ -48,6 +49,9 @@ object OkHttpProvider {
|
|||||||
.cookieJar(resolveCookieJar())
|
.cookieJar(resolveCookieJar())
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
.pingInterval(30, TimeUnit.SECONDS)
|
.pingInterval(30, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor(ServerHeadersInterceptor())
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
.build()
|
.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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.ui.common.CustomHeadersButton
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthLoginScreen(
|
fun AuthLoginScreen(
|
||||||
@@ -162,7 +163,13 @@ fun AuthLoginScreen(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
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
|
// 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()
|
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 ──────────────────────────────────────────────
|
// ── Delete confirmation ──────────────────────────────────────────────
|
||||||
|
|
||||||
fun requestDeleteProfile(profileId: String) {
|
fun requestDeleteProfile(profileId: String) {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ internal fun ConnectionSection(
|
|||||||
onTokenChange = viewModel::onDialogProfileTokenChange,
|
onTokenChange = viewModel::onDialogProfileTokenChange,
|
||||||
onSave = viewModel::saveProfileFromDialog,
|
onSave = viewModel::saveProfileFromDialog,
|
||||||
onDismiss = viewModel::closeProfileDialog,
|
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.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.ui.common.CustomHeadersButton
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ProfileEditorDialog(
|
internal fun ProfileEditorDialog(
|
||||||
@@ -33,6 +34,7 @@ internal fun ProfileEditorDialog(
|
|||||||
onTokenChange: (String) -> Unit,
|
onTokenChange: (String) -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onHeadersSaved: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val title =
|
val title =
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -63,6 +65,7 @@ internal fun ProfileEditorDialog(
|
|||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
CustomHeadersButton(baseUrl = baseUrl, onSaved = onHeadersSaved)
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
Text(
|
Text(
|
||||||
text = error,
|
text = error,
|
||||||
|
|||||||
@@ -1763,4 +1763,16 @@
|
|||||||
<string name="kanban_attachment_error">Attachment error: %1$s</string>
|
<string name="kanban_attachment_error">Attachment error: %1$s</string>
|
||||||
<string name="kanban_active_workers">Active workers</string>
|
<string name="kanban_active_workers">Active workers</string>
|
||||||
<string name="kanban_no_active_workers">No 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>
|
</resources>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class AuthManagerTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
mockPrefs = mockk(relaxed = true)
|
mockPrefs = mockk(relaxed = true)
|
||||||
|
every { mockPrefs.getString("server_custom_headers", null) } returns null
|
||||||
mockEditor = mockk(relaxed = true)
|
mockEditor = mockk(relaxed = true)
|
||||||
mockContext = mockk(relaxed = true)
|
mockContext = mockk(relaxed = true)
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Issue647ProfileUrlTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
mockPrefs = mockk(relaxed = true)
|
mockPrefs = mockk(relaxed = true)
|
||||||
|
every { mockPrefs.getString("server_custom_headers", null) } returns null
|
||||||
mockEditor = mockk(relaxed = true)
|
mockEditor = mockk(relaxed = true)
|
||||||
mockContext = 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user