v0.6.0 d5cc8fe

session_test.go RawHistoryFolder

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}

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 45

↑↓ moveenter openesc close