~gitpocket/gitpocket

test(web): cover registration, permissions, merges and injection payloads

commit
6f4e245fccffd55f3285585ec4558ffe1fb9bfa3 browse tree
parent
3b1371e84b8b46e3c441a3dae54a34d06d1012a3
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-13 11:27 UTC

2 files changed, +345 −0

flow_test.go added

@@ -0,0 +1,213 @@
1+package main
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "net/url"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+)
13+
14+func testServer(t *testing.T) (*Server, http.Handler) {
15+ t.Helper()
16+ root := t.TempDir()
17+ store, err := openStore(filepath.Join(root, "gitpocket.db"), false)
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ s := newServer(store, root, "example.org", "gitpocket", "git")
22+ return s, s.handler()
23+}
24+
25+func post(h http.Handler, path string, form url.Values, cookie string) *httptest.ResponseRecorder {
26+ r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
27+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
28+ r.Header.Set("Sec-Fetch-Site", "same-origin")
29+ r.RemoteAddr = "192.0.2.1:1000"
30+ if cookie != "" {
31+ r.Header.Set("Cookie", "session="+cookie)
32+ }
33+ w := httptest.NewRecorder()
34+ h.ServeHTTP(w, r)
35+ return w
36+}
37+
38+func get(h http.Handler, path, cookie string) *httptest.ResponseRecorder {
39+ r := httptest.NewRequest("GET", path, nil)
40+ r.RemoteAddr = "192.0.2.1:1000"
41+ if cookie != "" {
42+ r.Header.Set("Cookie", "session="+cookie)
43+ }
44+ w := httptest.NewRecorder()
45+ h.ServeHTTP(w, r)
46+ return w
47+}
48+
49+func session(w *httptest.ResponseRecorder) string {
50+ for _, c := range w.Result().Cookies() {
51+ if c.Name == "session" {
52+ return c.Value
53+ }
54+ }
55+ return ""
56+}
57+
58+func TestRegistrationModes(t *testing.T) {
59+ s, h := testServer(t)
60+ if w := post(h, "/register", url.Values{"name": {"alice"}, "password": {"correct horse"}}, ""); w.Code != http.StatusSeeOther || session(w) == "" {
61+ t.Fatalf("open registration failed: %d", w.Code)
62+ }
63+ s.store.SetSetting("registration", "invite")
64+ if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}}, ""); w.Code != 400 {
65+ t.Errorf("invite mode let bob in without a code: %d", w.Code)
66+ }
67+ code, _ := s.store.CreateInvite()
68+ if w := post(h, "/register", url.Values{"name": {"bob"}, "password": {"correct horse"}, "invite": {code}}, ""); w.Code != http.StatusSeeOther {
69+ t.Errorf("invite mode refused a valid code: %d", w.Code)
70+ }
71+ s.store.SetSetting("registration", "closed")
72+ if w := post(h, "/register", url.Values{"name": {"carol"}, "password": {"correct horse"}}, ""); w.Code != 403 {
73+ t.Errorf("closed mode allowed registration: %d", w.Code)
74+ }
75+ if w := get(h, "/", ""); strings.Contains(w.Body.String(), `href="/register"`) {
76+ t.Error("closed mode still links to registration")
77+ }
78+}
79+
80+func TestLoginAndHeaders(t *testing.T) {
81+ s, h := testServer(t)
82+ s.store.CreateUser("alice", "correct horse")
83+ if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"wrong password"}}, ""); w.Code != 401 {
84+ t.Errorf("wrong password gave %d", w.Code)
85+ }
86+ w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}}, "")
87+ if session(w) == "" {
88+ t.Fatal("login did not start a session")
89+ }
90+ for _, header := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options"} {
91+ if w.Header().Get(header) == "" {
92+ t.Errorf("missing %s", header)
93+ }
94+ }
95+}
96+
97+func TestCrossOriginPostRefused(t *testing.T) {
98+ s, h := testServer(t)
99+ u, _ := s.store.CreateUser("alice", "correct horse")
100+ id, _ := s.store.CreateSession(u.ID)
101+ r := httptest.NewRequest("POST", "/settings/profile", strings.NewReader("display=pwned"))
102+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
103+ r.Header.Set("Sec-Fetch-Site", "cross-site")
104+ r.Header.Set("Cookie", "session="+id)
105+ w := httptest.NewRecorder()
106+ h.ServeHTTP(w, r)
107+ if w.Code != 403 || s.store.Profile(u.ID).Display == "pwned" {
108+ t.Errorf("cross site post accepted: %d", w.Code)
109+ }
110+}
111+
112+func TestPermissions(t *testing.T) {
113+ s, h := testServer(t)
114+ alice, _ := s.store.CreateUser("alice", "correct horse")
115+ bob, _ := s.store.CreateUser("bob", "correct horse")
116+ secret, err := s.store.CreateRepo(alice, "secret", "", Private, "")
117+ if err != nil {
118+ t.Fatal(err)
119+ }
120+ os.MkdirAll(filepath.Dir(s.repoDir(secret)), 0o755)
121+ initRepo(s.repoDir(secret))
122+ if w := get(h, "/~alice/secret", ""); w.Code != 404 {
123+ t.Errorf("anonymous read of a private repo: %d", w.Code)
124+ }
125+ bobSession, _ := s.store.CreateSession(bob.ID)
126+ if w := get(h, "/~alice/secret", bobSession); w.Code != 404 {
127+ t.Errorf("stranger read a private repo: %d", w.Code)
128+ }
129+ if s.store.CanWrite(secret, &bob) {
130+ t.Error("stranger can write")
131+ }
132+ org, _ := s.store.CreateOrg("team")
133+ s.store.SetMember(org.ID, bob.ID, true)
134+ shared, _ := s.store.CreateRepo(org, "shared", "", Private, "")
135+ if !s.store.CanWrite(shared, &bob) || !s.store.Owns(shared, &bob) || s.store.CanWrite(shared, &alice) {
136+ t.Error("org membership not respected")
137+ }
138+ if _, ok := s.store.CheckPassword("team", ""); ok {
139+ t.Error("an organisation can log in")
140+ }
141+ if w := post(h, "/settings/profile", url.Values{"org": {"team"}, "display": {"x"}}, ""); w.Code != http.StatusSeeOther {
142+ t.Errorf("anonymous org edit not redirected to login: %d", w.Code)
143+ }
144+ aliceSession, _ := s.store.CreateSession(alice.ID)
145+ if w := post(h, "/settings/profile", url.Values{"org": {"team"}, "display": {"x"}}, aliceSession); w.Code != 403 {
146+ t.Errorf("non member edited an org: %d", w.Code)
147+ }
148+ s.store.AddRedirect("alice", "old", shared.ID)
149+ if w := get(h, "/~alice/old", ""); w.Code != 404 {
150+ t.Errorf("redirect leaked a private repo: %d", w.Code)
151+ }
152+ if w := get(h, "/~alice/old", bobSession); w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "/~team/shared" {
153+ t.Errorf("redirect for a member: %d %s", w.Code, w.Header().Get("Location"))
154+ }
155+ if w := get(h, "/admin", bobSession); w.Code != 404 {
156+ t.Errorf("admin page open to a normal user: %d", w.Code)
157+ }
158+}
159+
160+func TestMergeStrategies(t *testing.T) {
161+ if _, err := exec.LookPath("git"); err != nil {
162+ t.Skip("git not installed")
163+ }
164+ dir := filepath.Join(t.TempDir(), "r.git")
165+ if err := initRepo(dir); err != nil {
166+ t.Fatal(err)
167+ }
168+ g := Git{Dir: dir}
169+ base := commitFile(t, g, "main", "", "a.txt", "one\n")
170+ feature := commitFile(t, g, "feature", base, "b.txt", "two\n")
171+ if !g.Mergeable("main", feature) {
172+ t.Fatal("clean merge reported as conflicting")
173+ }
174+ if _, err := g.Merge("main", feature, "merge", "T", "t@example.org"); err != nil {
175+ t.Fatal(err)
176+ }
177+ if _, err := g.Merge("main", feature, "merge", "T", "t@example.org"); err == nil {
178+ t.Error("merged the same head twice")
179+ }
180+ left := commitFile(t, g, "left", base, "a.txt", "left\n")
181+ right := commitFile(t, g, "right", base, "a.txt", "right\n")
182+ if _, err := g.mergeTree(base, left, right); err == nil {
183+ t.Error("conflict not detected")
184+ }
185+}
186+
187+func commitFile(t *testing.T, g Git, branch, parent, name, content string) string {
188+ t.Helper()
189+ blob, err := g.run([]byte(content), "hash-object", "-w", "--stdin")
190+ if err != nil {
191+ t.Fatal(err)
192+ }
193+ index := filepath.Join(t.TempDir(), "index")
194+ env := []string{"GIT_INDEX_FILE=" + index}
195+ if parent != "" {
196+ g.runEnv(env, nil, "read-tree", parent)
197+ }
198+ if _, err := g.runEnv(env, nil, "update-index", "--add", "--cacheinfo", "100644,"+strings.TrimSpace(string(blob))+","+name); err != nil {
199+ t.Fatal(err)
200+ }
201+ tree, _ := g.runEnv(env, nil, "write-tree")
202+ args := []string{"commit-tree", strings.TrimSpace(string(tree))}
203+ if parent != "" {
204+ args = append(args, "-p", parent)
205+ }
206+ out, err := g.runEnv(ident("T", "t@example.org", timeNow()), []byte("c"), args...)
207+ if err != nil {
208+ t.Fatal(err)
209+ }
210+ hash := strings.TrimSpace(string(out))
211+ g.run(nil, "update-ref", "refs/heads/"+branch, hash)
212+ return hash
213+}

