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