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
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user