package main import ( "bytes" "crypto/rand" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" ) // runGit drives the real git binary, which is what will be talking to the // forge. func runGit(t *testing.T, dir string, args ...string) (string, error) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", // fail rather than block on a prompt "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com", ) out, err := cmd.CombinedOutput() return string(out), err } func addRepo(t *testing.T, f *Forge, path string, repo Repo) { t.Helper() if repo.Labels == nil { repo.Labels = []string{} } if repo.Grants == nil { repo.Grants = map[string]string{} } if err := f.store.CreateRepo(path, repo, f.store.forgeAuthor()); err != nil { t.Fatal(err) } } func addToken(t *testing.T, f *Forge, name, repo string) string { t.Helper() secret, token := mintToken(repo) err := f.store.UpdateUsers("users: mint a push token for "+name, f.store.forgeAuthor(), func(m map[string]User) error { u := m[name] u.Tokens = append(u.Tokens, token) m[name] = u return nil }) if err != nil { t.Fatal(err) } return secret } func TestGitOverHTTP(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") addUser(t, f, "eve", "hunter2hunter2") addRepo(t, f, "infra/dns", Repo{ Desc: "zones", Visibility: visPrivate, Issues: featMembers, Wiki: featDisabled, Grants: map[string]string{"alice": grantWrite}, }) addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled}) srv := httptest.NewServer(f.routes()) defer srv.Close() token := addToken(t, f, "alice", "infra/dns") authed := strings.Replace(srv.URL, "http://", "http://alice:"+token+"@", 1) refs := func(url string) *http.Response { t.Helper() resp, err := srv.Client().Get(url) if err != nil { t.Fatal(err) } resp.Body.Close() return resp } // A public repository read anonymously must never see a challenge, or // every clone starts with a password prompt. public := refs(srv.URL + "/notes.git/info/refs?service=git-upload-pack") if public.StatusCode != http.StatusOK { t.Errorf("anonymous read of a public repo: %d", public.StatusCode) } if got := public.Header.Get("WWW-Authenticate"); got != "" { t.Errorf("anonymous read of a public repo challenged: %q", got) } // A private one must, so that git knows to send credentials. private := refs(srv.URL + "/infra/dns.git/info/refs?service=git-upload-pack") if private.StatusCode != http.StatusUnauthorized { t.Errorf("anonymous read of a private repo: %d", private.StatusCode) } if private.Header.Get("WWW-Authenticate") == "" { t.Error("anonymous read of a private repo did not challenge") } // The tracker is readable and unpushable, even by an account that may push // code. if got := refs(authed + "/infra/dns.issues.git/info/refs?service=git-receive-pack"); got.StatusCode != http.StatusForbidden { t.Errorf("push to the issues repo: %d", got.StatusCode) } // A wiki that is disabled has no repository at all, which is said to an // account that may read the repository. if got := refs(authed + "/infra/dns.wiki.git/info/refs?service=git-upload-pack"); got.StatusCode != http.StatusNotFound { t.Errorf("a disabled wiki: %d", got.StatusCode) } // Anonymous is challenged for whatever it may not read, there or not, so a // 401 beside a 404 cannot say which private names are taken. for _, path := range []string{"/infra/dns.wiki.git", "/nothing.git", "/infra/nothing.git"} { got := refs(srv.URL + path + "/info/refs?service=git-upload-pack") if got.StatusCode != http.StatusUnauthorized || got.Header.Get("WWW-Authenticate") == "" { t.Errorf("anonymous read of %s: %d, wanted a challenge", path, got.StatusCode) } } work := t.TempDir() if out, err := runGit(t, work, "clone", authed+"/infra/dns.git", "dns"); err != nil { t.Fatalf("clone with a token: %v\n%s", err, out) } clone := filepath.Join(work, "dns") if err := os.WriteFile(filepath.Join(clone, "zone"), []byte("example.com. IN A 192.0.2.1\n"), 0600); err != nil { t.Fatal(err) } if out, err := runGit(t, clone, "add", "zone"); err != nil { t.Fatalf("add: %v\n%s", err, out) } if out, err := runGit(t, clone, "commit", "-m", "dns: add a zone"); err != nil { t.Fatalf("commit: %v\n%s", err, out) } if out, err := runGit(t, clone, "push", "origin", "master"); err != nil { t.Fatalf("push with a token: %v\n%s", err, out) } bare := filepath.Join(f.store.reposDir, "infra/dns.git") if out, err := runGit(t, bare, "rev-parse", "master"); err != nil { t.Fatalf("the push did not land: %v\n%s", err, out) } // receive.unpackLimit is passed in the environment, so the push arrives as // one packfile and the repository's own config is still untouched. packs, err := filepath.Glob(filepath.Join(bare, "objects/pack/*.pack")) if err != nil || len(packs) != 1 { t.Errorf("expected one packfile, got %v", packs) } config, err := os.ReadFile(filepath.Join(bare, "config")) if err != nil { t.Fatal(err) } if strings.Contains(string(config), "unpackLimit") || strings.Contains(string(config), "gc") { t.Errorf("git-forge wrote into the repository's config:\n%s", config) } // A token is scoped to one repository and is not a password. wrong := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1) if out, err := runGit(t, clone, "push", wrong+"/infra/dns.git", "master"); err == nil { t.Errorf("a token for another repository pushed:\n%s", out) } byPassword := strings.Replace(srv.URL, "http://", "http://alice:hunter2hunter2@", 1) if out, err := runGit(t, clone, "push", byPassword+"/infra/dns.git", "master"); err == nil { t.Errorf("an account password pushed:\n%s", out) } // A grant on another repository is not a grant on this one. eve := strings.Replace(srv.URL, "http://", "http://eve:"+addToken(t, f, "eve", "infra/dns")+"@", 1) if out, err := runGit(t, clone, "push", eve+"/infra/dns.git", "master"); err == nil { t.Errorf("an account with no write grant pushed:\n%s", out) } // An account that may not read it is told what the web tells it: nothing. if got := refs(eve + "/infra/dns.git/info/refs?service=git-upload-pack"); got.StatusCode != http.StatusNotFound { t.Errorf("a token without a read grant: %d", got.StatusCode) } } // git sends whatever the credential helper holds, and the name is folded to // find the account. Everything after that keys off the account rather than what // was typed, or users.json gains a record it would refuse to load next start. func TestTokenNameIsTheAccountsOwn(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") addRepo(t, f, "notes", Repo{ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, Grants: map[string]string{"alice": grantWrite}, }) secret := addToken(t, f, "alice", "notes") srv := httptest.NewServer(f.routes()) defer srv.Close() authed := strings.Replace(srv.URL, "http://", "http://Alice:"+secret+"@", 1) work := t.TempDir() if out, err := runGit(t, work, "clone", authed+"/notes.git", "notes"); err != nil { t.Fatalf("a token sent under a capitalised name: %v\n%s", err, out) } if users := f.store.Users(); len(users) != 1 { t.Errorf("one authentication left %d accounts: %v", len(users), users) } if u, _ := f.store.User("alice"); len(u.Tokens) != 1 || u.Tokens[0].Used != today() { t.Errorf("the token that was used is not the one marked used: %+v", u.Tokens) } if _, err := openStore(f.store.dir); err != nil { t.Errorf("the datadir no longer opens: %v", err) } } // git sends anything past its http.postBuffer with chunked transfer encoding, // and net/http/cgi refuses a chunked body. Every other push here is small // enough to hide it, so this one is over the line and is checked for coming // back whole rather than merely being accepted. func TestPushPastThePostBuffer(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") addRepo(t, f, "notes", Repo{ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, Grants: map[string]string{"alice": grantWrite}, }) srv := httptest.NewServer(f.routes()) defer srv.Close() authed := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1) work := t.TempDir() if out, err := runGit(t, work, "clone", authed+"/notes.git", "code"); err != nil { t.Fatalf("clone: %v\n%s", err, out) } // Random, so that it does not compress back under the buffer. blob := make([]byte, 4<<20) if _, err := rand.Read(blob); err != nil { t.Fatal(err) } code := filepath.Join(work, "code") if err := os.WriteFile(filepath.Join(code, "blob"), blob, 0600); err != nil { t.Fatal(err) } for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "notes: a big one"}} { if out, err := runGit(t, code, args...); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } if out, err := runGit(t, code, "push", "origin", "master"); err != nil { t.Fatalf("a push past the post buffer was refused: %v\n%s", err, out) } back := t.TempDir() if out, err := runGit(t, back, "clone", authed+"/notes.git", "again"); err != nil { t.Fatalf("cloning it back: %v\n%s", err, out) } got, err := os.ReadFile(filepath.Join(back, "again", "blob")) if err != nil { t.Fatal(err) } if !bytes.Equal(got, blob) { t.Errorf("%d bytes came back, of the %d that went in", len(got), len(blob)) } } // A token is a credential and not a permission: minting asks only that the // account can reach the repository, and what it may do is decided on arrival. // Gating the mint on the write grant instead would lock a read grant out of // cloning and a wiki editor out of pushing a page. func TestTokenCarriesTheAccountsOwnAccess(t *testing.T) { f := testForge(t) addUser(t, f, "docs", "hunter2hunter2") addRepo(t, f, "notes", Repo{ Visibility: visPrivate, Issues: featDisabled, Wiki: featMembers, Grants: map[string]string{"docs": grantRead}, }) h := f.routes() session := signIn(t, f, h, "docs") form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "repo": {"notes"}} w := post(h, "/settings/tokens", form, session) if w.Code != http.StatusOK { t.Fatalf("a read grant could not mint a token: status %d", w.Code) } secret := between(w.Body.String(), `
`, "") if secret == "" { t.Fatal("no token was shown") } srv := httptest.NewServer(h) defer srv.Close() authed := strings.Replace(srv.URL, "http://", "http://docs:"+secret+"@", 1) work := t.TempDir() if out, err := runGit(t, work, "clone", authed+"/notes.git", "code"); err != nil { t.Fatalf("a read grant could not clone a private repository: %v\n%s", err, out) } if out, err := runGit(t, work, "clone", authed+"/notes.wiki.git", "wiki"); err != nil { t.Fatalf("cloning the wiki: %v\n%s", err, out) } // The wiki knob says this account may write documentation. wiki := filepath.Join(work, "wiki") if err := os.WriteFile(filepath.Join(wiki, "index.md"), []byte("# hello\n"), 0600); err != nil { t.Fatal(err) } for _, args := range [][]string{{"add", "-A"}, {"commit", "-m", "wiki: a page"}} { if out, err := runGit(t, wiki, args...); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } if out, err := runGit(t, wiki, "push", "origin", "master"); err != nil { t.Errorf("a wiki editor could not push the wiki: %v\n%s", err, out) } // The same token must not push code, because the account cannot. code := filepath.Join(work, "code") if out, err := runGit(t, code, "commit", "--allow-empty", "-m", "nope"); err != nil { t.Fatalf("commit: %v\n%s", err, out) } if out, err := runGit(t, code, "push", "origin", "master"); err == nil { t.Errorf("a read grant pushed code with a token:\n%s", out) } } // git has no opinion about how large a push may be. The wiki is the case that // matters, since its knob can be set to any account at all. func TestPushIsBounded(t *testing.T) { f := testForge(t) addUser(t, f, "alice", "hunter2hunter2") addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) srv := httptest.NewServer(f.routes()) defer srv.Close() authed := strings.Replace(srv.URL, "http://", "http://alice:"+addToken(t, f, "alice", "notes")+"@", 1) work := t.TempDir() if out, err := runGit(t, work, "clone", authed+"/notes.wiki.git", "wiki"); err != nil { t.Fatalf("clone: %v\n%s", err, out) } wiki := filepath.Join(work, "wiki") commit := func(name string, body []byte) { t.Helper() if err := os.WriteFile(filepath.Join(wiki, name), body, 0600); err != nil { t.Fatal(err) } for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "wiki: " + name}} { if out, err := runGit(t, wiki, args...); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } } commit("index.md", []byte("# a page\n")) if out, err := runGit(t, wiki, "push", "origin", "master"); err != nil { t.Fatalf("a page sized push was refused: %v\n%s", err, out) } // Random, so that it does not compress: the bound is on the pack, and a // megabyte of zeroes would sail through one meant to stop a gigabyte. big := make([]byte, maxPushWiki+1<<20) if _, err := rand.Read(big); err != nil { t.Fatal(err) } commit("big.md", big) out, err := runGit(t, wiki, "push", "origin", "master") if err == nil { t.Fatalf("a push past the bound was accepted:\n%s", out) } if !strings.Contains(out, "maximum allowed size") { t.Errorf("refused for some reason other than the bound:\n%s", out) } } // Any account can reach every public repository, so without a bound a loop // grows users.json, and the config repo's history with it, without end. func TestTokensAreBounded(t *testing.T) { f := testForge(t) addUser(t, f, "bob", "hunter2hunter2") addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled}) h := f.routes() session := signIn(t, f, h, "bob") form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "repo": {"notes"}} // Fill the account through the store, so that what is being measured is // the bound and not the rate limit that also guards this route. for range maxTokens { addToken(t, f, "bob", "notes") } if w := post(h, "/settings/tokens", form, session); w.Code != http.StatusConflict { t.Errorf("minting past the bound: status %d", w.Code) } if u, _ := f.store.User("bob"); len(u.Tokens) != maxTokens { t.Errorf("the account holds %d tokens, wanted %d", len(u.Tokens), maxTokens) } // Revoking one makes room for exactly one more. u, _ := f.store.User("bob") revoke := url.Values{"csrf": {form.Get("csrf")}, "id": {u.Tokens[0].ID}} if w := post(h, "/settings/tokens/revoke", revoke, session); w.Code != http.StatusSeeOther { t.Fatalf("revoking: status %d", w.Code) } if w := post(h, "/settings/tokens", form, session); w.Code != http.StatusOK { t.Errorf("minting into the room a revoke made: status %d", w.Code) } if u, _ := f.store.User("bob"); len(u.Tokens) != maxTokens { t.Errorf("the account holds %d tokens, wanted %d", len(u.Tokens), maxTokens) } } // The whole token life through the pages, then a push with what came out. func TestTokenMintAndRevoke(t *testing.T) { f := testForge(t) addUser(t, f, "bob", "hunter2hunter2") addRepo(t, f, "notes", Repo{ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, Grants: map[string]string{"bob": grantWrite}, }) addRepo(t, f, "closed", Repo{Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled}) h := f.routes() session := signIn(t, f, h, "bob") token := f.csrf(withCookies("/", session)) // A repository the account cannot reach at all mints nothing. if w := post(h, "/settings/tokens", url.Values{"csrf": {token}, "repo": {"closed"}}, session); w.Code != http.StatusForbidden { t.Errorf("minting for a repository the account cannot read: status %d", w.Code) } w := post(h, "/settings/tokens", url.Values{"csrf": {token}, "repo": {"notes"}}, session) if w.Code != http.StatusOK { t.Fatalf("minting: status %d", w.Code) } secret := between(w.Body.String(), `
`, "") if secret == "" { t.Fatal("no token was shown") } u, _ := f.store.User("bob") if len(u.Tokens) != 1 || u.Tokens[0].Repo != "notes" || u.Tokens[0].Hash == secret { t.Fatalf("the token record is wrong: %+v", u.Tokens) } srv := httptest.NewServer(h) defer srv.Close() authed := strings.Replace(srv.URL, "http://", "http://bob:"+secret+"@", 1) work := t.TempDir() if out, err := runGit(t, work, "clone", authed+"/notes.git", "notes"); err != nil { t.Fatalf("clone with the minted token: %v\n%s", err, out) } // Revoking it stops it at once. revoke := url.Values{"csrf": {token}, "id": {u.Tokens[0].ID}} if w := post(h, "/settings/tokens/revoke", revoke, session); w.Code != http.StatusSeeOther { t.Fatalf("revoking: status %d", w.Code) } if u, _ := f.store.User("bob"); len(u.Tokens) != 0 { t.Errorf("the token survived revocation: %+v", u.Tokens) } if out, err := runGit(t, work, "clone", authed+"/notes.git", "again"); err == nil { t.Errorf("a revoked token still cloned:\n%s", out) } }