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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Harden the browser sign-in surface

edf9db6e07e5fd676049abcd41e78790b0953652 · Keenan Nemetz · 12d ago · parent 62b59a76

Harden the browser sign-in surface

The passkey ceremonies were the one place an anonymous client could make
the instance work, and they answered more than they had to. Sign-in told a
stranger whether an account existed, nothing bounded how often a client
could ask, an invite token sat in the request path where every access log
would keep it, and a session lookup hashed whatever cookie it was handed.

Sign-in no longer answers the question. An unknown account, a retired one
and an account with no passkey all get an ordinary assertion request
naming one credential that will not be found, which is a shape WebAuthn
cannot distinguish from a real account. Only finishing the ceremony fails,
generically.

The ceremonies are rate-limited per address (20 begins a minute, 60
finishes) and per account name (10 begins), and registration per address
(5), with 429 and Retry-After past the budget. The in-flight ceremony map
is capped at 4096 entries, swept on every put, five-minute TTL, so a
client that never finishes one cannot grow it. The key is the configured
trusted-proxy header when there is one, the connection otherwise.

Enrollment tokens moved into the URL fragment: /invite#<token> is never
sent to the server, so it reaches no log of ours and no proxy access log,
and no referrer carries it. Links in the old /invite/<token> shape are
redirected into the fragment, and the paths this instance does log are
redacted. The page itself renders before the token is known, so the
account name appears only after the ceremony.

CSRF checks no longer parse the request body. JSON endpoints require the
X-Vole-CSRF header; form endpoints parse a body that readForm has already
bounded with MaxBytesReader, and then compare the field or the header.
Logout bounds its body before parsing and answers a client with no
session the same way it answers a successful sign-out.

Sign-in, registration, enrollment, the account page and the admin invite
page are no-store, no-cache and no-referrer. HSTS now follows the
transport rather than the passkey feature: a request over TLS, or an
https public_url, is an HTTPS instance whether or not browser sign-in is
configured.

Sessions refuse a cookie that is not the shape this store issues (base64url,
at most 64 characters) before hashing or querying it, and the stored
User-Agent is cut to 256 bytes.

A passkey whose signature counter does not advance is refused at sign-in
and recorded as auth.clone-warning. The policy is documented; the trade is
that an authenticator with a broken counter needs an admin to remove its
passkey.

Tests: the limiter's window and per-key budgets, 429 with Retry-After on a
real begin, the decoy's shape and its silence about account state, the
ceremony cap and its sweep, the JSON endpoints refusing a form CSRF field,
HSTS across TLS/proxy/http, no-store on the auth pages, the invite token
redacted from a logged server error, the legacy invite redirect, logout
refusing an oversized body, the account page's headers, session token
shape and User-Agent truncation.

server/auth_test.go +93−9

