Thunderbird, CalDAV en de fail2ban zelfban: hoe we een startprobe tilden tot geclassificeerde ruis
Zaterdagochtend 04-10-2026: git.eddydevink.nl onbereikbaar vanaf de laptop. Eerste verdachte: de CGNAT-hairpin van Odido. Tweede verdachte, na tien seconden in de logs: fail2ban had ons eigen IP geband. Twee jails tegelijk, binnen één seconde. De trigger: Thunderbird had bij het opstarten 12× HTTP 401 op de CalDAV-agenda’s van Nextcloud gegenereerd — binnen één seconde. nginx-dav-auth en crossjail tellen 401s als brute-force-pogingen, dus een eigen client die bulk-401s produceert band zichzelf.
Dit is het verhaal van hoe we dat hebben gemeten, welke wegen we bewandelden (en waarom ze doodliepen), en hoe de uiteindelijke oplossing de detectie juist preciezer maakte in plaats van losser.
Het patroon: een dubbele golf per start
De eerste blik op de access.log was al verraderlijk. Van één en hetzelfde IP, binnen twee seconden, een perfect symmetrisch patroon:
PROPFIND /remote.php/dav/calendars/eddy/eddy_nc/ 401 → 207
PROPFIND /remote.php/dav/calendars/eddy/ebs_vrijedagen/ 401 → 207
PROPFIND /remote.php/dav/calendars/eddy/ebs_diensten-1/ 401 → 207
PROPFIND /remote.php/dav/calendars/eddy/contact_birthdays/ 401 → 207
OPTIONS /remote.php/dav/calendars/eddy/ 401 → 200 (×4)
PROPFIND /remote.php/dav/principals/users/eddy/ 401 → 207 (×4)
Elke request ging twee keer de deur uit: eerst met 401, daarna met 207/200. User-Agent: Thunderbird/156.0. En daarna: niets meer. Eén cluster, één start, klaar.
De gebruikelijke verdachte — een verlopen app-wachtwoord — hield geen stand: in de database (oc_authtoken) staat het token “Thunderbird” als geldig (password_invalid=0) en het was in dezelfde seconde succesvol gebruikt voor de 207-retries. Het Nextcloud-log bleef zelfs helemaal stil: geen Login failed-entry. Er werd dus nooit een fout wachtwoord aangeboden — de eerste golf ging zonder credentials de deur uit.
Hoe we het gemeten hebben
Het cruciale inzicht kwam pas toen we gingen clusteren op tijdstempel. De hele historie van vandaag:
09:57 – 12× 401 (Thunderbird-start)
13:51 – 12× 401 (Thunderbird-start) → crossjail-ban in 1 seconde, recidive volgde
14:01 – 12× 401 (Thunderbird-start)
14:03 – 12× 401 (natieve build, zelfde profiel — zie onder)
Per start exact 12: vier kalender-PROPFINDs, vier OPTIONS, vier principals-requests. De golf komt vóór de wachtwoordmanager klaar is; zodra die er is, wordt alles herhaald met credentials.
De doodlopende wegen (en hun bewijs)
We hebben de oplossingsruimte systematisch afgelopen, elk met een harde meting ertegenaan:
| Hypothese | Test | Uitkomst |
|---|---|---|
| Verlopen app-wachtwoord | DB-check (password_invalid), sha512-vergelijking vault-token vs live-token, curl-PROPFIND | Token geldig + succesvol gebruikt dezelfde seconde. Vault-pass bleek wel dood, maar was nooit de trigger |
| GNOME-keyring niet ontgrendeld | Locked-property van de secrets-service, flatpak-permissies | Keyring stond open óf is onbereikbaar voor de sandbox — Thunderbird gebruikt ’m überhaupt niet (profile-lokale logins.json + key4.db) |
| Rust-wachtwoordopslag kapot | logins.db inspecteren, migratie forceren (signon.storage.rust.restoreDone) | Store blijft leeg; legacy-store is de levende bron. Migratie verandert niets aan het patroon |
| Flatpak is de boosdoener | Native RPM-build met gekopieerd profiel (A/B, zelfde versie 156.0) | Identieke 12× 401 bij elke start. De race zit in Thunderbird zelf, niet in de verpakking |
| maxretry omhoog (10→50) | Live, daarna TB-start observeren | nginx-dav-auth bleef stil, maar crossjail (maxretry=3/24u, spiegelt élke Found van alle jails) bande alsnog binnen 1 seconde, en recidive escaleerde door |
De grappigste les: het is geen bug in Thunderbird, het is de CalDAV-authenticatieprobe. Thunderbird stuurt bij elke sessie een onbewuste request om uit de WWW-Authenticate-header te leren welke auth-scheme (Basic of Bearer) de server wil — en pas daarna authenticeert het. Dat is design, geen regressie.
We zijn niet de enige
De community kent dit patroon al jaren, onder allerlei symptomen:
- monicahq/monica#6183 — “Thunderbird will refuse to save credentials” bij non-RFC7617
WWW-Authenticate; de nginx-workaround vervangt de header doorBasic realm="...". - help.nextcloud.com/t/93233 — de canonieke Nextcloud-thread:
calendar.network.multirealm=true, theming-naam als realm (met een PR om het realm altijd te zetten). - Bugzilla: 2014716, 1769493, 1789424 — allemaal uitingen van dezelfde per-start-herverificatie.
De community-“fixes” zijn allemaal server-of-config-kant: realm correct teruggeven, multirealm aanzetten, .well-known correct redirecten. Niemand stopt de probe — en dat kan ook niet van clientkant. Onze server was overigens al correct (RFC7617-conform Basic realm="Nextcloud"), dus die kant was hier niet het probleem.
De definitieve oplossing: classificeer de aanval, niet de 401
Het antwoord was geen ruimere fail2ban, maar een preciezere: onderscheid de probe van de brute-force door te kijken óf er überhaupt credentials werden aangeboden. Een aanvaller kán niet raden zónder Authorization-header; een probe stuurt die per definitie niet.
- nginx logt de classificatie mee — een nieuw logveld
$dav_authop de Nextcloud-vhost:
# $dav_auth: "auth" = Authorization aanwezig (echte poging), "noauth" = probe
map $http_authorization $dav_auth {
default "auth";
"" "noauth";
}
log_format main_davauth '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" $dav_auth';
- De fail2ban-filter eist het veld:
[nginx-dav-auth]
# telt alleen 401s mét Authorization (afsluitend "auth" in main_davauth)
failregex = (?i)^<HOST> .* "…remote\.php/(dav|webdav).*" 401 .* auth$
maxretry = 10 # terug naar strikt: de ruis is weg
- crossjail spiegelt DAV niet meer —
\[(?!crossjail|nginx-dav-auth)\S+\]. Dat is geen crossjail uitzetten: de jail staat volledig aan en vangt nog steeds elke echte multi-jail-aanval (sshd+forgejo+nextcloud+…). Alleen wordt de discussie “1-jail DAV-ruis” niet meer dubbel geteld. Een aanvaller die alleen DAV raakt wordt nog altijd doornginx-dav-authzelf geband — dat was ook vóór de wijziging zo.
Verificatie (alles live, na verwijderen van de tijdelijke whitelist)
- Echte Thunderbird-start → 12× 401, allemaal
noauth→ 0 Found-events, 0 bans in elke jail. - Brute-force-simulatie (verkeerd wachtwoord) → wél een Found, teller stijgt: detectie intact.
fail2ban-regexover een volledige dag logs: exact 1 match — alleen de credentialed 401.
Wat het heeft opgelost
- Geen zelfban meer bij elke Thunderbird-start (en geen recidive-escalatie die tot ~191 jaar ban kan oplopen).
- De brute-force-detectie staat minstens even streng als eerst: maxretry terug naar 10, en de teller wordt niet meer vervuild door ruis die nooit een aanval was.
- Geen lokale proxy, geen keyring-herconfiguratie, geen native-reinstall nodig — Thunderbird blijft gewoon draaien zoals het is.
De les voor ons: een “false positive” die 401s produceert is vaak geen fout in de client en geen fout in de server — het is een signaal dat de detector de verkeerde dimensie bewaakt. Of er credentials werden aangeboden is een betere scheidslijn tussen “probe” en “poging” dan het aantal 401s per uur.
Zie ook de eerdere analyse van onze fail2ban-filters.