Commit 8a379d4719
8a379d4719bb3447b0fcfe42fc5164fcc019696b
parent: d0e26d3df9
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-28 08:59 UTC
web: Cache-Control: no-store on the login-link request
Ref #261
Layout: unified · split
CHANGELOG.org
+3
| @@ -107,6 +107,9 @@ for the eighteen commands whose CLI path differs from the registry's |
| 107 | 107 | directly, so a refusal reaches the viewer as a message instead of |
| 108 | 108 | being dropped. The watch button now cycles three states — default, |
| 109 | 109 | watching, muted — instead of two (#261). |
| 110 | - The response that consumes a login link's =?token== sends |
| 111 | =Cache-Control: no-store=, so no intermediary keeps a copy of the |
| 112 | single-use URL (#261). |
| 110 | 113 | |
| 111 | 114 | * v1.36.0 — 2026-09-23 |
| 112 | 115 | |
internal/httpd/accounts.go
+4
| @@ -121,6 +121,10 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) { |
| 121 | 121 | } |
| 122 | 122 | |
| 123 | 123 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { |
| 124 | // token, when present, is a single-use secret in the query string — |
| 125 | // the documented exception to "never in a URL" (Threat-Model). No |
| 126 | // cache may keep a copy of this response. |
| 127 | w.Header().Set("Cache-Control", "no-store") |
| 124 | 128 | token := r.URL.Query().Get("token") |
| 125 | 129 | if token == "" { |
| 126 | 130 | s.renderLogin(w, "", false, s.peekNext(r)) |
internal/httpd/logincookie_test.go
+24
| @@ -2,9 +2,11 @@ package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | "net/http/httptest" |
| 5 | 6 | "testing" |
| 6 | 7 | |
| 7 | 8 | "gitbay.org/gitbay/internal/config" |
| 9 | "gitbay.org/gitbay/internal/store" |
| 8 | 10 | ) |
| 9 | 11 | |
| 10 | 12 | // The session cookie must be Lax, not Strict. A login link clicked in a mail |
| @@ -36,3 +38,25 @@ func TestSessionCookieAttributes(t *testing.T) { |
| 36 | 38 | } |
| 37 | 39 | } |
| 38 | 40 | } |
| 41 | |
| 42 | // The login link's token rides in the query string — the one |
| 43 | // documented exception to "never in a URL" — so the response that |
| 44 | // consumes it must never be cached by an intermediary that might log |
| 45 | // or replay the URL (#261). |
| 46 | func TestLoginNoStoreHeader(t *testing.T) { |
| 47 | st, err := store.Open(":memory:") |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | defer st.Close() |
| 52 | if err := st.MigrateUp(); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | s := New(config.Default(), st) |
| 56 | rr := httptest.NewRecorder() |
| 57 | req := httptest.NewRequest("GET", "/login?token=bogus", nil) |
| 58 | s.login(rr, req) |
| 59 | if got := rr.Header().Get("Cache-Control"); got != "no-store" { |
| 60 | t.Errorf("Cache-Control = %q, want no-store", got) |
| 61 | } |
| 62 | } |