@@ -117,17 +117,27 @@ func TestBrowserLoginAndInvitePages(t *testing.T) {
117 117 t.Fatalf("login page lost the next path: %s", body)
118 118 }
119 119
120 acct, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour)
120 _, inv, err := f.srv.Identity.CreateWithInvite("newbie", "test", time.Hour)
121 121 if err != nil {
122 122 t.Fatal(err)
123 123 }
124 resp, body = f.browserGet(t, "/invite/"+inv.Token, "")
125 if resp.StatusCode != http.StatusOK || !strings.Contains(body, acct.Name) || !strings.Contains(body, "Create a passkey") {
124 // The token is in the fragment, so the server sees only /invite: the page is
125 // identical for a valid link and a stale one, and nothing about the token
126 // reaches a log. InviteBegin is what answers whether it is still good.
127 resp, body = f.browserGet(t, "/invite", "")
128 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Create a passkey") {
126 129 t.Fatalf("invite page: status=%d body=%s", resp.StatusCode, body)
127 130 }
128 resp, _ = f.browserGet(t, "/invite/not-a-token", "")
129 if resp.StatusCode != http.StatusNotFound {
130 t.Fatalf("invalid invite status = %d, want 404", resp.StatusCode)
131 if got := resp.Header.Get("Cache-Control"); got != "no-store" {
132 t.Fatalf("invite page cache header = %q", got)
133 }
134 if got := resp.Header.Get("Referrer-Policy"); got != "no-referrer" {
135 t.Fatalf("invite page referrer policy = %q", got)
136 }
137 // A link in the old shape still works: it is redirected into the fragment.
138 resp, _ = f.browserGet(t, "/invite/"+inv.Token, "")
139 if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/invite#"+inv.Token {
140 t.Fatalf("legacy invite link = %d %q", resp.StatusCode, resp.Header.Get("Location"))
131 141 }
132 142 }
133 143
@@ -138,9 +148,9 @@ func TestLoginIsDisabledWithoutHTTPS(t *testing.T) {
138 148 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") {
139 149 t.Fatalf("disabled login page: status=%d body=%s", resp.StatusCode, body)
140 150 }
141 resp, _ = f.browserGet(t, "/invite/anything", "")
142 if resp.StatusCode != http.StatusNotFound {
143 t.Fatalf("disabled invite status = %d, want 404", resp.StatusCode)
151 resp, body = f.browserGet(t, "/invite", "")
152 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "public_url") {
153 t.Fatalf("disabled invite page: status=%d body=%s", resp.StatusCode, body)
144 154 }
145 155 }
146 156
@@ -815,3 +825,77 @@ func TestDisplayNameRouteIsGone(t *testing.T) {
815 825 t.Fatalf("display route status = %d, want 404", resp.StatusCode)
816 826 }
817 827 }
828
829 // The body of a sign-out is bounded before it is parsed. A request that carries
830 // more than a CSRF field is refused rather than read.
831 func TestLogoutRefusesAnOversizedBody(t *testing.T) {
832 t.Parallel()
833 f := browserFixture(t)
834 sess, cookie := f.browserSession(t, f.ownerID)
835
836 req, err := http.NewRequest("POST", f.http.URL+"/logout",
837 strings.NewReader("csrf="+sess.CSRF+"&padding="+strings.Repeat("a", 128<<10)))
838 if err != nil {
839 t.Fatal(err)
840 }
841 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
842 req.Header.Set("Cookie", cookie)
843 resp, err := noRedirectAuth.Do(req)
844 if err != nil {
845 t.Fatal(err)
846 }
847 resp.Body.Close()
848 if resp.StatusCode != http.StatusBadRequest {
849 t.Fatalf("oversized logout body = %d, want 400", resp.StatusCode)
850 }
851 if _, err := f.srv.Sessions.Lookup(sess.Token); err != nil {
852 t.Fatal("a refused logout revoked the session")
853 }
854 }
855
856 // The begin endpoint answers with the same shape whether or not the account
857 // exists, and stops answering at all once an address has spent its budget.
858 func TestLoginBeginIsRateLimited(t *testing.T) {
859 t.Parallel()
860 f := browserFixture(t)
861
862 var sawOK, sawLimited bool
863 for i := 0; i < 100; i++ {
864 resp, _ := f.browserJSON(t, "/auth/login/begin", "", "", map[string]string{"username": "nobody"})
865 switch resp.StatusCode {
866 case http.StatusOK:
867 if sawLimited {
868 t.Fatal("a limited address was served again without waiting")
869 }
870 sawOK = true
871 case http.StatusTooManyRequests:
872 sawLimited = true
873 if resp.Header.Get("Retry-After") == "" {
874 t.Fatal("the rate limit response has no Retry-After")
875 }
876 default:
877 t.Fatalf("status = %d", resp.StatusCode)
878 }
879 if sawLimited {
880 break
881 }
882 }
883 if !sawOK || !sawLimited {
884 t.Fatalf("ok=%v limited=%v; the login endpoint is not rate limited", sawOK, sawLimited)
885 }
886 }
887
888 // An account page carries the session and the passkeys on it; neither is
889 // something a cache or a referrer should keep.
890 func TestAccountPageIsNotCached(t *testing.T) {
891 t.Parallel()
892 f := browserFixture(t)
893 _, cookie := f.browserSession(t, f.ownerID)
894 resp, _ := f.browserGet(t, "/account", cookie)
895 if got := resp.Header.Get("Cache-Control"); got != "no-store" {
896 t.Fatalf("account page Cache-Control = %q", got)
897 }
898 if got := resp.Header.Get("Referrer-Policy"); got != "no-referrer" {
899 t.Fatalf("account page Referrer-Policy = %q", got)
900 }
901 }