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 | directly, so a refusal reaches the viewer as a message instead of |
107 | directly, so a refusal reaches the viewer as a message instead of |
| 108 | being dropped. The watch button now cycles three states — default, |
108 | being dropped. The watch button now cycles three states — default, |
| 109 | watching, muted — instead of two (#261). |
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 | * v1.36.0 — 2026-09-23 |
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 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { |
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 | token := r.URL.Query().Get("token") |
128 | token := r.URL.Query().Get("token") |
| 125 | if token == "" { |
129 | if token == "" { |
| 126 | s.renderLogin(w, "", false, s.peekNext(r)) |
130 | s.renderLogin(w, "", false, s.peekNext(r)) |
internal/httpd/logincookie_test.go
+24
| @@ -2,9 +2,11 @@ package httpd |
| 2 | |
2 | |
| 3 | import ( |
3 | import ( |
| 4 | "net/http" |
4 | "net/http" |
| |
5 | "net/http/httptest" |
| 5 | "testing" |
6 | "testing" |
| 6 | |
7 | |
| 7 | "gitbay.org/gitbay/internal/config" |
8 | "gitbay.org/gitbay/internal/config" |
| |
9 | "gitbay.org/gitbay/internal/store" |
| 8 | ) |
10 | ) |
| 9 | |
11 | |
| 10 | // The session cookie must be Lax, not Strict. A login link clicked in a mail |
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 | } |