sync: scope the credential by the public-suffix list

The auth handler decided which hosts may receive the password by
comparing the last two labels of their names. A server at
cloud.example.co.uk therefore scoped the app password to co.uk, one at
myhome.duckdns.org to duckdns.org, and a self-hoster at 192.168.1.10 to
"any address ending .1.10". The handler sends Basic preemptively, before
any challenge, so those hosts get the password unprompted on the first
HTTPS request.

It is reachable: ServiceDiscovery accepts an SRV target outside the
domain it queried, over plain UDP DNS. Scoped correctly, an on-path
attacker needs a certificate for a name inside the victim's own
registrable domain. Scoped to co.uk, they need one for a domain they
already own.

Now topPrivateDomain(), from the list OkHttp bundles, falling back to the
exact host where there is none -- null means no restriction here, so an
IP literal or localhost must not pass one through. The handler had to
change with the caller: it re-derives the domain per request, so fixing
only the caller withholds the credential from everything.

Still trusted: two hosts under one registrable domain share an owner.
That is what iCloud's caldav/pNN-caldav split needs, and narrowing
further costs it.
This commit is contained in:
2026-09-07 21:35:10 +02:00
parent d5ce24b673
commit ede4205b7f
4 changed files with 160 additions and 12 deletions
+34
View File
@@ -180,3 +180,37 @@ downgrade still throws.
Found against a real server, not by reading: `cloud.jeanlucmakiola.de` returns
`301 → http://cloud.jeanlucmakiola.de/remote.php/dav/`.
## Change 8 — the credential is scoped by the public-suffix list, not by a label split
`BasicDigestAuthHandler` gated every request on
`domain.equals(UrlUtils.hostToDomain(request host))`, and `hostToDomain` is a
pure last-two-labels split with no public-suffix knowledge. So a server at
`cloud.example.co.uk` scoped the credential to `co.uk`, one at
`myhome.duckdns.org` to `duckdns.org`, and a self-hoster at `192.168.1.10` to
`1.10`.
⚠️ **The handler adds `Authorization: Basic` preemptively**, before any
challenge, to the first HTTPS request to any host that passes that gate. So the
scope is not merely recorded — it is the set of hosts that receive the app
password unprompted.
That is reachable. `ServiceDiscovery` builds candidate origins from SRV targets
without requiring the target to lie inside the queried domain, over plain UDP
DNS with no DNSSEC. Correct scoping forces an on-path attacker to obtain a
certificate for a name inside the victim's own registrable domain, which is
infeasible; `co.uk` scoping lets them point the SRV at a domain they own and
hold a legitimate certificate for.
Now: `request.url.topPrivateDomain() ?: request.url.host`. OkHttp bundles the
public-suffix list including its private section, so the dynamic-DNS providers
self-hosters actually use are covered. `topPrivateDomain()` is null for an IP
literal, a single-label host, and a host that *is* a public suffix — and null
means *no restriction* to this handler, so it falls back to the exact host.
The caller must derive the scope the same way, which is why
`CalDavHttp.authenticated` changed with it: a mismatch withholds the credential
from every request rather than leaking it.
`UrlUtils.hostToDomain` and its test are left alone — after this it has no
production callers, and keeping it keeps the resync diff small.
@@ -25,7 +25,12 @@ import java.util.concurrent.atomic.AtomicInteger
* Usage: Set as authenticator *and* as network interceptor.
*/
class BasicDigestAuthHandler(
/** Authenticate only against hosts ending with this domain (may be null, which means no restriction) */
/**
* Authenticate only against hosts sharing this registrable domain, as
* [HttpUrl.topPrivateDomain] derives it — or, for a host that has none
* (an IP literal, `localhost`, a host that *is* a public suffix), only
* against that exact host. Null means no restriction.
*/
val domain: String?,
val username: String,
@@ -61,7 +66,14 @@ class BasicDigestAuthHandler(
fun authenticateRequest(request: Request, response: Response?): Request? {
domain?.let {
val host = request.url.host
if (!domain.equals(UrlUtils.hostToDomain(host), true)) {
// ⚠️ The public-suffix list, not a last-two-labels split. Splitting
// scopes `cloud.example.co.uk` to `co.uk` and `192.168.1.10` to
// `1.10`, handing the credential preemptively to any host that
// matches — including one an attacker can buy a certificate for.
// `topPrivateDomain()` is null for hosts with no registrable domain,
// and null here would mean *no* restriction, so it falls back to the
// exact host.
if (!domain.equals(request.url.topPrivateDomain() ?: host, true)) {
Dav4jvm.log.warning("Not authenticating against $host because it doesn't belong to $domain")
return null
}