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 | } | ||