v0.19.0 55a688e

web_session_test.go RawHistoryFolder

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}

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 44

↑↓ moveenter openesc close