e2e/isolation_test.go

70dc0648f931f6f7112c6b71b0be485eeae4077f
gitbay/e2e/isolation_test.go history · blame · raw

170 lines · 7013 bytes

  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}