Closes #9.
What was wrong
The passkey ceremonies were the only anonymous endpoints that cost the instance work, and they answered questions they did not have to: whether an account exists, and whether it has a passkey. Nothing bounded how often a client could ask. Enrollment tokens sat in the request path, so every access log kept a one-time credential. A cookie of any length was hashed and looked up, and any User-Agent was stored. CSRF checks could parse an unbounded body. HSTS was tied to the passkey feature rather than to TLS.
What this does
Sign-in stops enumerating accounts. An unknown account, a retired account and an account with no passkey all receive an ordinary assertion request naming one credential that will not match. WebAuthn cannot distinguish that from a real account, so the endpoint cannot be asked the question; only finishing the ceremony fails, and the failure is generic.
Rate limits and a bounded ceremony store. Per address: 20 begins and 60
finishes a minute; per account name: 10 begins a minute; registration: 5 a
minute. A refused client gets 429 with Retry-After. The in-flight ceremony
map is capped at 4096 entries, swept on every put, five-minute TTL, so an
anonymous client cannot grow it. The address key is the configured
trusted-proxy header when there is one, the connection otherwise — the same
trust decision the Git transport already makes.
Enrollment tokens leave the request path. Links are now
/invite#<token>. Fragments are never sent to the server, so the token reaches
no instance log, no proxy access log and no referrer. Old /invite/<token> links
are redirected into the fragment so they keep working, and this instance’s own
log lines redact that path. The page renders before the token is known, so the
account name appears only after the ceremony — which is also one less thing a
link-holder learns from a stolen URL.
Body bounds before parsing. readForm applies MaxBytesReader, parses, and
then checks the CSRF field (or header). JSON endpoints now accept only the
X-Vole-CSRF header. Logout bounds its body and, like any sign-out, answers a
client with no session the same way.
Headers. Sign-in, registration, enrollment, the account page and the admin
invite page send Cache-Control: no-store, Pragma: no-cache,
Referrer-Policy: no-referrer. HSTS follows the transport: a request over TLS,
or an https public_url, gets it whether or not browser sign-in is enabled.
Sessions. A cookie that is not the shape this store issues (base64url, at
most 64 characters) is refused before it is hashed or queried, and the stored
User-Agent is cut to 256 bytes.
Passkey clone policy. A passkey whose signature counter did not advance is
refused at sign-in and recorded as auth.clone-warning. Documented in
accounts-and-access.md, including the trade: an authenticator with a broken
counter needs an admin to remove its passkey.
Notes
dev_loginwas already refused through a proxy:loopbackBrowserrequires a loopback peer, the configured port, and noForwarded/X-Forwarded-*header, and config validation refuses a public URL orhttps_addr. Existing tests cover it; no code change was needed. The acceptance criterion is therefore met, and I added nothing there rather than duplicating the check.Logoutdoes its ownMaxBytesReader+ParseForminstead of callingbeginPost, because a client with an expired session must still be able to sign out;beginPostwould redirect it to/logininstead.- Registration still reports “that account name is already taken”: choosing a name requires knowing whether it is free, and that is inherent to registration.
- The invite-fragment flow is exercised server-side (page renders, legacy
redirect,
InviteBeginwith a token), but thelocation.hashread is JavaScript and is not covered by a browser test in this suite.
Verification
gofmt,go vet ./...,go test -race ./...clean../vole workflow checkand./vole workflow run verifypass.
Tests, each failing before its fix
TestRateLimiterBudgets,TestAuthBeginIsRateLimited,TestLoginBeginIsRateLimited(HTTP level, 429 +Retry-After).TestLoginBeginDoesNotSayWhetherAnAccountExists(decoy shape, no state words).TestCeremonyStoreIsBounded(cap and post-sweep room).TestJSONEndpointsRequireTheCSRFHeader,TestLogoutRefusesAnOversizedBody.TestHSTSFollowsTheTransportNotPasskeys,TestAuthPagesAreNotCached,TestAccountPageIsNotCached.TestInviteTokensAreRedactedFromLogs, and the invite-page / legacy-redirect assertions inTestBrowserLoginAndInvitePages.TestLookupRefusesOversizedAndMalformedTokens,TestUserAgentIsTruncated.
Docs: accounts-and-access.md (link shape, rate limits, generic sign-in, clone
policy), operations.md (“Bounded sign-in”, HSTS), configuration.md (the
client-IP header now also keys the sign-in limits).