1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "io"
8 "net/http"
9 "net/netip"
10 "net/url"
11 "strings"
12 "testing"
13 "time"
14
15 "connectrpc.com/connect"
16 "github.com/bnema/zerowrap"
17 "github.com/golang-jwt/jwt/v5"
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20
21 pb "bnema.dev/ermite-api/gen/ermite"
22 "bnema.dev/ermite/internal/domain"
23 "bnema.dev/ermite/internal/ports"
24)
25
26const (
27 publicOrigin = "http://example.test" // ERMITE_PUBLIC_URL of the harness
28 sessionCookie = "__Host-ermite"
29)
30
31// page is the answer of a request to the web.
32type page struct {
33 status int
34 hdr http.Header
35 body string
36}
37
38func (p page) cookie(name string) *http.Cookie {
39 for _, c := range (&http.Response{Header: p.hdr}).Cookies() {
40 if c.Name == name {
41 return c
42 }
43 }
44 return nil
45}
46
47// browser is a bare HTTP client: it follows no redirect and keeps no cookie,
48// so that the tests say exactly what the browser sends.
49type browser struct {
50 h *harness
51}
52
53func (b browser) do(method, path string, hdr map[string]string, body string) page {
54 b.h.t.Helper()
55 req, err := http.NewRequest(method, b.h.url(path), strings.NewReader(body))
56 require.NoError(b.h.t, err)
57 for k, v := range hdr {
58 req.Header.Set(k, v)
59 }
60 if body != "" {
61 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
62 }
63 resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req)
64 require.NoError(b.h.t, err)
65 defer func() { _ = resp.Body.Close() }()
66 out, err := io.ReadAll(resp.Body)
67 require.NoError(b.h.t, err)
68 return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
69}
70
71func (b browser) get(path string, hdr map[string]string) page { return b.do("GET", path, hdr, "") }
72
73// as is the header map of a browser holding the session token.
74func as(token string) map[string]string {
75 return map[string]string{"Cookie": sessionCookie + "=" + token}
76}
77
78func post(token string) map[string]string {
79 h := as(token)
80 h["Origin"] = publicOrigin
81 return h
82}
83
84// loginPath turns the link the CLI printed into a path on the test server.
85func loginPath(t *testing.T, link string) string {
86 t.Helper()
87 u, err := url.Parse(strings.TrimSpace(link))
88 require.NoError(t, err)
89 assert.Equal(t, publicOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL")
90 assert.True(t, strings.HasPrefix(u.Path, "/-/login/"), u.Path)
91 return u.Path
92}
93
94// signIn runs `ermite web login` and confirms the link: it returns the token
95// of the cookie the browser would hold.
96func (c *cliHarness) signIn(t *testing.T) string {
97 t.Helper()
98 res := c.cli(t, adminToken, "web", "login").ok(t)
99 p := browser{c.harness}.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "")
100 require.Equal(t, http.StatusSeeOther, p.status, p.body)
101 ck := p.cookie(sessionCookie)
102 require.NotNil(t, ck)
103 return ck.Value
104}
105
106// AC1, AC2, AC7 (the session part): the whole path of the owner, from the CLI
107// link to the private page and back out.
108func TestWebLoginEndToEnd(t *testing.T) {
109 c := newCLIHarness(t)
110 c.seed("secret", domain.VisibilityPrivate, "notes.txt")
111 c.seed("pub", domain.VisibilityPublic, "a.txt")
112 b := browser{c.harness}
113
114 // Anonymous: the private repository is invisible; public caches as before.
115 home := b.get("/", nil)
116 assert.Contains(t, home.body, "pub")
117 assert.NotContains(t, home.body, "secret")
118 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
119 assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page")
120 assert.NotContains(t, home.body, `action="/-/logout"`)
121 assert.Equal(t, 404, b.get("/secret", nil).status)
122
123 // The CLI asks for a link and prints it; stderr says when it ends.
124 res := c.cli(t, adminToken, "web", "login").ok(t)
125 assert.Contains(t, res.stderr, "works once")
126 path := loginPath(t, res.stdout)
127
128 // GET shows the confirmation and never consumes the link.
129 for range 3 {
130 p := b.get(path, nil)
131 assert.Equal(t, 200, p.status)
132 assert.Equal(t, "no-store", p.hdr.Get("Cache-Control"))
133 // Only the origin leaves the page; no-referrer would make the browser
134 // send Origin: null on the sign-in POST.
135 assert.Equal(t, "strict-origin", p.hdr.Get("Referrer-Policy"))
136 assert.Contains(t, p.body, `method="post"`)
137 assert.Contains(t, p.body, `action="`+path+`"`)
138 assert.Nil(t, p.cookie(sessionCookie), "a GET sets no session")
139 assertNoInline(t, p.body)
140 }
141 // A POST that is not from this site is refused and does not consume it.
142 assert.Equal(t, 403, b.do("POST", path, nil, "").status, "no Origin")
143 assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "https://evil.example"}, "").status, "foreign Origin")
144 assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "null"}, "").status, "opaque Origin")
145 assert.Equal(t, 200, b.get(path, nil).status, "still pending")
146
147 // The POST consumes it and sets the cookie.
148 login := b.do("POST", path, map[string]string{"Origin": publicOrigin}, "")
149 require.Equal(t, http.StatusSeeOther, login.status, login.body)
150 assert.Equal(t, "/", login.hdr.Get("Location"))
151 assert.Equal(t, "no-store", login.hdr.Get("Cache-Control"))
152 ck := login.cookie(sessionCookie)
153 require.NotNil(t, ck)
154 assert.True(t, ck.Secure && ck.HttpOnly)
155 assert.Equal(t, http.SameSiteStrictMode, ck.SameSite)
156 assert.Equal(t, "/", ck.Path)
157 assert.Empty(t, ck.Domain, "no Domain: host only")
158 assert.Equal(t, 12*60*60, ck.MaxAge)
159 token := ck.Value
160 claims := jwt.MapClaims{}
161 _, _, err := jwt.NewParser().ParseUnverified(token, claims)
162 require.NoError(t, err)
163 assert.Equal(t, "owner", claims["sub"])
164 assert.Equal(t, float64(0), claims["epoch"])
165 exp, err := claims.GetExpirationTime()
166 require.NoError(t, err)
167 assert.WithinDuration(t, time.Now().Add(12*time.Hour), exp.Time, time.Minute)
168 assert.NotNil(t, claims["iat"])
169
170 // Replayed and unknown links fail with the same page.
171 for _, p := range []string{path, "/-/login/" + strings.Repeat("a", 43), "/-/login/short"} {
172 g := b.get(p, nil)
173 assert.Equal(t, 404, g.status, p)
174 assert.Equal(t, "no-store", g.hdr.Get("Cache-Control"))
175 assert.Contains(t, g.body, "login link not valid")
176 r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "")
177 assert.Nil(t, r.cookie(sessionCookie))
178 if validNonceShape(p) {
179 assert.Equal(t, 404, r.status, p)
180 assert.Equal(t, g.body, r.body, "no detail tells why")
181 } else {
182 // Not even the shape of a link: the gate refuses it, as it does
183 // any POST without a session.
184 assert.Equal(t, 403, r.status, p)
185 }
186 }
187
188 // Signed in: private repositories show on every page, headers say private.
189 for _, p := range []string{"/", "/secret", "/secret/tree", "/secret/blob/main/notes.txt", "/secret/commits/main", "/secret/refs", "/nope"} {
190 got := b.get(p, as(token))
191 if p == "/nope" {
192 assert.Equal(t, 404, got.status)
193 } else {
194 assert.Equal(t, 200, got.status, p)
195 }
196 assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p)
197 assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p)
198 assertNoInline(t, got.body)
199 }
200 home = b.get("/", as(token))
201 assert.Contains(t, home.body, "secret")
202 assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`)
203 assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
204 raw := b.get("/secret/raw/main/notes.txt", as(token))
205 assert.Equal(t, 200, raw.status)
206 assert.Equal(t, "private, no-store", raw.hdr.Get("Cache-Control"))
207 assert.Equal(t, 404, b.get("/secret/raw/main/notes.txt", nil).status)
208 // Static assets stay public.
209 assert.Contains(t, b.get("/", as(token)).body, "/-/static/")
210
211 // The live stream of a private repository is the owner's.
212 assert.Equal(t, 404, b.get("/-/events?topic=repo:secret", nil).status)
213 sctx, stop := context.WithCancel(c.ctx)
214 defer stop()
215 sreq, err := http.NewRequestWithContext(sctx, http.MethodGet, c.harness.url("/-/events?topic=repo:secret"), nil)
216 require.NoError(t, err)
217 sreq.Header.Set("Cookie", sessionCookie+"="+token)
218 sres, err := http.DefaultClient.Do(sreq)
219 require.NoError(t, err)
220 assert.Equal(t, 200, sres.StatusCode)
221 assert.Equal(t, "private, no-store, no-transform", sres.Header.Get("Cache-Control"))
222 assert.Contains(t, sres.Header.Values("Vary"), "Cookie")
223 stop()
224 _ = sres.Body.Close()
225
226 // The browser routes ignore Authorization: the admin token shows nothing.
227 bearer := b.get("/", map[string]string{"Authorization": "Bearer " + adminToken})
228 assert.NotContains(t, bearer.body, "secret")
229 assert.Equal(t, "no-cache", bearer.hdr.Get("Cache-Control"))
230 assert.Equal(t, 404, b.get("/secret", map[string]string{"Authorization": "Bearer " + adminToken}).status)
231
232 // The API ignores the cookie: no bearer is anonymous, so nothing private
233 // and nothing that needs a caller.
234 api := c.api("", connect.WithInterceptors(cookieInterceptor(token)))
235 list, err := api.repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
236 require.NoError(t, err)
237 require.Len(t, list.Msg.GetRepos(), 1)
238 assert.Equal(t, "pub", list.Msg.GetRepos()[0].GetName())
239 _, err = api.repo.GetRepo(context.Background(), connect.NewRequest(&pb.GetRepoRequest{Repo: "secret"}))
240 assert.Equal(t, "not_found", codeName(err))
241 _, err = api.token.ListTokens(context.Background(), connect.NewRequest(&pb.ListTokensRequest{}))
242 assert.Equal(t, "unauthenticated", codeName(err))
243 _, err = api.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
244 assert.Equal(t, "unauthenticated", codeName(err))
245 _, err = api.session.SignOutEverywhere(context.Background(), connect.NewRequest(&pb.SignOutEverywhereRequest{}))
246 assert.Equal(t, "unauthenticated", codeName(err))
247
248 // Logout: a POST from this site with the session; the cookie is deleted.
249 assert.Equal(t, 403, b.do("POST", "/-/logout", as(token), "").status, "no Origin")
250 assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": "https://evil.example", "Cookie": sessionCookie + "=" + token}, "").status)
251 assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": publicOrigin}, "").status, "no session")
252 out := b.do("POST", "/-/logout", post(token), "")
253 assert.Equal(t, http.StatusSeeOther, out.status)
254 assert.Equal(t, "/", out.hdr.Get("Location"))
255 gone := out.cookie(sessionCookie)
256 require.NotNil(t, gone)
257 assert.Negative(t, gone.MaxAge)
258 assert.Empty(t, gone.Value)
259 assert.Equal(t, 404, b.get("/secret", nil).status)
260 // A GET never signs out.
261 g := b.get("/-/logout", as(token))
262 assert.Equal(t, 404, g.status, "no page")
263 assert.Nil(t, g.cookie(sessionCookie), "the session is untouched")
264 assert.Equal(t, 200, b.get("/secret", as(token)).status, "the token itself lives until logout --all or expiry")
265
266 // logout --all: the cookie is rejected and cleared; anonymous again.
267 c.cli(t, adminToken, "web", "logout", "--all").ok(t)
268 after := b.get("/secret", as(token))
269 assert.Equal(t, 404, after.status)
270 assert.Equal(t, "no-store", after.hdr.Get("Cache-Control"), "a rejected cookie makes the answer uncacheable")
271 assert.Contains(t, after.hdr.Values("Vary"), "Cookie")
272 cleared := after.cookie(sessionCookie)
273 require.NotNil(t, cleared, "a rejected cookie is cleared")
274 assert.Negative(t, cleared.MaxAge)
275 assert.NotContains(t, b.get("/", as(token)).body, "secret")
276 assert.Equal(t, 403, b.do("POST", "/-/logout", post(token), "").status, "POST needs a valid session")
277
278 // A new link gives a session of the new epoch.
279 fresh := c.signIn(t)
280 assert.Equal(t, 200, b.get("/secret", as(fresh)).status)
281 assert.Equal(t, 404, b.get("/secret", as(token)).status)
282}
283
284// validNonceShape tells whether a login path ends in 43 characters of base64url.
285func validNonceShape(p string) bool {
286 return len(strings.TrimPrefix(p, "/-/login/")) == 43
287}
288
289// tokenID finds the id of the stored token with that secret, by listing them
290// as root and matching the hash.
291func (c *cliHarness) tokenID(t *testing.T, secret string) string {
292 t.Helper()
293 all, err := c.forge.Tokens.List(c.ctx, domain.Root())
294 require.NoError(t, err)
295 for _, tok := range all {
296 if tok.Hash == domain.HashTokenSecret(secret) {
297 return tok.ID
298 }
299 }
300 t.Fatal("token not found")
301 return ""
302}
303
304// GO-003: a session opened with a link made by an expiring token ends with the
305// token, and revoking a token with owner rights signs every browser out; any
306// other token leaves the sessions alone.
307func TestWebRevokingAnOwnerTokenSignsEveryoneOut(t *testing.T) {
308 c := newCLIHarness(t)
309 c.seed("secret", domain.VisibilityPrivate, "notes.txt")
310 b := browser{c.harness}
311
312 // The cap: a link made by a token that expires in an hour gives a cookie,
313 // and a session, of an hour.
314 soon, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{
315 Label: "soon", Scopes: domain.AllScopes(), ExpiresAt: time.Now().Add(time.Hour),
316 })
317 require.NoError(t, err)
318 res := c.cli(t, soon.Secret, "web", "login").ok(t)
319 login := b.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "")
320 require.Equal(t, http.StatusSeeOther, login.status, login.body)
321 ck := login.cookie(sessionCookie)
322 require.NotNil(t, ck)
323 assert.InDelta(t, 3600, ck.MaxAge, 5, "the cookie lasts as long as the token")
324 claims := jwt.MapClaims{}
325 _, _, err = jwt.NewParser().ParseUnverified(ck.Value, claims)
326 require.NoError(t, err)
327 exp, err := claims.GetExpirationTime()
328 require.NoError(t, err)
329 assert.WithinDuration(t, soon.Token.ExpiresAt, exp.Time, 2*time.Second)
330
331 // Revoking a token that cannot own the forge keeps the sessions.
332 session := c.signIn(t)
333 partial := c.token(domain.AllScopes()[:8])
334 c.cli(t, adminToken, "token", "revoke", c.tokenID(t, partial)).ok(t)
335 assert.Equal(t, 200, b.get("/secret", as(session)).status)
336 assert.Equal(t, 200, b.get("/secret", as(ck.Value)).status)
337
338 // Revoking one that can signs everyone out, whatever token made the link.
339 res = c.cli(t, adminToken, "token", "revoke", c.tokenID(t, soon.Secret)).ok(t)
340 assert.Contains(t, res.stdout, "revoked")
341 assert.Equal(t, 404, b.get("/secret", as(session)).status)
342 assert.Equal(t, 404, b.get("/secret", as(ck.Value)).status)
343
344 // The help says so.
345 help := c.cli(t, adminToken, "token", "revoke", "--help").ok(t)
346 assert.Contains(t, help.stdout+help.stderr, "signs every browser out")
347}
348
349// cookieInterceptor makes a Connect client send the session cookie, as a
350// browser-made call would, to show the API pays no attention to it.
351func cookieInterceptor(token string) connect.Interceptor {
352 return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
353 return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
354 req.Header().Set("Cookie", sessionCookie+"="+token)
355 return next(ctx, req)
356 }
357 })
358}
359
360// The cookie must be an unforgeable HS256 token for the key of the server.
361func TestWebRejectsForgedSessions(t *testing.T) {
362 c := newCLIHarness(t)
363 c.seed("secret", domain.VisibilityPrivate, "notes.txt")
364 b := browser{c.harness}
365 good := c.signIn(t)
366 require.Equal(t, 200, b.get("/secret", as(good)).status)
367
368 now := time.Now()
369 claims := jwt.MapClaims{"sub": "owner", "epoch": 0, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()}
370 none, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType)
371 require.NoError(t, err)
372 wrongKey, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("not the key of the server........"))
373 require.NoError(t, err)
374 wrongAlg, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte("not the key of the server........"))
375 require.NoError(t, err)
376 for name, tok := range map[string]string{
377 "alg none": none, "wrong key": wrongKey, "wrong alg": wrongAlg, "garbage": "x.y.z", "truncated": good[:len(good)-2],
378 } {
379 p := b.get("/secret", as(tok))
380 assert.Equal(t, 404, p.status, name)
381 assert.Equal(t, "no-store", p.hdr.Get("Cache-Control"), name)
382 assert.Contains(t, p.hdr.Values("Vary"), "Cookie", name)
383 ck := p.cookie(sessionCookie)
384 require.NotNil(t, ck, name)
385 assert.Negative(t, ck.MaxAge, name)
386 assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name)
387 }
388}
389
390// Failed logins count in the limiter of failed authentications, like a bad
391// token on the API: the eleventh attempt of one client is refused, and a valid
392// link of that client is too.
393func TestWebLoginFailuresAreRateLimited(t *testing.T) {
394 h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
395 c := &cliHarness{harness: h, url: h.srv.URL}
396 b := browser{c.harness}
397 from := func(ip string) map[string]string {
398 return map[string]string{"Origin": publicOrigin, "X-Forwarded-For": ip}
399 }
400 bad := "/-/login/" + strings.Repeat("b", 43)
401 for range 10 {
402 assert.Equal(t, 404, b.do("POST", bad, from("203.0.113.9"), "").status)
403 }
404 blocked := b.do("POST", bad, from("203.0.113.9"), "")
405 assert.Equal(t, http.StatusTooManyRequests, blocked.status)
406 assert.NotEmpty(t, blocked.hdr.Get("Retry-After"))
407
408 link := c.cli(t, adminToken, "web", "login").ok(t).stdout
409 assert.Equal(t, http.StatusTooManyRequests, b.do("POST", loginPath(t, link), from("203.0.113.9"), "").status, "the budget is spent")
410 assert.Equal(t, http.StatusSeeOther, b.do("POST", loginPath(t, link), from("203.0.113.10"), "").status, "another client is not affected")
411 // The same budget as the API's: a bad token from this client is refused.
412 _, err := c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
413 require.NoError(t, err, "GetVersion needs no authentication")
414 _, err = c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{}))
415 assert.Equal(t, "resource_exhausted", codeName(err))
416}
417
418func forwardedFor(ip string) connect.Interceptor {
419 return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
420 return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
421 req.Header().Set("X-Forwarded-For", ip)
422 return next(ctx, req)
423 }
424 })
425}
426
427// Only the owner creates links: the admin token, or a token with every scope
428// and no repository list.
429func TestSessionServiceIsOwnerOnly(t *testing.T) {
430 c := newCLIHarness(t)
431 ctx := context.Background()
432 c.seed("a", domain.VisibilityPublic)
433 partial := c.token(domain.AllScopes()[:8])
434 limited := c.token(domain.AllScopes(), "a")
435 full := c.token(domain.AllScopes())
436
437 for name, tc := range map[string]struct{ secret, code string }{
438 "anonymous": {"", "unauthenticated"},
439 "bad token": {"nope", "unauthenticated"},
440 "missing scope": {partial, "permission_denied"},
441 "repo list": {limited, "permission_denied"},
442 "every scope": {full, "ok"},
443 "admin token": {adminToken, "ok"},
444 } {
445 a := c.api(tc.secret)
446 _, err := a.session.CreateLoginLink(ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
447 assert.Equal(t, tc.code, codeName(err), name)
448 _, err = a.session.SignOutEverywhere(ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
449 if tc.code == "unauthenticated" {
450 assert.Equal(t, tc.code, codeName(err), name)
451 } else {
452 assert.Equal(t, tc.code, codeName(err), name)
453 }
454 }
455
456 res := c.cli(t, partial, "web", "login")
457 require.Error(t, res.err)
458 assert.Contains(t, res.err.Error(), "permission denied")
459 assert.Empty(t, res.stdout)
460 res = c.cli(t, adminToken, "web", "logout")
461 require.Error(t, res.err, "--all is required")
462 res = c.cli(t, adminToken, "web", "login", "--json").ok(t)
463 assert.ElementsMatch(t, []string{"expires_at", "url"}, keys(t, res.stdout))
464}
465
466// Login links are bounded in number.
467func TestLoginLinksAreBoundedOverTheAPI(t *testing.T) {
468 c := newCLIHarness(t)
469 a := c.api(adminToken)
470 var code string
471 for range 20 {
472 _, err := a.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{}))
473 if code = codeName(err); code != "ok" {
474 break
475 }
476 }
477 assert.Equal(t, "resource_exhausted", code)
478}
479
480// A link, a session token and the signing key are secrets: no log line holds
481// them, at the most verbose level.
482func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) {
483 c := newCLIHarness(t)
484 c.seed("secret", domain.VisibilityPrivate, "notes.txt")
485 b := browser{c.harness}
486
487 link := strings.TrimSpace(c.cli(t, adminToken, "web", "login").ok(t).stdout)
488 nonce := link[strings.LastIndex(link, "/")+1:]
489 require.Len(t, nonce, 43)
490 get := b.get(loginPath(t, link), nil)
491 require.Equal(t, 200, get.status)
492 p := b.do("POST", loginPath(t, link), map[string]string{"Origin": publicOrigin}, "")
493 token := p.cookie(sessionCookie).Value
494 b.get("/secret", as(token))
495 b.get("/-/login/"+strings.Repeat("z", 43), nil)
496 b.do("POST", "/-/login/"+strings.Repeat("z", 43), map[string]string{"Origin": publicOrigin}, "")
497
498 logs := c.logs.String()
499 assert.NotContains(t, logs, nonce)
500 assert.NotContains(t, logs, token)
501 assert.NotContains(t, logs, strings.Repeat("z", 43))
502 assert.Contains(t, logs, "login link created")
503 assert.Contains(t, logs, "owner signed in on the web")
504 key, err := c.forge.store.Sessions().Key(context.Background())
505 require.NoError(t, err)
506 assert.NotContains(t, logs, string(key))
507}
508
509// The owner principal is what the use cases see from a signed-in page, and is
510// labelled "owner": the use case logs it with a write the page made.
511func TestWebOwnerIsLabelledInLogs(t *testing.T) {
512 r := newMRRepo(t, domain.VisibilityPublic)
513 token := r.signIn(t)
514 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
515 b := browser{r.harness}
516 res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"from the page"}})
517 require.Equal(t, http.StatusSeeOther, res.status, res.body)
518 var line string
519 for _, l := range strings.Split(r.logs.String(), "\n") {
520 if strings.Contains(l, `"comment added"`) {
521 line = l
522 }
523 }
524 assert.Contains(t, line, `"principal":"owner"`)
525}
526
527// GO-004: a Secure cookie is kept by browsers only from https (or a loopback
528// host), so an http public URL on another host cannot sign anyone in.
529func TestSessionsNeedHTTPS(t *testing.T) {
530 for in, want := range map[string]bool{
531 "http://forge.example": true,
532 "http://forge.example:8080": true,
533 "HTTP://Forge.Example": true,
534 "http://10.0.0.5:3000": true,
535 "http://192.168.1.2": true,
536 "http://[2001:db8::1]:3000": true,
537 "http://localhost.example": true,
538 "http://127.0.0.1.example": true,
539 "http://example.test": true,
540 "https://forge.example": false,
541 "https://localhost": false,
542 "http://localhost": false,
543 "http://LOCALHOST:3000": false,
544 "http://127.0.0.1:3000": false,
545 "http://127.1.2.3": false,
546 "http://[::1]:3000": false,
547 "http://[::ffff:127.0.0.1]:1": false,
548 "": false,
549 } {
550 assert.Equal(t, want, sessionsNeedHTTPS(in), in)
551 }
552}
553
554// The warning is logged once at startup, with the harness's http://example.test
555// as public URL, and the server starts all the same.
556func TestStartupWarnsWhenSignInCannotWork(t *testing.T) {
557 c := newCLIHarness(t)
558 logs := c.logs.String()
559 assert.Contains(t, logs, "browser sign-in cannot work")
560 assert.Contains(t, logs, `"level":"warn"`)
561
562 var buf syncBuffer
563 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf}))
564 warnSessionsNeedHTTPS(ctx, "https://forge.example")
565 warnSessionsNeedHTTPS(ctx, "http://127.0.0.1:3000")
566 assert.Empty(t, buf.String(), "no warning for https or loopback")
567 warnSessionsNeedHTTPS(ctx, "http://forge.example")
568 assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL")
569}
570
571func TestWarnSharedClientKey(t *testing.T) {
572 t.Parallel()
573 proxies := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
574 tests := []struct {
575 name string
576 trusted []netip.Prefix
577 cap int
578 warns bool
579 }{
580 {"no proxy, cap active", nil, 4, true},
581 {"proxies trusted", proxies, 4, false},
582 {"no proxy, no cap", nil, 0, false},
583 }
584 for _, tt := range tests {
585 t.Run(tt.name, func(t *testing.T) {
586 t.Parallel()
587 var buf syncBuffer
588 ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf}))
589 warnSharedClientKey(ctx, tt.trusted, tt.cap)
590 if !tt.warns {
591 assert.Empty(t, buf.String())
592 return
593 }
594 assert.Contains(t, buf.String(), `"level":"warn"`)
595 assert.Contains(t, buf.String(), "ERMITE_TRUSTED_PROXIES")
596 })
597 }
598}
599
600// AC4: passkeys exist only where WebAuthn can work: an https public URL (or
601// localhost) with a DNS name as host.
602func TestPasskeysAvailable(t *testing.T) {
603 t.Parallel()
604 for in, want := range map[string]struct {
605 ok bool
606 reason string
607 }{
608 "https://git.example.com": {true, ""},
609 "https://git.example.com:8443": {true, ""},
610 "https://GIT.example.com/": {true, ""},
611 "http://localhost:7341": {true, ""},
612 "http://127.0.0.1:7341": {false, "public URL host is an IP address"},
613 "http://[::1]:7341": {false, "public URL host is an IP address"},
614 "https://203.0.113.7": {false, "public URL host is an IP address"},
615 "https://[2001:db8::1]": {false, "public URL host is an IP address"},
616 "http://git.example.com": {false, "needs https"},
617 "http://192.168.1.2:7341": {false, "needs https"},
618 "": {false, "public URL has no host"},
619 "https://": {false, "public URL has no host"},
620 } {
621 ok, reason := passkeysAvailable(in)
622 assert.Equal(t, want.ok, ok, in)
623 assert.Equal(t, want.reason, reason, in)
624 }
625}