Repository navigation
fix(carddav): gate Basic auth on the shared login rate limit - #580
Merged
maathimself merged 2 commits intoOct 11, 2026
Merged
Conversation
cardavAuth only read the shared auth:<ip> bucket after a wrong password, and only to choose between 401 and 429. A right password went through whatever the bucket said: once the budget was spent a wrong guess got 429 and the right one got in, so guessing was bounded only by the CardDAV limit of 500 requests per window. Take a slot atomically before the password check and answer 429 without checking once the budget is spent. Only a wrong password keeps its slot. A right password hands it back through a new rateLimiter.release(), and so does a refused request unless the window is about to end, so sync traffic and 2FA accounts never use the budget up. The slot is taken after the user lookup, so a database error neither counts nor refunds anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maathimself
approved these changes
Oct 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CardDAV accepts the account password over HTTP Basic, but a right password is never checked against the login rate limit. Once an IP has used up its budget, a wrong guess gets
429and the right one still gets in. So the limit tells a guesser when they have found the password instead of stopping them.cardavAuthonly reads the sharedauth:<ip>bucket insideauthFail, afterbcrypt.comparehas already said no, and only to pick429over401(carddav.js#L83-L90,#L97-L98). A right password goes straight tonext()(#L107-L108), and every guess still runs a bcrypt compare, however far over budget the IP is. The only real bound is the CardDAV request limit of 500 per window per IP (#L37-L39), an in-memory counter that is per process and resets on restart./api/auth/logintakes its slot before it looks at the password (auth.js#L77-L89), so at the default settings it allows 10 guesses per 15 minutes where CardDAV allows 500.Installs that route
/carddav/to the backend themselves are exposed today. The stock nginx config does not proxy it yet.Changes
cardavAuthtakes a slot from the sharedauth:<ip>budget before it checks the password, with the same atomicconsume()the login routes use. If the budget is used up, it answers429withRetry-Afterand does not check the password or log an auth event, the same as the login limiter.carddav_auth_failwith a401, as before. So does a guess at an unknown username or at an account with no password, after the existing dummy compare.rateLimiter.release(). A syncing client authenticates on every request, so it never uses up the budget. Neither does a 2FA account whose phone keeps sending the right password and getting403.429hands its slot back too, since no password was checked. Otherwise the refused requests in a burst of right passwords larger than the budget would stay counted, and enough of them would lock the IP out of CardDAV and web login without a single wrong password. In the last two seconds of a window the refused request keeps its slot instead. The counter is about to expire anyway, and a hand-back that landed after it did would take a counted guess off the next window.500without counting.release()decrements the Redis counter and deletes the key if it had expired mid-request and came back at-1. When Redis is down it falls back to the in-memory counter, never below zero, likeconsume().Two simpler versions do not work. Checking the bucket before the password without taking a slot lets a burst of concurrent guesses all pass the check. Resetting the bucket on a right password, as
/api/auth/logindoes, would let any CardDAV sync from the same IP, including an attacker's own account, wipe the counter that web login failures also use.Behaviour change: once an IP has used up the budget, CardDAV from that IP gets
429even with the right password until the window ends. The budget counts wrong CardDAV passwords plus requests to the sign-in routes that share the limit. Web login from that IP is already blocked the same way, and the admin setting's description ("Failed login attempts allowed from a single IP address before it is temporarily blocked") now holds for CardDAV too. The screen-lock PIN avoids blocking a correct PIN like this by counting failures per user and signing the session out after too many (auth.js#L639-L643). CardDAV has no session to sign out, and a right password that still gets in after the budget is spent is what tells a guesser they have found it, so the check has to come before the password.A slot is held while the password is checked, so if more CardDAV password checks are in flight from one IP at once than the budget has room for, the extra ones get
429with aRetry-Afterfor the rest of the window. Those refusals are not counted, so they do not lock the IP out. With the default budget and no recent failures, that takes more than 10 at once.The limit is keyed on
req.ip, like the login limiter. Since #540 the https profile setsTRUST_PROXY_HOPS=2, so behind Caddyreq.ipis the client's address, and the README says to set it behind an operator's own proxy too. Where it is not set behind a second proxy,req.ipis that proxy's address and web login already shares one budget across all users there. Once/carddav/is proxied through nginx, CardDAV on such an install would share that budget too: ten wrong passwords from one device with a stale password would stop CardDAV sync for every user until the window ends. So this is best in before/carddav/is proxied, and that change should point atTRUST_PROXY_HOPS.Not in this PR:
internal_auth_disabled) and MFA-required (mfa_enforcement). Enforcing them would turn CardDAV off on those servers until app-specific passwords exist, so that goes in a separate issue.auth:<ip>counter (auth.js#L86, called at#L265and#L313), CardDAV failures included. Anyone who can sign in to their own account from the same IP can spend most of the budget guessing another account's password, sign in, and repeat. That is already true of web login. Handing back only the successful request's own slot withrelease()would close it, but that changes the auth routes, so it is a separate change.Testing
New
backend/src/routes/carddav.auth.test.jsmounts the real CardDAV router on Express and sendsPROPFIND /carddav/, the discovery request a client starts with, overfetch, like the other route tests. The realrateLimiter.jsruns against an in-memory Redis stub, with a budget of 3.401, then the right one gets429with aRetry-After.bcrypt.comparethree times, and get three401s and five429s.207.bcrypt.comparewaits on a gate, so three hold the whole budget and three are refused. Once the gate opens, the burst gets three207s and three429s, and the next right password gets207.429and stays counted.401, 401, 207, 401, 429, so a right password hands back only its own slot.401. The next one gets429, and so does one for an account with no password.403, none429.500, and the right password still gets207afterwards.rateLimiter.test.jsgains threerelease()cases: it hands back one hit; on an expired key it does not leave a spare hit behind; and the in-memory fallback hands back one hit and never goes below zero.Against the current code, seven of these fail. The right password gets
207instead of429in the first test and in the end-of-window test,bcrypt.compareruns 8 times instead of 3, all six requests of the burst reach it, and the threerelease()tests fail becausereleasedoes not exist. The syncing-client, right-between-wrong, unknown-username, 2FA and database-error tests pass on the current code. They pin the parts of the fix that keep sync working and the limit tight.Breaking any one part of the fix fails at least one test:
release()after a right password: the syncing-client, burst, right-between-wrong and 2FA tests.reset()in place ofrelease(): the right-between-wrong test (401, 401, 207, 401, 401) and three others.429.release()without the expired-key cleanup or the zero floor: the matching limiter test.Backend
npm run lintclean,npm run lint:pluginsclean,npm testpasses (2420 passed, 29 skipped without a local Postgres). The frontend is not touched.Contributor License Agreement
By submitting this pull request I confirm that:
third-party material and confirmed it is compatible with the CLA).
🤖 Generated with Claude Code