1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "errors"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21 "bnema.dev/ermite/internal/ports/mocks"
22)
23
24// nonceA is the one valid login link of the mocks.
25var nonceA = strings.Repeat("a", 43)
26
27// newVerifier is a WebSessionService that accepts the session token valid, checked
28// by verify when it is not nil, and that does not know any login link. A
29// call that is not expected fails the test.
30func newVerifier(t *testing.T, valid string, verify func(token string) (bool, error)) *mocks.WebSessionService {
31 t.Helper()
32 if verify == nil {
33 verify = func(token string) (bool, error) { return token == valid, nil }
34 }
35 m := mocks.NewWebSessionService(t)
36 m.EXPECT().Verify(mock.Anything, mock.Anything).RunAndReturn(
37 func(_ context.Context, token string) (domain.Principal, bool, error) {
38 ok, err := verify(token)
39 if !ok || err != nil {
40 return domain.Anonymous(), false, err
41 }
42 return domain.Owner(), true, nil
43 }).Maybe()
44 return m
45}
46
47// expectLogin makes nonceA the one link of m, answering with the token valid.
48func expectLogin(m *mocks.WebSessionService, valid string) {
49 m.EXPECT().LoginLinkValid(mock.Anything).RunAndReturn(func(n string) bool { return n == nonceA }).Maybe()
50 m.EXPECT().Login(mock.Anything, nonceA).Return(valid, time.Hour, nil).Maybe()
51 m.EXPECT().Login(mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Maybe()
52}
53
54// newSessions is a WebSessionService with one session token and one login link.
55func newSessions(t *testing.T, valid string) *mocks.WebSessionService {
56 t.Helper()
57 m := newVerifier(t, valid, nil)
58 expectLogin(m, valid)
59 return m
60}
61
62// newBrokenSessions is a WebSessionService whose check cannot be made.
63func newBrokenSessions(t *testing.T, valid string) *mocks.WebSessionService {
64 t.Helper()
65 m := newVerifier(t, valid, func(string) (bool, error) { return false, errors.New("kv down") })
66 expectLogin(m, valid)
67 return m
68}
69
70type countingLimiter struct{ fails int }
71
72func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 }
73func (l *countingLimiter) Fail(context.Context, string) { l.fails++ }
74
75func newSessionServer(t *testing.T, sess ports.WebSessionService, publicURL string) (*Server, *countingLimiter) {
76 t.Helper()
77 lim := &countingLimiter{}
78 srv := newTestServer(t, Config{
79 Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
80 PublicURL: publicURL, Sessions: sess, Limiter: lim,
81 })
82 return srv, lim
83}
84
85func TestOriginOf(t *testing.T) {
86 t.Parallel()
87 for in, want := range map[string]string{
88 "https://forge.example": "https://forge.example",
89 "https://Forge.Example/": "https://forge.example",
90 "https://forge.example:443": "https://forge.example",
91 "http://forge.example:80": "http://forge.example",
92 "http://forge.example:8080": "http://forge.example:8080",
93 "https://forge.example:8443": "https://forge.example:8443",
94 "http://127.0.0.1:3000": "http://127.0.0.1:3000",
95 "http://[::1]:3000": "http://[::1]:3000",
96 "https://forge.example/sub/dir": "https://forge.example",
97 } {
98 u, err := url.Parse(in)
99 require.NoError(t, err)
100 assert.Equal(t, want, originOf(u), in)
101 }
102}
103
104func TestValidNonce(t *testing.T) {
105 t.Parallel()
106 assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a"))
107 for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} {
108 assert.False(t, validNonce(n), n)
109 }
110}
111
112// The gate: a POST needs the Origin of the public URL, whatever Host says, and
113// a session unless it is the login.
114func TestGate(t *testing.T) {
115 t.Parallel()
116 srv, lim := newSessionServer(t, newSessions(t, "tok"), "https://forge.example")
117 do := func(path, origin, cookie string) int {
118 req := httptest.NewRequest(http.MethodPost, path, nil)
119 req.Host = "forge.example"
120 if origin != "" {
121 req.Header.Set("Origin", origin)
122 }
123 if cookie != "" {
124 req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
125 }
126 rec := httptest.NewRecorder()
127 srv.ServeHTTP(rec, req)
128 return rec.Code
129 }
130 link := "/-/login/" + strings.Repeat("a", 43)
131
132 assert.Equal(t, 403, do("/-/logout", "", "tok"), "no Origin")
133 assert.Equal(t, 403, do("/-/logout", "https://evil.example", "tok"))
134 assert.Equal(t, 403, do("/-/logout", "https://forge.example.evil.example", "tok"))
135 assert.Equal(t, 403, do("/-/logout", "http://forge.example", "tok"), "other scheme")
136 assert.Equal(t, 403, do("/-/logout", "null", "tok"))
137 assert.Equal(t, 403, do("/-/logout", "https://forge.example", ""), "no session")
138 assert.Equal(t, 403, do("/-/logout", "https://forge.example", "stale"), "bad session")
139 assert.Equal(t, 303, do("/-/logout", "https://forge.example", "tok"))
140 assert.Equal(t, 403, do("/anything", "https://forge.example", ""), "every POST is gated")
141
142 assert.Equal(t, 403, do(link, "", ""), "login without Origin")
143 assert.Equal(t, 403, do(link, "https://evil.example", ""))
144 assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session")
145 assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", ""))
146 assert.Equal(t, 1, lim.fails, "one failed login counted")
147 assert.Equal(t, 403, do("/-/login/x", "https://forge.example", ""), "a malformed link is not a login")
148}
149
150// GO-001: the exemption of the login POST is decided on the escaped path, the
151// one the router matches, and on exactly loginPrefix + nonce. An encoded slash
152// must not turn another route into a "login".
153func TestGateLoginExemptionIsExact(t *testing.T) {
154 t.Parallel()
155 sess := newVerifier(t, "tok", nil) // no Login is expected until the last request
156 lim := &countingLimiter{}
157 srv := newTestServer(t, Config{
158 Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
159 MergeRequests: mocks.NewMergeRequestService(t),
160 Sessions: sess, Limiter: lim,
161 })
162 post := func(target, cookie string) int {
163 req := httptest.NewRequest(http.MethodPost, target, nil)
164 req.Header.Set("Origin", "https://forge.example")
165 if cookie != "" {
166 req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
167 }
168 rec := httptest.NewRecorder()
169 srv.ServeHTTP(rec, req)
170 return rec.Code
171 }
172 nonce := nonceA
173 for _, target := range []string{
174 "/-%2Flogin%2Fx/merge-requests/1/close",
175 "/-%2flogin%2fx/merge-requests/1/close",
176 "/-%2Flogin%2F" + nonce + "/merge-requests/1/close",
177 "/-/login%2F" + nonce,
178 "/-/login/" + nonce + "/merge-requests/1/close",
179 "/-/login/" + nonce + "/",
180 "/-/login/" + nonce + "%2Fx",
181 "/-/login/" + nonce + "%2F..%2Fx",
182 "/-/login/%2e%2e/x/merge-requests/1/close",
183 "/-/login/x/../../x/merge-requests/1/close",
184 "/-/login/",
185 "/-/login/%61" + nonce[1:],
186 "/-/login//" + nonce,
187 "/%2D/login/" + nonce,
188 } {
189 assert.Equal(t, http.StatusForbidden, post(target, ""), target)
190 }
191 // No Login was expected so far: the mock fails the test on any call.
192 sess.AssertNotCalled(t, "Login", mock.Anything, mock.Anything)
193
194 sess.EXPECT().Login(mock.Anything, nonce).Return("tok", time.Hour, nil).Once()
195 assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in")
196}
197
198// A session check that cannot be made fails closed (anonymous) and keeps the
199// cookie; a check that says no clears it.
200func TestWithSessionFailsClosed(t *testing.T) {
201 t.Parallel()
202 broken := newBrokenSessions(t, "tok")
203 srv, _ := newSessionServer(t, broken, "https://forge.example")
204 req := httptest.NewRequest(http.MethodGet, "/", nil)
205 req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
206 rec := httptest.NewRecorder()
207 r := srv.withSession(rec, req)
208 assert.False(t, SignedIn(r.Context()))
209 assert.Equal(t, domain.Anonymous(), principal(r.Context()))
210 assert.Empty(t, rec.Header().Values("Set-Cookie"))
211
212 srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example")
213 req = httptest.NewRequest(http.MethodGet, "/", nil)
214 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
215 rec = httptest.NewRecorder()
216 r = srv.withSession(rec, req)
217 assert.False(t, SignedIn(r.Context()))
218 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
219
220 req = httptest.NewRequest(http.MethodGet, "/", nil)
221 req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
222 r = srv.withSession(httptest.NewRecorder(), req)
223 assert.True(t, SignedIn(r.Context()))
224 assert.Equal(t, domain.Owner(), principal(r.Context()), "a valid session is the owner")
225 assert.Equal(t, domain.Anonymous(), principal(context.Background()), "no principal is anonymous")
226}
227
228// GO-006: the answer to a request with an invalid cookie is not cached and
229// varies on Cookie, and nothing later in the request undoes it: the page
230// writer, the raw file handler and the event stream keep it.
231func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
232 t.Parallel()
233 sess := newSessions(t, "tok")
234 srv, _ := newSessionServer(t, sess, "https://forge.example")
235
236 // withSession alone.
237 req := httptest.NewRequest(http.MethodGet, "/", nil)
238 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
239 rec := httptest.NewRecorder()
240 srv.withSession(rec, req)
241 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
242 assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"))
243 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
244
245 // A check that cannot be made keeps the cookie and the headers as they are.
246 broken, _ := newSessionServer(t, newBrokenSessions(t, "tok"), "https://forge.example")
247 rec = httptest.NewRecorder()
248 broken.withSession(rec, req)
249 assert.Empty(t, rec.Header().Values("Cache-Control"))
250 assert.Empty(t, rec.Header().Values("Vary"))
251
252 // No cookie, a valid cookie: untouched by withSession.
253 rec = httptest.NewRecorder()
254 srv.withSession(rec, httptest.NewRequest(http.MethodGet, "/", nil))
255 assert.Empty(t, rec.Header().Values("Cache-Control"))
256 assert.Empty(t, rec.Header().Values("Vary"))
257
258 // The page writer, a 404 and a 500 included, does not replace it.
259 for _, target := range []string{"/", "/nope/refs", "/x/tree/main"} {
260 req := httptest.NewRequest(http.MethodGet, target, nil)
261 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
262 rec := httptest.NewRecorder()
263 srv.ServeHTTP(rec, req)
264 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target)
265 assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target)
266 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target)
267 }
268
269 // The shared helper keeps no-store for a visitor, and replaces it for the
270 // owner (whose answer is private anyway).
271 h := http.Header{"Cache-Control": {"no-store"}, "Vary": {"Accept, cookie"}}
272 cacheHeaders(context.Background(), h, "no-cache", sessionCache)
273 assert.Equal(t, []string{"no-store"}, h.Values("Cache-Control"))
274 assert.Equal(t, []string{"Accept, cookie"}, h.Values("Vary"), "no second Vary: Cookie")
275 owner := context.WithValue(context.Background(), signedInKey{}, struct{}{})
276 cacheHeaders(owner, h, "no-cache", sessionCache)
277 assert.Equal(t, []string{sessionCache}, h.Values("Cache-Control"))
278
279 // Static assets are not touched by sessions at all, cookie or not.
280 req = httptest.NewRequest(http.MethodGet, StaticPrefix+"nope", nil)
281 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
282 rec = httptest.NewRecorder()
283 srv.ServeHTTP(rec, req)
284 assert.Empty(t, rec.Header().Values("Set-Cookie"))
285 assert.Empty(t, rec.Header().Values("Vary"))
286}
287
288// Without sessions configured there is no login route and no cookie is read.
289func TestNoSessionsNoRoutes(t *testing.T) {
290 t.Parallel()
291 srv := newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)})
292 rec := httptest.NewRecorder()
293 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil))
294 assert.Equal(t, 404, rec.Code)
295
296 _, err := New(Config{
297 Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
298 PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t),
299 })
300 assert.Error(t, err, "sessions need a limiter")
301}
302
303func TestLogPathHidesLinks(t *testing.T) {
304 t.Parallel()
305 assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
306 assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
307 for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} {
308 assert.Equal(t, "/-/login/…", logPath(p), p)
309 }
310}