sync: refuse Basic over cleartext even when challenged

insecurePreemptive gated only the preemptive branch, so a plain-HTTP
server answering 401 with a Basic challenge still got the password in a
header every hop can read. The flag's name was accurate; its coverage
was not. The gate moves onto the Basic emission, covering both paths,
and the flag becomes insecureBasic.

Digest is left answered over cleartext: it never puts the password on
the wire, and refusing it would break a LAN server the day the
per-account cleartext opt-in ships.

Not reachable in the app today -- network_security_config forbids
cleartext outright and nothing passes allowCleartext -- but :caldav is a
plain JVM module meant for reuse, where neither of those holds.

Upstream's testBasic asserted this exact behaviour over http://, so it
is amended to https:// and the cleartext cases are pinned explicitly.
PROVENANCE change 9 records it as the one upstream test this port
changes rather than inherits.
This commit is contained in:
2026-09-07 21:36:58 +02:00
parent ede4205b7f
commit 9f832686fb
5 changed files with 131 additions and 6 deletions
@@ -82,9 +82,8 @@ object CalDavHttp {
domain = origin.topPrivateDomain() ?: origin.host,
username = username,
password = password,
// Never preemptively over cleartext. The handler already gates its
// own preemptive path on isHttps; stating it is cheap insurance.
insecurePreemptive = false,
// Never over cleartext, challenged or not.
insecureBasic = false,
)
return base(userAgent)
.authenticator(handler)
@@ -3,7 +3,9 @@ package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import com.google.common.truth.Truth.assertThat
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.Test
class CalDavHttpTest {
@@ -171,6 +173,35 @@ class CalDavHttpTest {
).isNull()
}
@Test
fun `cleartext carries no credential even when the server asks for one`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val request = Request.Builder().url("http://cloud.example.com/dav/").build()
// ⚠️ Gating only the preemptive path leaves this open: the server just
// has to ask. Basic is the password, in a header every hop can read.
assertThat(handler.authenticateRequest(request, challenge(request, "Basic"))).isNull()
}
@Test
fun `a challenge over TLS is still answered`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val request = Request.Builder().url("https://cloud.example.com/dav/").build()
val authorised = handler.authenticateRequest(request, challenge(request, "Basic"))
assertThat(authorised?.header("Authorization")).startsWith("Basic")
}
private fun challenge(request: Request, scheme: String) = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(401)
.message("Authentication required")
.header("WWW-Authenticate", "$scheme realm=\"dav\"")
.build()
@Test
fun `derived clients share one connection pool`() {
// A fresh OkHttpClient per probe gives each its own pool and dispatcher