Commit 6df734dd58

6df734dd583c0ee20eab4a7be6d0c5d678a6f070

parent: 96df83f2d3

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 19:28 UTC

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

The threat model states this as one rule — every surface answers "not
found" identically for a private repository and a missing one — and it
was tested per feature, in whichever test happened to think of it. A
surface added later leaked without anything failing. The global search
added in v1.11.0 is covered only because it occurred to me while writing
it.

TestPrivateRepoIsInvisible walks the surfaces as a list: every web route,
thirteen control commands, and the listings a stranger legitimately
reaches, as both an anonymous visitor and a logged-in stranger. Adding a
route without adding it here is the omission that shows up.

Distinctive words in the description, an issue title, an issue body and a
file mean a leak is found whatever shape it took, and the assertions
separate a leak from an echo: a search page renders the query into its
own form and filter links, so a term present because the prober typed it
proves nothing. What survives is a different secret appearing, or a link
to the repository — either of which can only come from a result row. The
first version of this test did not make that distinction and reported
four false positives.

The owner's own search is asserted at the end, so the rest is measuring
access control rather than a fixture that never had the data.

Removing the visibility predicate from the search query fails it on both
the web and the CLI path; I checked.

Ref #149

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}