Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

#55

Harden the browser sign-in surface

merged · by keenan 13d ago · merged into main at edf9db6e by keenan 12d ago

keenan opened this 13d ago

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_login was already refused through a proxy: loopbackBrowser requires a loopback peer, the configured port, and no Forwarded/X-Forwarded-* header, and config validation refuses a public URL or https_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.
  • Logout does its own MaxBytesReader + ParseForm instead of calling beginPost, because a client with an expired session must still be able to sign out; beginPost would redirect it to /login instead.
  • 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, InviteBegin with a token), but the location.hash read is JavaScript and is not covered by a browser test in this suite.

Verification

  • gofmt, go vet ./..., go test -race ./... clean.
  • ./vole workflow check and ./vole workflow run verify pass.

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 in TestBrowserLoginAndInvitePages.
  • 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).

Discussion (0)

No discussion yet.