SHA256
fix(android): retain HTTPS sessions with upstream default cookie flags
Reproduce browser-success/native-401 with unmodified upstream SESSION_SECURE=false behind HTTPS. Keep cookies in the existing HTTPS-only origin-bound encrypted jar regardless of the server Secure attribute; preserve TLS, redirect, expiry and origin protections. Distinguish credential/2FA rejection from missing/rejected session handoff and clear stale login errors on retry. Verify 13 JVM tests plus the 7-test real-server suite in both Secure/default-cookie modes, including bad-password then successful native login. Bump Android preview to 0.2.1/code 3; extend CI and document the defect without claiming access to the user's production server.
This commit is contained in:
1 parent
129eac675c
commit
4b483c20e3
16 files changed
+220
-22
No files matched your search
@@ -10,8 +10,8 @@ android {
|
||||
applicationId = "community.yuvomi.mobile"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 2
|
||||
versionName = "0.2.0-native-preview"
|
||||
versionCode = 3
|
||||
versionName = "0.2.1-native-preview"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
buildTypes {
|
||||
|
||||
+7
@@ -77,9 +77,16 @@ class NativeUpstreamTest {
|
||||
compose.onNodeWithTag("connect").performClick()
|
||||
awaitTag("username")
|
||||
compose.onNodeWithTag("username").performTextInput("mobiletest")
|
||||
compose.onNodeWithTag("password").performTextInput(password + "-invalid")
|
||||
compose.onNodeWithTag("login").performScrollTo().performClick()
|
||||
awaitTag("auth-error")
|
||||
compose.onNodeWithText("Benutzername oder Passwort wurden vom Server nicht akzeptiert.").assertIsDisplayed()
|
||||
compose.onNodeWithTag("password").performTextInput(password)
|
||||
compose.onNodeWithTag("login").performScrollTo().performClick()
|
||||
awaitTag("native-dashboard")
|
||||
compose.activityRule.scenario.onActivity {
|
||||
assertEquals(null, ViewModelProvider(it)[NativeController::class.java].state.error)
|
||||
}
|
||||
captureFixtureDashboard()
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
@@ -9,7 +9,9 @@ import org.json.JSONObject
|
||||
|
||||
data class SessionCookie(val name: String, val value: String, val expires: Long)
|
||||
|
||||
/** An explicit same-origin jar; never installs a process-wide CookieHandler. */
|
||||
/** Origin-bound HTTPS-only jar; never installs a process-wide CookieHandler.
|
||||
* The native transport enforces HTTPS even when the server omits cookie Secure.
|
||||
*/
|
||||
class NativeCookies(private val server: ServerAddress, serialized: String? = null) {
|
||||
private val values = linkedMapOf<String, SessionCookie>()
|
||||
|
||||
@@ -35,7 +37,10 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
|
||||
for (header in headers) {
|
||||
val parsed = runCatching { HttpCookie.parse(header) }.getOrDefault(emptyList())
|
||||
for (cookie in parsed) {
|
||||
if (!allowed(cookie.name) || !cookie.secure || !safe(cookie.value)) continue
|
||||
// Upstream defaults SESSION_SECURE to false, even behind an HTTPS
|
||||
// proxy. Never downgrade transport: all of this jar's cookies are
|
||||
// bound to ServerAddress's HTTPS origin regardless of this flag.
|
||||
if (!allowed(cookie.name) || !safe(cookie.value)) continue
|
||||
if (cookie.domain != null && !cookie.domain.trimStart('.').equals(host, true)) continue
|
||||
if (cookie.path != null && cookie.path != "/") continue
|
||||
if (cookie.maxAge == 0L) {
|
||||
@@ -61,12 +66,27 @@ class NativeCookies(private val server: ServerAddress, serialized: String? = nul
|
||||
|
||||
fun clear() = values.clear()
|
||||
|
||||
fun hasSession(now: Long = System.currentTimeMillis()): Boolean = values["yuvomi.sid"]?.let {
|
||||
it.value.isNotEmpty() && it.expires > now
|
||||
} == true
|
||||
|
||||
private fun allowed(name: String) = name == "yuvomi.sid" || name == "csrf-token"
|
||||
private fun safe(value: String) = value.length <= 8192 && value.none { it <= ' ' || it == ';' || it == ',' || it == '\u007f' }
|
||||
}
|
||||
|
||||
class NativeApiError(val status: Int, val reason: String = "") : Exception() {
|
||||
fun userMessage(): String = when (status) {
|
||||
class NativeApiError(val status: Int, val reason: String = "", val authenticationStep: String = "") : Exception() {
|
||||
fun authenticationMessage(): String? = when {
|
||||
status == 401 && reason == "session_cookie_missing" ->
|
||||
"Der Server hat die Anmeldung bestätigt, aber kein nutzbares Sitzungscookie geliefert. Bitte Server und Reverse Proxy prüfen."
|
||||
status == 401 && reason == "session_not_accepted" ->
|
||||
"Die Anmeldung wurde bestätigt, aber der Server akzeptiert die Sitzung nicht. Bitte Server und Reverse Proxy prüfen."
|
||||
status == 401 && authenticationStep == "/auth/login" -> "Benutzername oder Passwort wurden vom Server nicht akzeptiert."
|
||||
status == 401 && authenticationStep == "/auth/2fa/verify" ->
|
||||
"Der Bestätigungscode ist ungültig oder die ausstehende Anmeldung ist abgelaufen. Bitte erneut anmelden."
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun userMessage(): String = authenticationMessage() ?: when (status) {
|
||||
401 -> "Anmeldung oder Sitzung ist nicht mehr gültig."
|
||||
403 -> "Diese Aktion ist nicht erlaubt. Bitte prüfe deine Berechtigungen."
|
||||
409 -> "Der Eintrag wurde inzwischen geändert. Bitte neu laden."
|
||||
@@ -85,6 +105,10 @@ class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, p
|
||||
cookies.clear()
|
||||
}
|
||||
|
||||
fun requireSessionCookie() {
|
||||
if (!cookies.hasSession()) throw NativeApiError(401, "session_cookie_missing")
|
||||
}
|
||||
|
||||
fun request(path: String, method: String = "GET", body: JSONObject? = null, retry: Boolean = true): JSONObject {
|
||||
require(path.startsWith("/") && !path.startsWith("//") && !path.contains('#') && !path.contains('\\'))
|
||||
val target = URI(server.origin + "/api/v1" + path)
|
||||
@@ -136,7 +160,10 @@ class NativeApi(val server: ServerAddress, private val cookies: NativeCookies, p
|
||||
request("/auth/me", retry = false)
|
||||
return request(path, method, body, retry = false)
|
||||
}
|
||||
if (status !in 200..299) throw NativeApiError(status, data.optString("reason"))
|
||||
if (status !in 200..299) {
|
||||
val step = path.takeIf { it == "/auth/login" || it == "/auth/2fa/verify" }.orEmpty()
|
||||
throw NativeApiError(status, data.optString("reason"), step)
|
||||
}
|
||||
return data
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
|
||||
@@ -66,7 +66,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
val selectedGeneration = generation
|
||||
state = previous.copy(busy = true, error = null)
|
||||
worker.execute {
|
||||
val result = runCatching { block(previous) }
|
||||
val result = runCatching { block(previous.copy(error = null)) }
|
||||
main.post {
|
||||
if (generation != selectedGeneration) return@post
|
||||
state = result.fold(
|
||||
@@ -165,6 +165,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
private fun authenticated(previous: NativeState, response: JSONObject): NativeState {
|
||||
requireNotNull(client).requireSessionCookie()
|
||||
val user = response.getJSONObject("user")
|
||||
val permissions = response.getJSONObject("permissions")
|
||||
val signedIn = previous.copy(
|
||||
@@ -179,7 +180,7 @@ class NativeController(application: Application) : AndroidViewModel(application)
|
||||
return try {
|
||||
signedIn.copy(dashboard = requireNotNull(client).request("/dashboard"))
|
||||
} catch (error: Exception) {
|
||||
if (error is NativeApiError && error.status == 401) throw error
|
||||
if (error is NativeApiError && error.status == 401) throw NativeApiError(401, "session_not_accepted")
|
||||
signedIn.copy(error = "Die Anmeldung war erfolgreich. Die Übersicht konnte noch nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,47 @@ import org.junit.Test
|
||||
class NativeCookiesTest {
|
||||
private val server = ServerAddress.parse("https://home.example:8443")
|
||||
|
||||
@Test fun acceptsOnlySecureSameHostSessionCookies() {
|
||||
@Test fun acceptsOnlyAllowListedSameHostCookies() {
|
||||
val jar = NativeCookies(server)
|
||||
jar.accept(listOf("yuvomi.sid=valid; Secure; HttpOnly; Path=/; Max-Age=60"))
|
||||
assertEquals("yuvomi.sid=valid", jar.header())
|
||||
jar.accept(listOf("yuvomi.sid=insecure; Path=/", "other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure"))
|
||||
jar.accept(listOf("other=unwanted; Secure", "yuvomi.sid=other-host; Domain=evil.example; Secure"))
|
||||
assertEquals("yuvomi.sid=valid", jar.header())
|
||||
}
|
||||
|
||||
@Test fun acceptsUpstreamDefaultCookieOnHttpsWithoutServerSecureAttribute() {
|
||||
val jar = NativeCookies(server)
|
||||
jar.accept(listOf("yuvomi.sid=default-cookie; HttpOnly; Path=/; Max-Age=60"))
|
||||
assertEquals("yuvomi.sid=default-cookie", jar.header())
|
||||
assertTrue(jar.hasSession())
|
||||
val restored = NativeCookies(server, jar.serialized())
|
||||
assertEquals("yuvomi.sid=default-cookie", restored.header())
|
||||
assertTrue(restored.hasSession())
|
||||
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=0"))
|
||||
assertFalse(jar.hasSession())
|
||||
}
|
||||
|
||||
@Test fun emptyExpiredAndMissingCookiesAreNotAuthenticatedSessions() {
|
||||
val jar = NativeCookies(server)
|
||||
assertFalse(jar.hasSession())
|
||||
jar.accept(listOf("yuvomi.sid=; Path=/; Max-Age=60"))
|
||||
assertFalse(jar.hasSession())
|
||||
val now = System.currentTimeMillis()
|
||||
jar.accept(listOf("yuvomi.sid=short; Path=/; Max-Age=1"), now)
|
||||
assertFalse(jar.hasSession(now + 2_000))
|
||||
}
|
||||
|
||||
@Test fun authenticationErrorsDistinguishCredentialsCodeAndSessionHandoff() {
|
||||
assertEquals(
|
||||
"Benutzername oder Passwort wurden vom Server nicht akzeptiert.",
|
||||
NativeApiError(401, authenticationStep = "/auth/login").userMessage(),
|
||||
)
|
||||
assertTrue(NativeApiError(401, authenticationStep = "/auth/2fa/verify").userMessage().contains("Bestätigungscode"))
|
||||
assertTrue(NativeApiError(401, "session_cookie_missing").userMessage().contains("Sitzungscookie"))
|
||||
assertTrue(NativeApiError(401, "session_not_accepted").userMessage().contains("akzeptiert die Sitzung nicht"))
|
||||
assertEquals("Anmeldung oder Sitzung ist nicht mehr gültig.", NativeApiError(401).userMessage())
|
||||
}
|
||||
|
||||
@Test fun absoluteExpiryDoesNotExtendWhenRestoring() {
|
||||
val now = System.currentTimeMillis()
|
||||
val jar = NativeCookies(server)
|
||||
|
||||
Reference in new issue
Block a user