unit_test.go added

@@ -0,0 +1,132 @@
1+package main
2+
3+import (
4+ "net/http/httptest"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestValidBranch(t *testing.T) {
10+ for _, ok := range []string{"main", "feature/x", "v1.2", "a_b-c"} {
11+ if !validBranch(ok) {
12+ t.Errorf("%q rejected", ok)
13+ }
14+ }
15+ for _, bad := range []string{"", "-x", "a..b", "a b", "a:b", "a~1", "a^", "x.lock", "a//b", "/a", "a/", ".a", "a/.b", "a@{1}", "@", "a\nb", "a\x00b", "a*", "a?", "a[", "a\\b"} {
16+ if validBranch(bad) {
17+ t.Errorf("%q accepted", bad)
18+ }
19+ }
20+}
21+
22+func TestCleanPath(t *testing.T) {
23+ cases := map[string]bool{"a/b.txt": true, "": true, "../x": false, "a/../b": false, "-rf": false, "a\nb": false, "a\x00": false, "a\tb": false, "a//b": false}
24+ for in, want := range cases {
25+ if _, got := cleanPath(in); got != want {
26+ t.Errorf("cleanPath(%q) = %v", in, got)
27+ }
28+ }
29+}
30+
31+func TestClientIP(t *testing.T) {
32+ r := httptest.NewRequest("GET", "/", nil)
33+ r.RemoteAddr = "203.0.113.9:4000"
34+ r.Header.Set("X-Forwarded-For", "1.1.1.1")
35+ if got := clientIP(r); got != "203.0.113.9" {
36+ t.Errorf("direct client spoofed its address as %s", got)
37+ }
38+ r.RemoteAddr = "127.0.0.1:4000"
39+ r.Header.Set("X-Forwarded-For", "6.6.6.6, 198.51.100.7")
40+ if got := clientIP(r); got != "198.51.100.7" {
41+ t.Errorf("proxied address %s, want the last hop", got)
42+ }
43+}
44+
45+func TestParseQuery(t *testing.T) {
46+ text, quals := parseQuery(`kernel language:"Standard ML" user:~james tag:sel4`)
47+ if text != "kernel" || quals["language"] != "Standard ML" || quals["user"] != "james" || quals["tag"] != "sel4" {
48+ t.Errorf("got %q %v", text, quals)
49+ }
50+ if got := stripLanguage(`x lang:go tag:"a b"`); got != `x tag:"a b"` {
51+ t.Errorf("stripLanguage = %q", got)
52+ }
53+}
54+
55+func TestBuckets(t *testing.T) {
56+ b := newBuckets(0.001, 3)
57+ for i := 0; i < 3; i++ {
58+ if !b.allow("k") {
59+ t.Fatal("burst refused early")
60+ }
61+ }
62+ if b.allow("k") {
63+ t.Error("bucket did not run dry")
64+ }
65+ if !b.allow("other") {
66+ t.Error("keys are not independent")
67+ }
68+}
69+
70+func TestCappedBuffer(t *testing.T) {
71+ var b cappedBuffer
72+ if _, err := b.Write(make([]byte, maxOutput)); err != nil {
73+ t.Fatal(err)
74+ }
75+ if _, err := b.Write([]byte{1}); err == nil || !b.over {
76+ t.Error("cap not enforced")
77+ }
78+}
79+
80+func TestMarkdownSanitised(t *testing.T) {
81+ s, _ := testServer(t)
82+ for _, in := range []string{
83+ `<script>alert(1)</script>`,
84+ `<img src=x onerror=alert(1)>`,
85+ `[x](javascript:alert(1))`,
86+ `<a href="javascript:alert(1)">x</a>`,
87+ `<video src="javascript:alert(1)"></video>`,
88+ `<iframe src="https://evil.example"></iframe>`,
89+ `<svg onload=alert(1)>`,
90+ `<div style="background:url(javascript:alert(1))">x</div>`,
91+ } {
92+ out := strings.ToLower(string(s.renderMarkdownAt([]byte(in), "/raw/", "/tree/")))
93+ for _, bad := range []string{"<script", "onerror", "javascript:", "<iframe", "onload", "style="} {
94+ if strings.Contains(out, bad) {
95+ t.Errorf("%q rendered %q", in, out)
96+ }
97+ }
98+ }
99+}
100+
101+func TestSizes(t *testing.T) {
102+ for in, want := range map[string]int64{"2G": 2 << 30, "500M": 500 << 20, "2048 MiB": 2 << 30, "10KB": 10 << 10, "0": 0} {
103+ if got, err := parseSize(in); err != nil || got != want {
104+ t.Errorf("parseSize(%q) = %d %v", in, got, err)
105+ }
106+ }
107+ if got := compactSize(2 << 30); got != "2G" {
108+ t.Errorf("compactSize = %s", got)
109+ }
110+}
111+
112+func TestHeaderInjection(t *testing.T) {
113+ if got := headerText("hi\r\nBcc: x@evil.example"); strings.ContainsAny(got, "\r\n") {
114+ t.Errorf("header kept a line break: %q", got)
115+ }
116+ for _, bad := range []string{"a@b.c\r\nBcc: x@y.z", "a@b.c, x@y.z", "<a@b.c>", "nope", "a b@c.d"} {
117+ if validEmail(bad) {
118+ t.Errorf("accepted %q", bad)
119+ }
120+ }
121+ if !validEmail("someone@example.org") {
122+ t.Error("rejected a plain address")
123+ }
124+}
125+
126+func TestRelativeLinkBaseEscaped(t *testing.T) {
127+ s, _ := testServer(t)
128+ out := string(s.renderMarkdownAt([]byte("![x](pic.png) [y](doc.md)"), `/~a/r/raw/x"onmouseover="alert(1)/`, `/~a/r/tree/x"><script>/`))
129+ if strings.Contains(out, `"onmouseover`) || strings.Contains(out, "<script") {
130+ t.Errorf("base broke out of the attribute: %s", out)
131+ }
132+}