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