fix(auth): support __Host-/__Secure- prefixed session cookies and proxy-relative paths (issue #635) (#648)

- CookieSerialization: add SESSION_COOKIE_NAMES (__Host-, __Secure-, bare) and
  isSessionCookieName(); isSessionCookie now matches any variant.
- PersistentCookieJar.getSessionCookieValue(): iterate SESSION_COOKIE_NAMES,
  returning the strictest prefixed match when present.
- HermesWsClient.refreshWsTicketIfNeeded(): drop the bare-name session-cookie
  preflight in gated mode — the server may hold a valid refresh cookie under a
  prefixed name; the authenticated ticket endpoint is the authority. Ticket URL
  already derived from endpoint (issue #634).
- HermesWsClient: stop logging WS close/failure reason strings and exception
  messages (class name only) to avoid leaking server context / URLs.
- ProfileScopeInterceptor: match profile-scoped paths via endpoint-relative
  request path so reverse-proxy prefixes (e.g. /hermes/api/...) are honored;
  wrap endpoint read in runCatching.

Verified: ktlintCheck, testDebugUnitTest, lintDebug all green locally.

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-20 20:56:39 +03:00
1 parent 86c780c10b
commit c8a5055a84
7 files changed
+225 -15

No files matched your search

@@ -86,8 +86,25 @@ fun sessionCookieFromValue(value: String): Cookie? = wrapSessionCookie(value)
const val SESSION_COOKIE_NAME = "hermes_session_at"
/**
* All names the dashboard may use for its session cookie, strictest variant
* first. Over HTTPS the server emits a [`__Host-`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes)
* (root-facing HTTPS) or `__Secure-` (HTTPS reverse-proxy with a path prefix)
* prefixed cookie; the bare name is the legacy HTTP form. Ordering matches the
* server's own fallback precedence so the most-constrained variant wins.
*/
val SESSION_COOKIE_NAMES: List<String> =
listOf(
"__Host-$SESSION_COOKIE_NAME",
"__Secure-$SESSION_COOKIE_NAME",
SESSION_COOKIE_NAME,
)
/** True when [name] is any recognized dashboard session-cookie name. */
fun isSessionCookieName(name: String): Boolean = name in SESSION_COOKIE_NAMES
/** True when [cookie] is the dashboard session cookie. */
fun isSessionCookie(cookie: Cookie): Boolean = cookie.name == SESSION_COOKIE_NAME
fun isSessionCookie(cookie: Cookie): Boolean = isSessionCookieName(cookie.name)
/** Parse a raw `Set-Cookie` header value (sans the `hermes_session_at=` key). */
fun Cookie.Companion.parseRaw(
@@ -74,8 +74,14 @@ class PersistentCookieJar(
return null
}
/** Convenience: value of the `hermes_session_at` session cookie. */
fun getSessionCookieValue(): String? = getCookie(SESSION_COOKIE_NAME)?.value
/**
* Convenience: value of the dashboard session cookie.
*
* Iterates [SESSION_COOKIE_NAMES] (strictest `__Host-` / `__Secure-`
* prefix first) and returns the first match. Over HTTPS the server may
* store the cookie under a prefixed name the bare-name lookup would miss.
*/
fun getSessionCookieValue(): String? = SESSION_COOKIE_NAMES.firstNotNullOfOrNull { name -> getCookie(name)?.value }
override fun saveFromResponse(
url: HttpUrl,
@@ -62,7 +62,15 @@ object ProfileScopeInterceptor : Interceptor {
return chain.proceed(request) // explicit param wins
}
if (!isProfileScopedPath(url.encodedPath)) {
// Derive the path relative to the endpoint's proxy prefix so profile
// scoping works when the dashboard is served behind a reverse proxy
// (e.g. `/hermes/api/status` → relative `/api/status`). A transient
// DataStore read failure mid-intercept must not crash every request.
val relativePath =
runCatching { AuthManager.endpoint().relativeRequestPath(url) }
.getOrDefault(url.encodedPath)
if (!isProfileScopedPath(relativePath)) {
return chain.proceed(request)
}
@@ -253,13 +253,6 @@ object HermesWsClient {
return true
}
val sessionCookie = AuthManager.getSessionCookie()
if (sessionCookie.isNullOrBlank()) {
Log.w(TAG, "WS ticket refresh aborted: no session cookie found in gated mode")
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
return false
}
try {
val client = OkHttpProvider.probe
val request =
@@ -294,7 +287,7 @@ object HermesWsClient {
return false
}
} catch (e: Exception) {
Log.w(TAG, "WS ticket refresh failed: ${e.message}")
Log.w(TAG, "WS ticket refresh failed: ${e.javaClass.simpleName}")
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
return false
}
@@ -539,7 +532,8 @@ object HermesWsClient {
code: Int,
reason: String,
) {
Log.d(TAG, "WebSocket closing: $code $reason")
// Do NOT log [reason] — it may carry server-side context.
Log.d(TAG, "WebSocket closing: $code")
webSocket.close(code, reason)
}
@@ -548,7 +542,9 @@ object HermesWsClient {
code: Int,
reason: String,
) {
Log.i(TAG, "WebSocket closed: $code $reason")
// Do NOT log [reason] — it may carry server-side context. The
// reason is still inspected internally to detect auth failures.
Log.i(TAG, "WebSocket closed: $code")
connected.set(false)
stopHealthTracking()
if (code == 4001 || code == 4401 ||
@@ -567,7 +563,10 @@ object HermesWsClient {
t: Throwable,
response: Response?,
) {
Log.e(TAG, "WebSocket failure: ${t.message}", t)
// Log the exception class only — [Throwable.message] can leak URLs
// or headers. The message is still inspected internally for auth
// detection.
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
connected.set(false)
stopHealthTracking()
val code = response?.code ?: 0
@@ -1,10 +1,12 @@
package com.m57.hermescontrol.data.remote
import kotlinx.coroutines.test.runTest
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
@@ -28,11 +30,62 @@ class CookieManagerTest {
return ServerEndpoint.fromLegacy(host, 9119)
}
private fun httpsEp(
host: String,
path: String = "/",
): ServerEndpoint =
ServerEndpoint.parse(
"https://$host:9119$path",
CleartextPolicy.ALLOW_WITH_WARNING,
)
@After
fun tearDown() {
CookieManager.resetForTest()
}
@Test
fun getSessionCookie_recognizesHostPrefixedCookie() =
runTest {
val jar = buildFakePersistentCookieJar()
jar.useStore(PersistentCookieJar.DEFAULT_SERVER_ID)
CookieManager.setJarForTest(jar)
// Server stored a __Host- prefixed cookie (HTTPS deployment).
jar.saveFromResponse(
"https://dash.local/".toHttpUrl(),
listOf(
Cookie
.Builder()
.name("__Host-hermes_session_at")
.value("prefixed-value")
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
.hostOnlyDomain("dash.local")
.path("/")
.secure()
.httpOnly()
.build(),
),
)
assertEquals("prefixed-value", CookieManager.getSessionCookie())
}
@Test
fun setSessionCookie_httpsMarksSecureAndHonorsPath() =
runTest {
val jar = fakeJar()
// Proxy-prefixed HTTPS endpoint.
CookieManager.setSessionCookie("sess-secure", httpsEp("dash.local", "/hermes/"))
val cookies =
jar.loadForRequest(
"https://dash.local/hermes/api/status".toHttpUrl(),
)
assertEquals(1, cookies.size)
assertTrue("HTTPS session cookie must be Secure", cookies[0].secure)
assertEquals("/hermes/", cookies[0].path)
}
@Test
fun setThenGetSessionCookie_roundTrips() {
fakeJar()
@@ -179,4 +179,60 @@ class PersistentCookieJarTest {
assertTrue(jar.loadForRequest("http://empty.local/".toHttpUrl()).isEmpty())
assertNull(jar.getSessionCookieValue())
}
@Test
fun getSessionCookieValue_findsPrefixedHostCookie() =
runTest {
val jar = makeJar()
jar.useStore("prefixed-scope")
val url = "https://dash.local/".toHttpUrl()
val prefixed =
Cookie
.Builder()
.name("__Host-hermes_session_at")
.value("host-prefixed-value")
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
.hostOnlyDomain("dash.local")
.path("/")
.secure()
.httpOnly()
.build()
jar.saveFromResponse(url, listOf(prefixed))
assertEquals("host-prefixed-value", jar.getSessionCookieValue())
}
@Test
fun getSessionCookieValue_prefersStrictestVariant() =
runTest {
val jar = makeJar()
jar.useStore("multi-scope")
val url = "https://dash.local/".toHttpUrl()
// Both a bare and a __Secure- prefixed cookie present; the strictest
// (__Host- / __Secure- first in SESSION_COOKIE_NAMES) must win.
val bare =
Cookie
.Builder()
.name(SESSION_COOKIE_NAME)
.value("bare-value")
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
.hostOnlyDomain("dash.local")
.path("/")
.httpOnly()
.build()
val secure =
Cookie
.Builder()
.name("__Secure-hermes_session_at")
.value("secure-value")
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
.hostOnlyDomain("dash.local")
.path("/")
.secure()
.httpOnly()
.build()
jar.saveFromResponse(url, listOf(bare, secure))
assertEquals("secure-value", jar.getSessionCookieValue())
}
}
@@ -2,9 +2,12 @@ package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
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.buildFakePersistentCookieJar
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
@@ -45,6 +48,13 @@ class HermesWsClientTest {
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
// Non-gated by default (token mode) so the gated ticket path is exercised
// only by the explicit gated-mode test below.
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config.ServerStoreState()
}
// Issue #470: clients are built through OkHttpProvider, which now
// resolves the shared CookieManager.cookieJar. Inject a fake jar so
@@ -475,4 +485,65 @@ class HermesWsClientTest {
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
assertFalse(HermesWsClient.isConnected)
}
// ── Issue #635: gated-mode WS ticket fetch must not be blocked by a
// missing bare-name session cookie (HTTPS deployments prefix it with
// __Host- / __Secure-). ────────────────────────────────────────────────
@Test
fun testGatedMode_attemptsTicketFetchWithoutBareCookie() {
// Force gated mode (ws auth via ticket, not loopback token).
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config.ServerStoreState(wsAuthParam = "ticket")
}
// No bare-name session cookie present (the prefixed one is server-side).
every { AuthManager.getSessionCookie() } returns null
// setToken is exercised by the ticket refresh; stub it (AuthManager is
// a mocked object, so unstubbed calls throw).
every { AuthManager.setToken(any()) } returns Unit
// Separate server for the ticket endpoint so its queue can't interleave
// with the WebSocket upgrade on the main mockWebServer.
val ticketServer = MockWebServer()
ticketServer.start()
every { AuthManager.endpointForBuild() } returns
ServerEndpoint.parse(
ticketServer.url("/").toString(),
CleartextPolicy.ALLOW_WITH_WARNING,
)
ticketServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{"ticket":"refreshed-ticket"}"""),
)
val connectLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
response: okhttp3.Response,
) {
connectLatch.countDown()
}
},
),
)
HermesWsClient.connect()
// Before the fix, a null bare cookie short-circuited to AUTH_EXPIRED and
// the ticket endpoint was NEVER called. After the fix it is attempted,
// so the connection reaches CONNECTED.
assertTrue(
"Gated WS ticket fetch should be attempted even without a bare cookie",
connectLatch.await(5, TimeUnit.SECONDS),
)
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
ticketServer.shutdown()
}
}