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/require"
17
18 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19)
20
21// fakeSessions accepts one token.
22type fakeSessions struct {
23 valid string
24 err error
25 used []string
26}
27
28func (f *fakeSessions) LoginLinkValid(n string) bool { return n == strings.Repeat("a", 43) }
29
30func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) {
31 f.used = append(f.used, n)
32 if n != strings.Repeat("a", 43) {
33 return "", 0, ErrLinkRejected
34 }
35 return f.valid, time.Hour, nil
36}
37
38func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) {
39 return ctx, f.err == nil && tok == f.valid, f.err
40}
41
42type countingLimiter struct{ fails int }
43
44func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 }
45func (l *countingLimiter) Fail(context.Context, string) { l.fails++ }
46
47func newSessionServer(t *testing.T, sess SessionAuth, publicURL string) (*Server, *countingLimiter) {
48 t.Helper()
49 lim := &countingLimiter{}
50 srv, err := New(Config{
51 Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
52 PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1",
53 Sessions: sess, Limiter: lim,
54 })
55 require.NoError(t, err)
56 return srv, lim
57}
58
59func TestOriginOf(t *testing.T) {
60 for in, want := range map[string]string{
61 "https://forge.example": "https://forge.example",
62 "https://Forge.Example/": "https://forge.example",
63 "https://forge.example:443": "https://forge.example",
64 "http://forge.example:80": "http://forge.example",
65 "http://forge.example:8080": "http://forge.example:8080",
66 "https://forge.example:8443": "https://forge.example:8443",
67 "http://127.0.0.1:3000": "http://127.0.0.1:3000",
68 "http://[::1]:3000": "http://[::1]:3000",
69 "https://forge.example/sub/dir": "https://forge.example",
70 } {
71 u, err := url.Parse(in)
72 require.NoError(t, err)
73 assert.Equal(t, want, originOf(u), in)
74 }
75}
76
77func TestValidNonce(t *testing.T) {
78 assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a"))
79 for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} {
80 assert.False(t, validNonce(n), n)
81 }
82}
83
84// The gate: a POST needs the Origin of the public URL, whatever Host says, and
85// a session unless it is the login.
86func TestGate(t *testing.T) {
87 srv, lim := newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example")
88 do := func(path, origin, cookie string) int {
89 req := httptest.NewRequest(http.MethodPost, path, nil)
90 req.Host = "forge.example"
91 if origin != "" {
92 req.Header.Set("Origin", origin)
93 }
94 if cookie != "" {
95 req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
96 }
97 rec := httptest.NewRecorder()
98 srv.ServeHTTP(rec, req)
99 return rec.Code
100 }
101 link := "/-/login/" + strings.Repeat("a", 43)
102
103 assert.Equal(t, 403, do("/-/logout", "", "tok"), "no Origin")
104 assert.Equal(t, 403, do("/-/logout", "https://evil.example", "tok"))
105 assert.Equal(t, 403, do("/-/logout", "https://forge.example.evil.example", "tok"))
106 assert.Equal(t, 403, do("/-/logout", "http://forge.example", "tok"), "other scheme")
107 assert.Equal(t, 403, do("/-/logout", "null", "tok"))
108 assert.Equal(t, 403, do("/-/logout", "https://forge.example", ""), "no session")
109 assert.Equal(t, 403, do("/-/logout", "https://forge.example", "stale"), "bad session")
110 assert.Equal(t, 303, do("/-/logout", "https://forge.example", "tok"))
111 assert.Equal(t, 403, do("/anything", "https://forge.example", ""), "every POST is gated")
112
113 assert.Equal(t, 403, do(link, "", ""), "login without Origin")
114 assert.Equal(t, 403, do(link, "https://evil.example", ""))
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", ""))
117 assert.Equal(t, 1, lim.fails, "one failed login counted")
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".
124func 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)
166}
167
168// A session check that cannot be made fails closed (anonymous) and keeps the
169// cookie; a check that says no clears it.
170func TestWithSessionFailsClosed(t *testing.T) {
171 broken := &fakeSessions{valid: "tok", err: errors.New("kv down")}
172 srv, _ := newSessionServer(t, broken, "https://forge.example")
173 req := httptest.NewRequest(http.MethodGet, "/", nil)
174 req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
175 rec := httptest.NewRecorder()
176 r := srv.withSession(rec, req)
177 assert.False(t, SignedIn(r.Context()))
178 assert.Empty(t, rec.Header().Values("Set-Cookie"))
179
180 srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example")
181 req = httptest.NewRequest(http.MethodGet, "/", nil)
182 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
183 rec = httptest.NewRecorder()
184 r = srv.withSession(rec, req)
185 assert.False(t, SignedIn(r.Context()))
186 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
187
188 req = httptest.NewRequest(http.MethodGet, "/", nil)
189 req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
190 assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context()))
191}
192
193// GO-006: the answer to a request with an invalid cookie is not cached and
194// varies on Cookie, and nothing later in the request undoes it: the page
195// writer, the raw file handler and the event stream keep it.
196func TestInvalidCookieAnswerIsNotCached(t *testing.T) {
197 sess := &fakeSessions{valid: "tok"}
198 srv, _ := newSessionServer(t, sess, "https://forge.example")
199
200 // withSession alone.
201 req := httptest.NewRequest(http.MethodGet, "/", nil)
202 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
203 rec := httptest.NewRecorder()
204 srv.withSession(rec, req)
205 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
206 assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"))
207 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0")
208
209 // A check that cannot be made keeps the cookie and the headers as they are.
210 broken, _ := newSessionServer(t, &fakeSessions{valid: "tok", err: errors.New("kv down")}, "https://forge.example")
211 rec = httptest.NewRecorder()
212 broken.withSession(rec, req)
213 assert.Empty(t, rec.Header().Values("Cache-Control"))
214 assert.Empty(t, rec.Header().Values("Vary"))
215
216 // No cookie, a valid cookie: untouched by withSession.
217 rec = httptest.NewRecorder()
218 srv.withSession(rec, httptest.NewRequest(http.MethodGet, "/", nil))
219 assert.Empty(t, rec.Header().Values("Cache-Control"))
220 assert.Empty(t, rec.Header().Values("Vary"))
221
222 // The page writer, a 404 and a 500 included, does not replace it.
223 for _, target := range []string{"/", "/nope/refs", "/x/tree/main"} {
224 req := httptest.NewRequest(http.MethodGet, target, nil)
225 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
226 rec := httptest.NewRecorder()
227 srv.ServeHTTP(rec, req)
228 assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target)
229 assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"), target)
230 assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target)
231 }
232
233 // The shared helper keeps no-store for a visitor, and replaces it for the
234 // owner (whose answer is private anyway).
235 h := http.Header{"Cache-Control": {"no-store"}, "Vary": {"Accept, cookie"}}
236 cacheHeaders(context.Background(), h, "no-cache", sessionCache)
237 assert.Equal(t, []string{"no-store"}, h.Values("Cache-Control"))
238 assert.Equal(t, []string{"Accept, cookie"}, h.Values("Vary"), "no second Vary: Cookie")
239 owner := context.WithValue(context.Background(), signedInKey{}, struct{}{})
240 cacheHeaders(owner, h, "no-cache", sessionCache)
241 assert.Equal(t, []string{sessionCache}, h.Values("Cache-Control"))
242
243 // Static assets are not touched by sessions at all, cookie or not.
244 req = httptest.NewRequest(http.MethodGet, StaticPrefix+"nope", nil)
245 req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"})
246 rec = httptest.NewRecorder()
247 srv.ServeHTTP(rec, req)
248 assert.Empty(t, rec.Header().Values("Set-Cookie"))
249 assert.Empty(t, rec.Header().Values("Vary"))
250}
251
252// Without sessions configured there is no login route and no cookie is read.
253func TestNoSessionsNoRoutes(t *testing.T) {
254 srv, err := New(Config{
255 Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
256 PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
257 })
258 require.NoError(t, err)
259 rec := httptest.NewRecorder()
260 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil))
261 assert.Equal(t, 404, rec.Code)
262
263 _, err = New(Config{
264 Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"),
265 PublicURL: "https://forge.example", Sessions: &fakeSessions{},
266 })
267 assert.Error(t, err, "sessions need a limiter")
268}
269
270func TestLogPathHidesLinks(t *testing.T) {
271 assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43)))
272 assert.Equal(t, "/demo/tree", logPath("/demo/tree"))
273 for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} {
274 assert.Equal(t, "/-/login/…", logPath(p), p)
275 }
276}