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:
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
|
||||
|
||||
Reference in new issue
Block a user