tests: a private repository is invisible on every surface, in one test !247

merged merged by cmc on 2026-09-04 19:38 UTC · krz/gitbay:security-sweep into main

1 file changed, +170 −0

Layout: unified · split

e2e/isolation_test.go added +170
@@ -0,0 +1,170 @@
1package e2e
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7 "strings"
8 "testing"
9)
10
11// TestPrivateRepoIsInvisible walks every read surface as a stranger and
12// as an anonymous visitor, and asserts a private repository is
13// indistinguishable from one that does not exist.
14//
15// The threat model states this as one rule — "every surface answers 'not
16// found' identically" — but it was only ever tested per feature, in
17// whichever test happened to think of it. A surface added later leaks
18// without anything failing. This is the cross-cutting version: the list
19// of surfaces is the thing under test, so adding a route without adding
20// it here is the omission that shows up.
21func TestPrivateRepoIsInvisible(t *testing.T) {
22 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
23 ownerKey := inst.newKey(t, "owner")
24 strangerKey := inst.newKey(t, "stranger")
25 inst.admin(t, "admin", "user", "create", "owner", "--key", ownerKey+".pub")
26 inst.admin(t, "admin", "user", "create", "stranger", "--key", strangerKey+".pub")
27
28 if _, errOut, code := inst.ssh(t, ownerKey, "", "repo", "create", "owner/secret", "--private"); code != 0 {
29 t.Fatalf("repo create: %s", errOut)
30 }
31 // Distinctive strings: if any of these reach a stranger through any
32 // surface, the grep below finds it whatever shape the leak took.
33 const (
34 repoWord = "zulqarnain" // in the description
35 titleWord = "brontosaurus" // in an issue title
36 bodyWord = "quinquagenarian" // in an issue body only
37 fileWord = "pterodactyl" // in a file only
38 )
39 inst.ssh(t, ownerKey, "", "repo", "settings", "description", "owner/secret", "'"+repoWord+"'")
40 inst.ssh(t, ownerKey, "", "repo", "topics", "add", "owner/secret", repoWord)
41 if _, errOut, code := inst.ssh(t, ownerKey, "", "issue", "create", "owner/secret",
42 "--title", "'"+titleWord+"'", "--body", "'"+bodyWord+"'"); code != 0 {
43 t.Fatalf("issue create: %s", errOut)
44 }
45
46 env := inst.gitEnv(ownerKey)
47 work := t.TempDir()
48 mustGit(t, work, env, "clone", inst.sshURL("owner/secret"), "w")
49 dir := work + "/w"
50 os.WriteFile(dir+"/notes.txt", []byte(fileWord+"\n"), 0o644)
51 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
52 mustGit(t, dir, env, "add", ".")
53 mustGit(t, dir, env, "commit", "-q", "-m", "secret work")
54 mustGit(t, dir, env, "push", "-q", "origin", "main")
55 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
56 mustGit(t, dir, env, "commit", "-q", "--allow-empty", "-m", "more")
57 mustGit(t, dir, env, "push", "-q", "origin", "feat")
58 inst.ssh(t, ownerKey, "", "mr", "create", "owner/secret",
59 "--source", "feat", "--target", "main", "--title", "'"+titleWord+" mr'")
60
61 secrets := []string{repoWord, titleWord, bodyWord, fileWord}
62 // The repository's own name is not secret in the same way — a name is
63 // guessable — but its content must never appear.
64 webPaths := []string{
65 "/owner/secret", "/owner/secret/issues", "/owner/secret/issues/1",
66 "/owner/secret/mrs", "/owner/secret/mrs/1", "/owner/secret/refs",
67 "/owner/secret/tree/main/", "/owner/secret/blob/main/notes.txt",
68 "/owner/secret/raw/main/notes.txt", "/owner/secret/log",
69 "/owner/secret/releases", "/owner/secret/builds", "/owner/secret/wiki",
70 "/owner/secret/compare/main...feat", "/owner/secret/milestones",
71 "/owner/secret/archive/main.tar.gz", "/owner/secret/badge/build.svg",
72 "/owner/secret/search?q=" + fileWord,
73 "/owner", "/explore", "/explore?q=" + repoWord,
74 "/search?q=" + repoWord, "/search?q=" + titleWord, "/search?q=" + bodyWord,
75 "/owner/secret/info/refs?service=git-upload-pack",
76 }
77
78 // Anonymous.
79 for _, p := range webPaths {
80 status, body := inst.get(t, p)
81 checkNoLeak(t, "anonymous "+p, p, status, body, secrets)
82 }
83 // A logged-in stranger.
84 browser := inst.login(t, strangerKey)
85 for _, p := range webPaths {
86 status, body := browserGet(t, browser, inst.base()+p)
87 checkNoLeak(t, "stranger "+p, p, status, body, secrets)
88 }
89
90 // Control commands, as the stranger. Each must be exit 3 (not found)
91 // or return nothing about the repository — never exit 4, which would
92 // confirm the namespace exists.
93 cmds := [][]string{
94 {"repo", "show", "owner/secret"},
95 {"issue", "list", "owner/secret"},
96 {"issue", "show", "owner/secret", "1"},
97 {"mr", "list", "owner/secret"},
98 {"mr", "show", "owner/secret", "1"},
99 {"mr", "diff", "owner/secret", "1"},
100 {"repo", "grep", "owner/secret", fileWord},
101 {"repo", "log", "owner/secret"},
102 {"repo", "refs", "owner/secret"},
103 {"build", "list", "owner/secret"},
104 {"release", "list", "owner/secret"},
105 {"wiki", "list", "owner/secret"},
106 {"repo", "download", "owner/secret"},
107 }
108 for _, argv := range cmds {
109 out, errOut, code := inst.ssh(t, strangerKey, "", argv...)
110 label := "stranger " + strings.Join(argv, " ")
111 if code == 4 {
112 t.Errorf("%s: exit 4 (denied) confirms the repository exists; want 3", label)
113 }
114 checkNoLeakText(t, label, out+errOut, secrets)
115 }
116
117 // Listings a stranger legitimately reaches must not carry it either.
118 for _, argv := range [][]string{
119 {"repo", "list"}, {"explore"}, {"search", repoWord}, {"search", titleWord},
120 {"search", bodyWord}, {"feed"}, {"dashboard"}, {"profile", "show", "owner"},
121 } {
122 out, errOut, _ := inst.ssh(t, strangerKey, "", argv...)
123 checkNoLeakText(t, "stranger "+strings.Join(argv, " "), out+errOut, secrets)
124 }
125
126 // The owner can still see all of it, so the assertions above are
127 // measuring access control and not a broken fixture.
128 out, _, code := inst.ssh(t, ownerKey, "", "search", bodyWord, "--json")
129 if code != 0 || !strings.Contains(out, titleWord) {
130 t.Fatalf("owner cannot find their own issue by body; the fixture is wrong, not the ACL: %s", out)
131 }
132}
133
134// checkNoLeak asserts a response carries nothing about the private
135// repository. A search page echoes the query into its own form and filter
136// links, so a term that appears only because the prober typed it is not a
137// leak — those are dropped, and the surviving signals are a *different*
138// secret appearing, or a link to the repository, either of which can only
139// come from a result row.
140func checkNoLeak(t *testing.T, label, path string, status int, body string, secrets []string) {
141 t.Helper()
142 if status == http.StatusForbidden {
143 t.Errorf("%s: 403 confirms the namespace exists; want 404", label)
144 }
145 echoed := ""
146 if i := strings.Index(path, "q="); i >= 0 {
147 echoed = path[i+2:]
148 }
149 var forbidden []string
150 for _, s := range secrets {
151 if s != echoed {
152 forbidden = append(forbidden, s)
153 }
154 }
155 checkNoLeakText(t, fmt.Sprintf("%s (status %d)", label, status), body, forbidden)
156 // A listing that found the repository would link it. The repo's own
157 // pages are excluded: the URL under test is that link.
158 if !strings.HasPrefix(path, "/owner/secret") && strings.Contains(body, `href="/owner/secret`) {
159 t.Errorf("%s: links the private repository", label)
160 }
161}
162
163func checkNoLeakText(t *testing.T, label, body string, secrets []string) {
164 t.Helper()
165 for _, s := range secrets {
166 if strings.Contains(body, s) {
167 t.Errorf("%s leaked %q", label, s)
168 }
169 }
170}