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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user