Commit 6df734dd58
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/isolation_test.go added +170
| @@ -0,0 +1,170 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 21 | func 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. | |
| 140 | func 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 | ||
| 163 | func 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 | } | |