detached 3922170

fix(web): exempt only exact login paths from the session gate

The gate decided on the decoded path, so POST /-%2Flogin%2Fx/... skipped
the session check while the router sent it to another handler. It now
looks at the escaped path and requires loginPrefix + a valid nonce and
nothing after it. The static prefix check and the redaction of login
links in the logs are hardened the same way.
commit
3922170cfb6de99195360f69d4c2e505218b51f5
author
brice · · 2026-10-10 19:20 UTC
committer
brice · · 2026-10-10 19:28 UTC
parent
19cd78f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +99 -10
M cmd/ermite/web_session_test.go +13 -2
@@ -171,9 +171,15 @@ func TestWebLoginEndToEnd(t *testing.T) {
171171 assert.Equal(t, "no-store", g.hdr.Get("Cache-Control"))
172172 assert.Contains(t, g.body, "login link not valid")
173173 r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "")
174- assert.Equal(t, 404, r.status, p)
175- assert.Equal(t, g.body, r.body, "no detail tells why")
176174 assert.Nil(t, r.cookie(sessionCookie))
175+ if validNonceShape(p) {
176+ assert.Equal(t, 404, r.status, p)
177+ assert.Equal(t, g.body, r.body, "no detail tells why")
178+ } else {
179+ // Not even the shape of a link: the gate refuses it, as it does
180+ // any POST without a session.
181+ assert.Equal(t, 403, r.status, p)
182+ }
177183 }
178184
179185 // Signed in: private repositories show on every page, headers say private.
@@ -271,6 +277,11 @@ func TestWebLoginEndToEnd(t *testing.T) {
271277 assert.Equal(t, 404, b.get("/secret", as(token)).status)
272278 }
273279
280+// validNonceShape tells whether a login path ends in 43 characters of base64url.
281+func validNonceShape(p string) bool {
282+ return len(strings.TrimPrefix(p, "/-/login/")) == 43
283+}
284+
274285 // cookieInterceptor makes a Connect client send the session cookie, as a
275286 // browser-made call would, to show the API pays no attention to it.
276287 func cookieInterceptor(token string) connect.Interceptor {
@@ -171,9 +171,15 @@ func TestWebLoginEndToEnd(t *testing.T) {
171 assert.Equal(t, "no-store", g.hdr.Get("Cache-Control"))171 assert.Equal(t, "no-store", g.hdr.Get("Cache-Control"))
172 assert.Contains(t, g.body, "login link not valid")172 assert.Contains(t, g.body, "login link not valid")
173 r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "")173 r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "")
174- assert.Equal(t, 404, r.status, p)
175- assert.Equal(t, g.body, r.body, "no detail tells why")
176 assert.Nil(t, r.cookie(sessionCookie))174 assert.Nil(t, r.cookie(sessionCookie))
175+ if validNonceShape(p) {
176+ assert.Equal(t, 404, r.status, p)
177+ assert.Equal(t, g.body, r.body, "no detail tells why")
178+ } else {
179+ // Not even the shape of a link: the gate refuses it, as it does
180+ // any POST without a session.
181+ assert.Equal(t, 403, r.status, p)
182+ }
177 }183 }
178 184
179 // Signed in: private repositories show on every page, headers say private.185 // Signed in: private repositories show on every page, headers say private.
@@ -271,6 +277,11 @@ func TestWebLoginEndToEnd(t *testing.T) {
271 assert.Equal(t, 404, b.get("/secret", as(token)).status)277 assert.Equal(t, 404, b.get("/secret", as(token)).status)
272 }278 }
273 279
280+// validNonceShape tells whether a login path ends in 43 characters of base64url.
281+func validNonceShape(p string) bool {
282+ return len(strings.TrimPrefix(p, "/-/login/")) == 43
283+}
284+
274 // cookieInterceptor makes a Connect client send the session cookie, as a285 // cookieInterceptor makes a Connect client send the session cookie, as a
275 // browser-made call would, to show the API pays no attention to it.286 // browser-made call would, to show the API pays no attention to it.
276 func cookieInterceptor(token string) connect.Interceptor {287 func cookieInterceptor(token string) connect.Interceptor {
M internal/adapters/httpserver/middleware.go +5 -2
@@ -4,6 +4,7 @@ package httpserver
44
55 import (
66 "net/http"
7+ "path"
78 "strings"
89 "time"
910
@@ -65,9 +66,11 @@ func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter
6566 // the path is a secret.
6667 const loginPathPrefix = "/-/login/"
6768
68-// loggedPath is the path of a request as the log may show it.
69+// loggedPath is the path of a request as the log may show it. The check is
70+// wide on purpose: a path that only reaches the link after cleaning
71+// ("/x/../-/login/…", "//-/login/…") or holds the prefix further in is cut too.
6972 func loggedPath(p string) string {
70- if strings.HasPrefix(p, loginPathPrefix) {
73+ if strings.HasPrefix(path.Clean(p), loginPathPrefix) || strings.Contains(p, loginPathPrefix) {
7174 return loginPathPrefix + "…"
7275 }
7376 return p
@@ -4,6 +4,7 @@ package httpserver
4 4
5 import (5 import (
6 "net/http"6 "net/http"
7+ "path"
7 "strings"8 "strings"
8 "time"9 "time"
9 10
@@ -65,9 +66,11 @@ func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter
65 // the path is a secret.66 // the path is a secret.
66 const loginPathPrefix = "/-/login/"67 const loginPathPrefix = "/-/login/"
67 68
68-// loggedPath is the path of a request as the log may show it.69+// loggedPath is the path of a request as the log may show it. The check is
70+// wide on purpose: a path that only reaches the link after cleaning
71+// ("/x/../-/login/…", "//-/login/…") or holds the prefix further in is cut too.
69 func loggedPath(p string) string {72 func loggedPath(p string) string {
70- if strings.HasPrefix(p, loginPathPrefix) {73+ if strings.HasPrefix(path.Clean(p), loginPathPrefix) || strings.Contains(p, loginPathPrefix) {
71 return loginPathPrefix + "…"74 return loginPathPrefix + "…"
72 }75 }
73 return p76 return p
M internal/adapters/httpserver/server_test.go +13 -0
@@ -141,6 +141,19 @@ func TestRequestLog(t *testing.T) {
141141 assert.Empty(t, quiet.String())
142142 }
143143
144+// The secret of a login link never reaches the request log, whatever shape
145+// the path takes.
146+func TestRequestLogCutsLoginLinks(t *testing.T) {
147+ var buf syncBuffer
148+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
149+ h := httpserver.New(httpserver.Config{}).Handler()
150+ for _, target := range []string{"/-/login/sEcReT", "//-/login/sEcReT", "/x/../-/login/sEcReT", "/-/login/sEcReT/more", "/-/./login/sEcReT"} {
151+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "POST", target, nil))
152+ }
153+ assert.NotContains(t, buf.String(), "sEcReT")
154+ assert.Contains(t, buf.String(), "/-/login/…")
155+}
156+
144157 func TestDirWritable(t *testing.T) {
145158 dir := t.TempDir()
146159 check := httpserver.DirWritable(dir)
@@ -141,6 +141,19 @@ func TestRequestLog(t *testing.T) {
141 assert.Empty(t, quiet.String())141 assert.Empty(t, quiet.String())
142 }142 }
143 143
144+// The secret of a login link never reaches the request log, whatever shape
145+// the path takes.
146+func TestRequestLogCutsLoginLinks(t *testing.T) {
147+ var buf syncBuffer
148+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf}))
149+ h := httpserver.New(httpserver.Config{}).Handler()
150+ for _, target := range []string{"/-/login/sEcReT", "//-/login/sEcReT", "/x/../-/login/sEcReT", "/-/login/sEcReT/more", "/-/./login/sEcReT"} {
151+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "POST", target, nil))
152+ }
153+ assert.NotContains(t, buf.String(), "sEcReT")
154+ assert.Contains(t, buf.String(), "/-/login/…")
155+}
156+
144 func TestDirWritable(t *testing.T) {157 func TestDirWritable(t *testing.T) {
145 dir := t.TempDir()158 dir := t.TempDir()
146 check := httpserver.DirWritable(dir)159 check := httpserver.DirWritable(dir)
M internal/adapters/web/session.go +17 -4
@@ -6,6 +6,8 @@ import (
66 "context"
77 "errors"
88 "net/http"
9+ "net/url"
10+ "path"
911 "strconv"
1012 "strings"
1113 "time"
@@ -88,9 +90,11 @@ func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
8890 }
8991
9092 // logPath is the path of a request as the log may show it: a login link is
91-// a secret.
93+// a secret. The decoded path is given, so the check is wide on purpose: a
94+// path that only reaches the link after cleaning ("/x/../-/login/…",
95+// "//-/login/…") or that holds the prefix further in is cut too.
9296 func logPath(p string) string {
93- if strings.HasPrefix(p, loginPrefix) {
97+ if strings.HasPrefix(path.Clean(p), loginPrefix) || strings.Contains(p, loginPrefix) {
9498 return loginPrefix + "…"
9599 }
96100 return p
@@ -100,7 +104,7 @@ func logPath(p string) string {
100104 // makes the request the owner's. A cookie that is not valid is cleared. A
101105 // request without a cookie is untouched.
102106 func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
103- if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.Path, StaticPrefix) {
107+ if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.EscapedPath(), StaticPrefix) {
104108 return r
105109 }
106110 c, err := r.Cookie(CookieName)
@@ -160,13 +164,22 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
160164 denyForbidden(w)
161165 return false
162166 }
163- if !SignedIn(r.Context()) && !strings.HasPrefix(r.URL.Path, loginPrefix) {
167+ if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {
164168 denyForbidden(w)
165169 return false
166170 }
167171 return true
168172 }
169173
174+// isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
175+// the right shape and nothing after it. It looks at the escaped path, which is
176+// what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
177+// for a link while the router sends it to another route.
178+func isLoginPath(u *url.URL) bool {
179+ nonce, ok := strings.CutPrefix(u.EscapedPath(), loginPrefix)
180+ return ok && validNonce(nonce)
181+}
182+
170183 func denyForbidden(w http.ResponseWriter) {
171184 w.Header().Set("Cache-Control", "no-store")
172185 http.Error(w, "forbidden", http.StatusForbidden)
@@ -6,6 +6,8 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "net/http"8 "net/http"
9+ "net/url"
10+ "path"
9 "strconv"11 "strconv"
10 "strings"12 "strings"
11 "time"13 "time"
@@ -88,9 +90,11 @@ func cacheHeaders(ctx context.Context, h http.Header, public, private string) {
88 }90 }
89 91
90 // logPath is the path of a request as the log may show it: a login link is92 // logPath is the path of a request as the log may show it: a login link is
91-// a secret.93+// a secret. The decoded path is given, so the check is wide on purpose: a
94+// path that only reaches the link after cleaning ("/x/../-/login/…",
95+// "//-/login/…") or that holds the prefix further in is cut too.
92 func logPath(p string) string {96 func logPath(p string) string {
93- if strings.HasPrefix(p, loginPrefix) {97+ if strings.HasPrefix(path.Clean(p), loginPrefix) || strings.Contains(p, loginPrefix) {
94 return loginPrefix + "…"98 return loginPrefix + "…"
95 }99 }
96 return p100 return p
@@ -100,7 +104,7 @@ func logPath(p string) string {
100 // makes the request the owner's. A cookie that is not valid is cleared. A104 // makes the request the owner's. A cookie that is not valid is cleared. A
101 // request without a cookie is untouched.105 // request without a cookie is untouched.
102 func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {106 func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request {
103- if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.Path, StaticPrefix) {107+ if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.EscapedPath(), StaticPrefix) {
104 return r108 return r
105 }109 }
106 c, err := r.Cookie(CookieName)110 c, err := r.Cookie(CookieName)
@@ -160,13 +164,22 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
160 denyForbidden(w)164 denyForbidden(w)
161 return false165 return false
162 }166 }
163- if !SignedIn(r.Context()) && !strings.HasPrefix(r.URL.Path, loginPrefix) {167+ if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {
164 denyForbidden(w)168 denyForbidden(w)
165 return false169 return false
166 }170 }
167 return true171 return true
168 }172 }
169 173
174+// isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
175+// the right shape and nothing after it. It looks at the escaped path, which is
176+// what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
177+// for a link while the router sends it to another route.
178+func isLoginPath(u *url.URL) bool {
179+ nonce, ok := strings.CutPrefix(u.EscapedPath(), loginPrefix)
180+ return ok && validNonce(nonce)
181+}
182+
170 func denyForbidden(w http.ResponseWriter) {183 func denyForbidden(w http.ResponseWriter) {
171 w.Header().Set("Cache-Control", "no-store")184 w.Header().Set("Cache-Control", "no-store")
172 http.Error(w, "forbidden", http.StatusForbidden)185 http.Error(w, "forbidden", http.StatusForbidden)
M internal/adapters/web/session_test.go +51 -2
@@ -115,8 +115,54 @@ func TestGate(t *testing.T) {
115115 assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session")
116116 assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", ""))
117117 assert.Equal(t, 1, lim.fails, "one failed login counted")
118- assert.Equal(t, 404, do("/-/login/x", "https://forge.example", ""))
119- assert.Equal(t, 2, lim.fails, "a malformed link counts too")
118+ assert.Equal(t, 403, do("/-/login/x", "https://forge.example", ""), "a malformed link is not a login")
119+}
120+
121+// GO-001: the exemption of the login POST is decided on the escaped path, the
122+// one the router matches, and on exactly loginPrefix + nonce. An encoded slash
123+// must not turn another route into a "login".
124+func TestGateLoginExemptionIsExact(t *testing.T) {
125+ sess := &fakeSessions{valid: "tok"}
126+ lim := &countingLimiter{}
127+ srv, err := New(Config{
128+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
129+ MergeRequests: ermiteconnect.NewMergeRequestServiceClient(http.DefaultClient, "http://unused.invalid"),
130+ PublicURL: "https://forge.example", Sessions: sess, Limiter: lim,
131+ })
132+ require.NoError(t, err)
133+ post := func(target, cookie string) int {
134+ req := httptest.NewRequest(http.MethodPost, target, nil)
135+ req.Header.Set("Origin", "https://forge.example")
136+ if cookie != "" {
137+ req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
138+ }
139+ rec := httptest.NewRecorder()
140+ srv.ServeHTTP(rec, req)
141+ return rec.Code
142+ }
143+ nonce := strings.Repeat("a", 43)
144+ for _, target := range []string{
145+ "/-%2Flogin%2Fx/merge-requests/1/close",
146+ "/-%2flogin%2fx/merge-requests/1/close",
147+ "/-%2Flogin%2F" + nonce + "/merge-requests/1/close",
148+ "/-/login%2F" + nonce,
149+ "/-/login/" + nonce + "/merge-requests/1/close",
150+ "/-/login/" + nonce + "/",
151+ "/-/login/" + nonce + "%2Fx",
152+ "/-/login/" + nonce + "%2F..%2Fx",
153+ "/-/login/%2e%2e/x/merge-requests/1/close",
154+ "/-/login/x/../../x/merge-requests/1/close",
155+ "/-/login/",
156+ "/-/login/%61" + nonce[1:],
157+ "/-/login//" + nonce,
158+ "/%2D/login/" + nonce,
159+ } {
160+ assert.Equal(t, http.StatusForbidden, post(target, ""), target)
161+ }
162+ assert.Empty(t, sess.used, "no link was used")
163+
164+ assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in")
165+ assert.Equal(t, []string{nonce}, sess.used)
120166 }
121167
122168 // A session check that cannot be made fails closed (anonymous) and keeps the
@@ -165,4 +211,7 @@ func TestNoSessionsNoRoutes(t *testing.T) {
165211 func TestLogPathHidesLinks(t *testing.T) {
166212 assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
167213 assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
214+ for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} {
215+ assert.Equal(t, "/-/login/…", logPath(p), p)
216+ }
168217 }
@@ -115,8 +115,54 @@ func TestGate(t *testing.T) {
115 assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session")115 assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session")
116 assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", ""))116 assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", ""))
117 assert.Equal(t, 1, lim.fails, "one failed login counted")117 assert.Equal(t, 1, lim.fails, "one failed login counted")
118- assert.Equal(t, 404, do("/-/login/x", "https://forge.example", ""))118+ assert.Equal(t, 403, do("/-/login/x", "https://forge.example", ""), "a malformed link is not a login")
119- assert.Equal(t, 2, lim.fails, "a malformed link counts too")119+}
120+
121+// GO-001: the exemption of the login POST is decided on the escaped path, the
122+// one the router matches, and on exactly loginPrefix + nonce. An encoded slash
123+// must not turn another route into a "login".
124+func TestGateLoginExemptionIsExact(t *testing.T) {
125+ sess := &fakeSessions{valid: "tok"}
126+ lim := &countingLimiter{}
127+ srv, err := New(Config{
128+ Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
129+ MergeRequests: ermiteconnect.NewMergeRequestServiceClient(http.DefaultClient, "http://unused.invalid"),
130+ PublicURL: "https://forge.example", Sessions: sess, Limiter: lim,
131+ })
132+ require.NoError(t, err)
133+ post := func(target, cookie string) int {
134+ req := httptest.NewRequest(http.MethodPost, target, nil)
135+ req.Header.Set("Origin", "https://forge.example")
136+ if cookie != "" {
137+ req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
138+ }
139+ rec := httptest.NewRecorder()
140+ srv.ServeHTTP(rec, req)
141+ return rec.Code
142+ }
143+ nonce := strings.Repeat("a", 43)
144+ for _, target := range []string{
145+ "/-%2Flogin%2Fx/merge-requests/1/close",
146+ "/-%2flogin%2fx/merge-requests/1/close",
147+ "/-%2Flogin%2F" + nonce + "/merge-requests/1/close",
148+ "/-/login%2F" + nonce,
149+ "/-/login/" + nonce + "/merge-requests/1/close",
150+ "/-/login/" + nonce + "/",
151+ "/-/login/" + nonce + "%2Fx",
152+ "/-/login/" + nonce + "%2F..%2Fx",
153+ "/-/login/%2e%2e/x/merge-requests/1/close",
154+ "/-/login/x/../../x/merge-requests/1/close",
155+ "/-/login/",
156+ "/-/login/%61" + nonce[1:],
157+ "/-/login//" + nonce,
158+ "/%2D/login/" + nonce,
159+ } {
160+ assert.Equal(t, http.StatusForbidden, post(target, ""), target)
161+ }
162+ assert.Empty(t, sess.used, "no link was used")
163+
164+ assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in")
165+ assert.Equal(t, []string{nonce}, sess.used)
120 }166 }
121 167
122 // A session check that cannot be made fails closed (anonymous) and keeps the168 // A session check that cannot be made fails closed (anonymous) and keeps the
@@ -165,4 +211,7 @@ func TestNoSessionsNoRoutes(t *testing.T) {
165 func TestLogPathHidesLinks(t *testing.T) {211 func TestLogPathHidesLinks(t *testing.T) {
166 assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))212 assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
167 assert.Equal(t, "/demo/tree", logPath("/demo/tree"))213 assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
214+ for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} {
215+ assert.Equal(t, "/-/login/…", logPath(p), p)
216+ }
168 }217 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close