diff options
58 files changed, 12798 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f8d5960 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +/datadir +/git-forge +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6af6f97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/git-forge +/datadir/ +.env diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..1cb79e7 --- /dev/null +++ b/Containerfile @@ -0,0 +1,30 @@ +# git-forge: one binary, one data directory, one listener. Strictly Alpine, +# both stages, so there is one distribution to reason about. + +FROM alpine:3.23 AS build +RUN apk add --no-cache go +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# Static, so the runtime image needs neither Go nor libc. +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /git-forge . + +FROM alpine:3.23 +# git-daemon is not the daemon here: in Alpine that subpackage is where +# git-http-backend lives, and without it every clone and push answers 500. +# tini reaps what git leaves: a detached gc is reparented to PID 1 and would +# otherwise stay a zombie. +RUN apk add --no-cache git git-daemon cgit highlight ca-certificates tini +COPY --from=build /git-forge /usr/local/bin/git-forge + +# Non-root, and the datadir is credential material, so 0700 and one owner. +RUN adduser -D -H -u 1000 forge \ + && install -d -o forge -g forge -m 0700 /var/lib/git-forge +USER forge +ENV HOME=/tmp + +EXPOSE 8080 +VOLUME /var/lib/git-forge +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/git-forge"] @@ -0,0 +1,26 @@ +Copyright (c) 2026 Lena + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. @@ -0,0 +1,211 @@ +git-forge +========= +A small self-hosted git forge: HTTP push and pull, cgit browsing with syntax +highlighting, local and OIDC accounts, TOTP, scoped push tokens, per-repo +permissions, issues, a wiki, and outbound mail. One binary, one data +directory, one listener. + +git-forge does not reimplement git. It runs `git-http-backend` and `cgit` as +CGI behind its own listener, authorises every request before exec'ing either, +and supplies only what those two lack. + +Out, deliberately: pull requests, patch review, CI, hooks, forks, repository +rename, inbound mail, SSH, attachments, milestones, assignees, and closing an +issue from a commit message. Also out: JavaScript, a database, a reverse +proxy, and a process supervisor. + +Limits: one process per datadir, `git grep` is the issue search, and a few +thousand issues per repository is the ceiling. One push is bounded at 1 GiB +of code and 16 MiB of wiki; the total is not. Eight cgit and eight +git-http-backend children run at once and the rest queue. + + +Build +----- +Go 1.25 or later: + go build + +`CGO_ENABLED=0` makes the binary static, which is what `Containerfile` does. + +Gating checks: + gofmt -l . must print nothing + go vet ./... must be clean + go mod tidy -diff must print nothing + go test ./... unit and integration tests, needs git; some skip + without cgit + ./test builds the image and drives a container end to end + +`./test` needs docker, git, curl, lynx, openssl, python3, and timeout. Checked +by hand: OIDC against a real provider, and Mailgun against a real domain. + +Running outside the container needs `git`, `cgit`, and `highlight`. On Alpine +`git-http-backend` is in the `git-daemon` package; without it every clone and +push answers 500. `GIT_FORGE_CGIT` names `cgit.cgi` when it is not at +`/usr/share/webapps/cgit/cgit.cgi`. + + +Run +--- + ./git-forge -datadir /var/lib/git-forge + +The datadir comes from the flag, else `GIT_FORGE_DATADIR`, else +`/var/lib/git-forge`, and never from the config. Everything missing is +created on first start. + +There is no TLS. A push token is sent as Basic auth on every request, and the +session cookie is `Secure` only when `base_url` is https. Keep the listener on +localhost, or put something in front that terminates TLS and set `base_url` to +the origin it serves. + +Administration is web only. With no accounts, git-forge prints a one time +setup token to stdout and serves `/setup`; that form takes the token and +creates the first admin. A restart before setup prints a new token, and once +any account exists `/setup` answers 404. + +Bare metal on Alpine: `adduser -D -H forge`, install the binary and +`git-forge.initd` as `/etc/init.d/git-forge`, and put secrets as `VAR=value` +lines in `/etc/conf.d/git-forge`, mode 0600. The unit creates the datadir, +runs as `forge`, and writes stdout to `/var/log/git-forge.log`. + + +Configuration +------------- +`{datadir}/config/forge.json`, plus environment for secrets. The config has no +field for a secret: + GIT_FORGE_COOKIE_KEY else generated into {datadir}/cookie.key + GIT_FORGE_MAIL_MAILGUN_API_KEY + GIT_FORGE_OIDC_<KEY>_CLIENT_SECRET + +Five fields have an environment override: `GIT_FORGE_LISTEN`, +`GIT_FORGE_SITE_NAME`, `GIT_FORGE_SITE_DESC`, `GIT_FORGE_BASE_URL`, and +`GIT_FORGE_CLIENT_IP_HEADER`. An override is an overlay: `/admin` rewrites +the file without it. + +`site_name`, `site_desc`, `logo`, and `icon` are editable at `/admin`, which +commits the change. `logo` and `icon` are image paths, relative to the config +directory, served at `/logo` and `/favicon.ico`; empty means cgit's own. + +`mail.driver` is one of: + none the default. Nothing is sent; the log names each dropped + message by recipient and subject, never its body + log whole messages to the log, for development. A set-password + or reset link is a capability, and this driver logs it + mailgun one form-encoded POST to `mail.mailgun.base_url`, with the + key from the environment + +An OIDC provider takes an optional `allow`: addresses, or `@domain` for a +whole domain. An empty list admits anyone the provider vouches for. It is +checked at every login, so removing an entry revokes: + + "allow": ["carol@example.com", "@example.com"] + +`client_ip_header` is empty by default. Behind a proxy every request shares +one address, so every per address rate limit becomes one bucket; naming the +header the proxy sets is the way out. Naming one trusts it: the proxy must +overwrite it, and nothing may reach the listener around the proxy. + + "client_ip_header": "CF-Connecting-IP" + +Rate limits are constants in the source. Startup validates `forge.json` and +`repos.json` and exits nonzero on anything incoherent. + + +Data directory +-------------- + config/ git repo with a working tree: forge.json, users.json, + repos.json, and an uploaded logo and icon + cookie.key generated secret, 0600, outside the config repo + mailq/ spooled outbound mail, transient + repos/ bare git repos, holding nothing git-forge wrote + +Back up with `tar` against a stopped service, or a filesystem snapshot. The +datadir is credential material in full: `users.json` holds argon2id hashes, +TOTP secrets, and recovery codes, and the config repo keeps their history. + +Exactly one process may serve a datadir: git-forge takes an exclusive `flock` +on it and a second process refuses to start. Storage must be a real +filesystem with atomic `rename()` and working lock files; FUSE-mounted object +storage is not. + + +Debugging +--------- +One log line per request on stdout. Query strings are not logged, because +reset links and OIDC codes arrive in them, and the path is quoted, so a +newline in one cannot write its own line. + +The config repo is the audit trail: + cat {datadir}/config/repos.json + git -C {datadir}/config log -p repos.json when did bob get write + git -C {datadir}/config log --author=alice what has this admin done + git -C {datadir}/config revert HEAD undo a fat-fingered grant + +The working tree is the source of truth; a commit that fails is logged and +the request still succeeds. + +Issues and wiki pages are paths inside a git tree: + git -C {datadir}/repos/infra/dns.issues.git show master:issues/7.json + +Repository state is derived from the tree: a directory ending in `.git` is a +repository, anything else is a section one level deep. A directory with no +entry in `repos.json` is served private with issues and wiki off, and shown +with an adopt button at `/admin`; an entry with no directory is ignored. Both +are logged at startup. + +A repository path is one or two components of lowercase letters, digits, +dot, dash, and underscore. Neither component may be a name one of git-forge's +own routes owns, and a second component may not be a cgit route either: +`/infra/log` is the log of the repository `infra`. + +Every form refused with a 403 while everything else works means `base_url` is +not the origin the browser uses: a form post whose `Origin` disagrees with it +is refused. + +An image that does not load is one that is not this origin's. The security +policy refuses remote images, so a badge in a README cannot tell a third party +who read it. No script runs anywhere. + +Handing out access asks for the acting account's own authenticator code, at +`/admin` and on a repository's settings alike, since what it hands out +outlives the session. A code works once: a second action within the same +thirty seconds waits for the next code. An admin without an authenticator is +sent to enrol one. Changing the second factor asks for the password on a local +account and the enrolled code on an external one. + +cgit is invoked per request with a generated config naming only the one +repository. Those configs live in a temporary directory logged at startup and +are not part of the backup. + +`git gc --auto` runs after a push and after every issue and wiki write, so a +repository's on-disk shape changes when git's thresholds fire. + + +Tokens +------ +Git over HTTP takes a token, never a password: git cannot be asked for a +second factor, and a token is separately scoped and separately revoked. + +A token is a credential, not a permission. Minting one at `/settings` asks +only that the account can reach the repository; what it may do is decided on +arrival, from the same grants the web uses. + +An anonymous git request for anything it may not read is answered 401, +whether the repository exists or not, so the answer never says which private +names are taken. An authenticated request without read answers 404, as the +web does. + + +Container +--------- + podman build -t git-forge . + podman run -p 8080:8080 -v git-forge:/var/lib/git-forge git-forge + +Alpine, non-root, volume at `/var/lib/git-forge`, tini as PID 1 to reap +git's detached gc. `/healthz` answers 200 and SIGTERM drains and exits. + + +License +------- +BSD 3-Clause, in `LICENSE`. cgit's stylesheet is served from the cgit package +at runtime rather than copied into this tree: it is cgit's, under cgit's own +licence. diff --git a/access_test.go b/access_test.go new file mode 100644 index 0000000..d7ec662 --- /dev/null +++ b/access_test.go @@ -0,0 +1,364 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" +) + +// The rules are unit tested on Viewer. This proves the wiring: that every +// route actually asks, and asks the right question. +func TestAccessThroughTheRouter(t *testing.T) { + f := testForge(t) + for _, name := range []string{"owner", "writer", "reader", "stranger"} { + addUser(t, f, name, "hunter2hunter2") + } + // owner administers both repositories, and changing a grant asks for a code, + // so this is the viewer that holds an authenticator. + addTOTP(t, f, "owner") + grants := map[string]string{"owner": grantAdmin, "writer": grantWrite, "reader": grantRead} + addRepo(t, f, "pub", Repo{ + Desc: "public", Visibility: visPublic, Issues: featUsers, Wiki: featUsers, + Labels: []string{"bug"}, Grants: grants, + }) + addRepo(t, f, "priv", Repo{ + Desc: "private", Visibility: visPrivate, Issues: featMembers, Wiki: featMembers, + Labels: []string{"bug"}, Grants: grants, + }) + if _, err := os.Stat(cgitBinary()); err != nil { + t.Skip("cgit is not installed, and some of these rows are cgit's") + } + h := f.routes() + + // Signing in is deliberately expensive, so it happens once per viewer. + sessions := map[string][]*http.Cookie{"": nil} + sessions["owner"] = codeSession(t, f, h, "owner", "hunter2hunter2") + for _, name := range []string{"writer", "reader", "stranger"} { + sessions[name] = signIn(t, f, h, name) + } + + // Something to read in each. + token := f.csrf(withCookies("/", sessions["owner"])) + for _, repo := range []string{"pub", "priv"} { + post(h, "/"+repo+"/issues", url.Values{"csrf": {token}, "title": {"one"}, "body": {"b"}}, sessions["owner"]) + post(h, "/"+repo+"/wiki/", url.Values{ + "csrf": {token}, "base": {""}, "body": {"# page"}, "action": {"save"}}, sessions["owner"]) + } + + const ( + ok = http.StatusOK + gone = http.StatusNotFound + refused = http.StatusForbidden + redirect = http.StatusSeeOther + ) + // What each viewer should get. Anonymous is the empty name, and a path + // holding %s gets the viewer's name, so one row's writes cannot decide the + // next row's answer. + cases := []struct { + method, path string + anon, stranger int + reader, writer, owner int + }{ + {"GET", "/pub/issues", ok, ok, ok, ok, ok}, + {"GET", "/pub/issues/1", ok, ok, ok, ok, ok}, + {"GET", "/pub/wiki", ok, ok, ok, ok, ok}, + // Public and readable, so refusing to administer it is a 403 and not a + // pretence that it is not there. + {"GET", "/pub/settings", refused, refused, refused, refused, ok}, + {"GET", "/pub/", ok, ok, ok, ok, ok}, + {"GET", "/priv/", gone, gone, ok, ok, ok}, + {"GET", "/priv/issues", gone, gone, ok, ok, ok}, + {"GET", "/priv/issues/1", gone, gone, ok, ok, ok}, + {"GET", "/priv/wiki", gone, gone, ok, ok, ok}, + {"GET", "/priv/settings", gone, gone, refused, refused, ok}, + {"GET", "/priv.issues/", gone, gone, ok, ok, ok}, + {"GET", "/priv.wiki/", gone, gone, ok, ok, ok}, + + // issues is users on pub, so any account may write, but not a visitor. + {"POST", "/pub/issues", refused, redirect, redirect, redirect, redirect}, + // issues is members on priv, so a grant is needed. + {"POST", "/priv/issues", gone, gone, redirect, redirect, redirect}, + {"POST", "/priv/issues/1/comment", gone, gone, redirect, redirect, redirect}, + // closing is triage: the author, or whoever the repo trusts with code. + {"POST", "/priv/issues/1/state", gone, gone, refused, redirect, redirect}, + {"POST", "/priv/issues/1/labels", gone, gone, refused, redirect, redirect}, + // the wiki knob, not the write grant, governs the wiki. + {"POST", "/pub/wiki/%s", refused, redirect, redirect, redirect, redirect}, + {"POST", "/priv/wiki/%s", gone, gone, redirect, redirect, redirect}, + // A repository's own settings follow the admin grant. Deleting it is + // the global flag's alone, so even the repository's admin is refused + // before the typed confirmation is ever looked at. + {"POST", "/priv/settings/grant", gone, gone, refused, refused, redirect}, + {"POST", "/priv/settings/delete", gone, gone, refused, refused, refused}, + // the global admin pages are neither. + {"GET", "/admin", redirect, refused, refused, refused, refused}, + } + + for _, c := range cases { + for _, who := range []struct { + name string + want int + }{ + {"", c.anon}, {"stranger", c.stranger}, {"reader", c.reader}, + {"writer", c.writer}, {"owner", c.owner}, + } { + label := who.name + if label == "" { + label = "anonymous" + } + session := sessions[who.name] + path := c.path + if strings.Contains(path, "%s") { + path = fmt.Sprintf(path, label) + } + if got := request(t, f, h, c.method, path, session); got != who.want { + t.Errorf("%s %s as %s: %d, wanted %d", c.method, path, label, got, who.want) + } + } + } +} + +// request drives one route with a body good enough to get past validation, so +// that what is being measured is the permission check and not the form. +func request(t *testing.T, f *Forge, h http.Handler, method, path string, session []*http.Cookie) int { + t.Helper() + if method == http.MethodGet { + return get(h, path, session).Code + } + form := url.Values{"csrf": {f.csrf(withCookies("/", session))}} + form.Set("title", "t") + form.Set("body", "b") + form.Set("account", "reader") + form.Set("level", grantRead) + form.Set("label", "bug") + form.Set("confirm", "wrong-on-purpose") // deletion must refuse before it acts + form.Set("base", "") + form.Set("action", "save") + form.Set("code", freshCode(t, f, "owner", knownSecret)) // for the rows that confirm + return post(h, path, form, session).Code +} + +func signIn(t *testing.T, f *Forge, h http.Handler, name string) []*http.Cookie { + t.Helper() + w := post(h, "/login", url.Values{"name": {name}, "password": {"hunter2hunter2"}}, nil) + if w.Code != http.StatusSeeOther { + t.Fatalf("signing %s in: status %d", name, w.Code) + } + return w.Result().Cookies() +} + +// What neither CGI will take. A control byte cannot go in an environment +// variable at all, and git-http-backend refuses a relative or empty component +// with a 500 of its own. Percent encoded, these get past the mux's cleaning. +func TestUnservablePathsAreRefused(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + if _, err := os.Stat(cgitBinary()); err != nil { + t.Skip("cgit is not installed, and the rows that used to answer 500 are cgit's") + } + h := f.routes() + for _, path := range []string{ + "/notes/tree/%00", + "/notes/plain/a%00b", + "/notes.git/%00/info/refs", + "/notes.git/info/refs%00", + "/notes/tree/%0aforged", + "/notes/%7f", + "/notes.git/%2e%2e/%2e%2e/etc/passwd", + "/notes.git/%2e/info/refs", + "/notes.git%2f%2finfo/refs", + "/notes.git/%2f/info/refs", + "/notes/%2e%2e/log/", + } { + if code := get(h, path, nil).Code; code != http.StatusNotFound { + t.Errorf("%s answered %d", path, code) + } + } + // And what the guard must let through, including the trailing slash cgit + // emits and expects back. + for _, path := range []string{"/notes/issues", "/notes/", "/notes/log/", "/notes.git/info/refs?service=git-upload-pack"} { + if code := get(h, path, nil).Code; code != http.StatusOK { + t.Errorf("an ordinary path %s answered %d", path, code) + } + } +} + +// The path arrives percent-decoded, so a newline in it would end the log line +// and let anybody write the next one. +func TestLogLineCannotBeForged(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(old) + + // A path that closes the real line and opens a plausible fake one. + get(h, "/notes/issues%0a2026-08-15%2000:00:00%20203.0.113.9%20POST%20/admin/user/delete%20303%200%201ms", nil) + + out := strings.TrimRight(buf.String(), "\n") + if lines := strings.Split(out, "\n"); len(lines) != 1 { + t.Errorf("one request wrote %d log lines:\n%s", len(lines), out) + } + if !strings.Contains(out, `\n`) { + t.Errorf("the newline in the path was not escaped: %s", out) + } +} + +// A private repository's page must not sit in a shared cache. cgit says the +// opposite on its own pages, dating them five minutes ahead, so the policy has +// to be stated here and not left to what the CGI happens to send. +func TestNoPageIsStored(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "priv", Repo{ + Visibility: visPrivate, Issues: featMembers, Wiki: featMembers, + Grants: map[string]string{"alice": grantRead}, + }) + h := f.routes() + session := signIn(t, f, h, "alice") + for _, path := range []string{"/", "/settings", "/priv/issues", "/priv/wiki", "/priv/"} { + w := get(h, path, session) + if w.Code != http.StatusOK { + t.Fatalf("%s answered %d", path, w.Code) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("%s says Cache-Control: %q", path, got) + } + } + // And what is the same for everybody keeps its own. + for _, path := range []string{"/cgit.css", "/favicon.ico", "/avatar/alice"} { + if got := get(h, path, session).Header().Get("Cache-Control"); !strings.HasPrefix(got, "max-age=") { + t.Errorf("%s says Cache-Control: %q", path, got) + } + } +} + +// Markdown renders an image with whatever src it was handed, so a body written +// by anyone who may file an issue would have the reader fetch from wherever the +// author chose. On a private repository that names who is reading it. The +// policy is what stops the fetch, so it has to say so on the page that carries +// one. +func TestARemoteImageIsRefusedByThePolicy(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "priv", Repo{ + Visibility: visPrivate, Issues: featMembers, Wiki: featDisabled, + Grants: map[string]string{"alice": grantRead}, + }) + h := f.routes() + session := signIn(t, f, h, "alice") + form := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "title": {"one"}, + "body": {""}, + } + if w := post(h, "/priv/issues", form, session); w.Code != http.StatusSeeOther { + t.Fatalf("opening the issue: status %d", w.Code) + } + + w := get(h, "/priv/issues/1", session) + if !strings.Contains(w.Body.String(), `src="https://tracker.example/p.png"`) { + t.Fatal("markdown no longer renders a remote image, and this measures the header that stops it") + } + if policy := w.Header().Get("Content-Security-Policy"); !strings.Contains(policy, "img-src 'self'") { + t.Errorf("the page carries a third party image under %q", policy) + } +} + +// A HEAD is a GET whose body is dropped on the way out. The mux answers one +// for every route it holds a pattern for, so the pages under a repository, +// which read the method themselves, have to as well. +func TestHeadIsAnsweredWhereverGetIs(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + h := f.routes() + if code := get(h, "/notes/issues", nil).Code; code != http.StatusOK { + t.Fatalf("the route this is measured against answered %d", code) + } + for _, path := range []string{ + "/", "/login", "/healthz", "/notes/issues", "/notes/issues/new", + "/notes/issues/1", "/notes/wiki", "/notes/wiki/page", "/notes/settings", + } { + want := get(h, path, nil).Code + r := httptest.NewRequest("HEAD", path, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != want { + t.Errorf("HEAD %s answered %d, where GET answers %d", path, w.Code, want) + } + } + // And a method nothing serves still says so. + r := httptest.NewRequest("PUT", "/notes/issues", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("PUT answered %d", w.Code) + } +} + +// A repository that has been deleted must stop answering everywhere at once. +func TestDeletedRepoIsGone(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + h := f.routes() + if code := get(h, "/notes/issues", nil).Code; code != http.StatusOK { + t.Fatalf("before the delete, the repository did not answer: %d", code) + } + if err := f.store.DeleteRepo("notes", f.store.forgeAuthor()); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/notes/issues", "/notes/wiki", "/notes/settings"} { + if code := get(h, path, nil).Code; code != http.StatusNotFound { + t.Errorf("%s answered %d after the repository was deleted", path, code) + } + } + // git is challenged rather than told, so the answer cannot say whether the + // name was ever taken. + for _, path := range []string{ + "/notes.git/info/refs?service=git-upload-pack", + "/notes.issues.git/info/refs?service=git-upload-pack", + } { + if code := get(h, path, nil).Code; code != http.StatusUnauthorized { + t.Errorf("%s answered %d after the repository was deleted", path, code) + } + } + if body := get(h, "/", nil).Body.String(); strings.Contains(body, "notes") { + t.Error("the index still lists a deleted repository") + } +} + +// The name goes into an attribute of an SVG this server writes, so what the +// route accepts is what keeps that safe. +func TestAvatarIsDrawnFromTheNameAlone(t *testing.T) { + h := testForge(t).routes() + + one := avatarSVG("alice") + if !bytes.Equal(one, avatarSVG("alice")) { + t.Error("one account was drawn two different ways") + } + if bytes.Equal(one, avatarSVG("bob")) { + t.Error("two accounts were drawn the same way") + } + + for _, name := range []string{`a"><script>x</script>`, "../../etc/passwd", "Alice", "a b", "a/b"} { + if code := get(h, "/avatar/"+url.PathEscape(name), nil).Code; code != http.StatusNotFound { + t.Errorf("/avatar/%q answered %d, and only an account name may be drawn", name, code) + } + } + + w := get(h, "/avatar/sso:carol", nil) + if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "image/svg+xml" { + t.Errorf("a usable name answered %d as %q", w.Code, w.Header().Get("Content-Type")) + } + if body := w.Body.String(); strings.Contains(body, "<script") || !strings.Contains(body, "<svg") { + t.Errorf("the drawing is not a drawing: %q", body) + } +} diff --git a/account.go b/account.go new file mode 100644 index 0000000..2cb15c1 --- /dev/null +++ b/account.go @@ -0,0 +1,695 @@ +package main + +// The account's own pages: signing in and out, settings, passwords, addresses, +// push tokens, and the second factor. + +import ( + "crypto/hmac" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +// loginPage lists the external providers by key and label alone: the template +// is handed nothing it must not print. +type loginPage struct{ Providers []loginLink } + +type loginLink struct{ Key, Label string } + +func (f *Forge) loginData() loginPage { + var links []loginLink + for _, p := range f.cfg.OIDC { + links = append(links, loginLink{Key: p.Key, Label: p.Label}) + } + return loginPage{Providers: links} +} + +func (f *Forge) handleLogin(w http.ResponseWriter, r *http.Request) { + if _, _, ok := f.user(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + d := f.page(r) + d.Data = f.loginData() + f.render(w, "login.html", http.StatusOK, d) +} + +func (f *Forge) handleLoginPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + // Failures only, per address only: per account, anybody could lock a + // victim out. + if f.loginLimit.over("login:"+f.peer(r), loginsPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many failed sign in attempts from here, try again later") + return + } + name := accountName(r.PostFormValue("name")) + password := r.PostFormValue("password") + + u, ok := f.store.User(name) + if !ok || u.Origin != originLocal { + u = User{} // hash anyway, so the two failures cost the same + } + if !passwordOK(u.Password, password) { + f.loginLimit.charge("login:" + f.peer(r)) + log.Printf("login refused for %q from %s", name, f.peer(r)) + d := f.page(r) + d.Error = "wrong account name or password" + d.Data = f.loginData() + f.render(w, "login.html", http.StatusUnauthorized, d) + return + } + if u.TOTP != nil { + f.askForCode(w, r, name, u, "") + return + } + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +type totpPage struct{ Name, Expires, Token string } + +// askForCode is the second step of a login. The half-finished login is carried +// as the account, a short expiry, and a MAC over both plus the epoch, which an +// external account has where it has no password. +func (f *Forge) askForCode(w http.ResponseWriter, r *http.Request, name string, u User, message string) { + expires := strconv.FormatInt(time.Now().Add(totpPendLife).Unix(), 10) + d := f.page(r) + d.Error = message + d.Data = totpPage{ + Name: name, Expires: expires, + Token: f.mac("totp-pending", name, expires, strconv.Itoa(u.Epoch)), + } + status := http.StatusOK + if message != "" { + status = http.StatusUnauthorized + } + f.render(w, "totp.html", status, d) +} + +func (f *Forge) handleLoginTOTP(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, expires := r.PostFormValue("name"), r.PostFormValue("expires") + unix, err := strconv.ParseInt(expires, 10, 64) + u, ok := f.store.User(name) + if err != nil || !ok || u.TOTP == nil || time.Now().After(time.Unix(unix, 0)) { + f.fail(w, r, http.StatusForbidden, "that login took too long. sign in again") + return + } + if !hmac.Equal([]byte(r.PostFormValue("t")), []byte(f.mac("totp-pending", name, expires, strconv.Itoa(u.Epoch)))) { + f.fail(w, r, http.StatusForbidden, "that login did not start here. sign in again") + return + } + // Six digits is not many. + if !f.totpLimit.allow(f.peer(r), Viewer{Name: name}, totpPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many codes tried in the last hour, try again later") + return + } + + code := strings.TrimSpace(r.PostFormValue("code")) + switch { + case f.codeOK(name, u, code): + case f.spendRecoveryCode(name, u, code): + f.mailAccount(u, "a recovery code was used", + "A recovery code was used to sign in to "+name+" on "+f.siteName()+ + ". It cannot be used again.\n") + u, _ = f.store.User(name) + default: + f.askForCode(w, r, name, u, "that code is not right") + return + } + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// spendRecoveryCode removes the code in the same write that accepts it, and +// decides inside that write, so two logins with one code cannot both succeed. +func (f *Forge) spendRecoveryCode(name string, u User, code string) bool { + want := tokenHash(strings.TrimSpace(code)) + err := f.store.UpdateUser(name, "users: spend a recovery code for "+name, f.store.author(name, u), + func(acct *User) error { + if acct.TOTP == nil { + return errBadCode + } + left := []string{} + for _, kept := range acct.TOTP.Recovery { + if !hmac.Equal([]byte(kept), []byte(want)) { + left = append(left, kept) + } + } + if len(left) == len(acct.TOTP.Recovery) { + return errBadCode + } + acct.TOTP.Recovery = left + return nil + }) + if err != nil && !errors.Is(err, errBadCode) { + log.Printf("totp: %v", err) + } + return err == nil +} + +type enrollPage struct { + Secret string // only when there is nothing enrolled yet + URI string // the same secret, as an otpauth link + Enrolled bool + Required bool // an admin may not turn it off + Local bool // an external account has no password to ask for + Left int // recovery codes still unspent + Codes []string // shown once, right after they are made +} + +// The proposed secret lives in the form until a code proves the authenticator +// has it. +func (f *Forge) handleTOTP(w http.ResponseWriter, r *http.Request) { + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + d := f.page(r) + d.Data = f.enrollData(name, u, "", nil) + f.render(w, "totp-enroll.html", http.StatusOK, d) +} + +// secret is the one being enrolled: empty mints a fresh one, and a retry keeps +// the one already typed into the authenticator. +func (f *Forge) enrollData(name string, u User, secret string, codes []string) enrollPage { + page := enrollPage{Required: u.Admin, Codes: codes, Local: u.Origin == originLocal} + if u.TOTP != nil { + page.Enrolled, page.Left = true, len(u.TOTP.Recovery) + return page + } + if secret == "" { + secret = totpSecret() + } + page.Secret, page.URI = secret, f.totpURI(name, secret) + return page +} + +// proveOwner stands between a stolen cookie and a change that outlives the +// session: a local account is asked for its password, an external one for a +// code, and one with neither has nothing to be asked for. +func (f *Forge) proveOwner(w http.ResponseWriter, r *http.Request, name string, u User, secret string) bool { + if u.Origin != originLocal { + return u.TOTP == nil || f.confirmCode(w, r) + } + if passwordOK(u.Password, r.PostFormValue("current")) { + return true + } + f.enrollError(w, r, name, u, secret, "the current password is wrong", http.StatusUnauthorized) + return false +} + +// enrollError draws the authenticator page again with a message and the same +// secret. +func (f *Forge) enrollError(w http.ResponseWriter, r *http.Request, name string, u User, secret, msg string, status int) { + d := f.page(r) + d.Error = msg + d.Data = f.enrollData(name, u, secret, nil) + f.render(w, "totp-enroll.html", status, d) +} + +func (f *Forge) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + secret := strings.TrimSpace(r.PostFormValue("secret")) + if u.TOTP != nil { + f.fail(w, r, http.StatusConflict, "this account already has an authenticator. remove it first") + return + } + if !validSecret(secret) { + f.fail(w, r, http.StatusBadRequest, "that is not a secret this forge minted. start again from the settings page") + return + } + // Without this a stolen cookie takes the account for good. + if !f.proveOwner(w, r, name, u, secret) { + return + } + step := totpStep(secret, r.PostFormValue("code"), time.Now()) + if step < 0 { + f.enrollError(w, r, name, u, secret, + "that code is not right. check the clock on the device, and try the next code", + http.StatusBadRequest) + return + } + + show, keep := newRecoveryCodes() + err := f.store.UpdateUser(name, "users: enroll an authenticator for "+name, f.store.author(name, u), + func(acct *User) error { + // The enrolling code is spent too. + acct.TOTP = &TOTP{Secret: secret, Recovery: keep, Used: step} + acct.Epoch++ // every other session dies with the change + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "an authenticator was enrolled", + "An authenticator was enrolled for "+name+" on "+f.siteName()+ + ", and every other session was signed out.\n") + + d := f.pageFor(name, u, f.setSession(w, name, u.Epoch)) + d.Notice = "enrolled. write these recovery codes down now: each one works once, and they are not shown again" + d.Data = f.enrollData(name, u, "", show) + f.render(w, "totp-enroll.html", http.StatusOK, d) +} + +func (f *Forge) handleTOTPRemove(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + if u.Admin { + f.fail(w, r, http.StatusForbidden, + "an account with the global admin flag must keep an authenticator. have another admin take the flag away first") + return + } + if !f.proveOwner(w, r, name, u, "") { + return + } + err := f.store.UpdateUser(name, "users: remove the authenticator for "+name, f.store.author(name, u), + func(acct *User) error { + acct.TOTP = nil + acct.Epoch++ + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "the authenticator was removed", + "The authenticator for "+name+" on "+f.siteName()+ + " was removed, and every other session was signed out.\n") + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/settings/totp", http.StatusSeeOther) +} + +func (f *Forge) handleTOTPCodes(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + if u.TOTP == nil { + f.fail(w, r, http.StatusConflict, "this account has no authenticator") + return + } + // New codes are new keys to the account, so they cost a proof too. + if !f.proveOwner(w, r, name, u, "") { + return + } + show, keep := newRecoveryCodes() + err := f.store.UpdateUser(name, "users: regenerate recovery codes for "+name, f.store.author(name, u), + func(acct *User) error { + // An admin can clear it between the check above and this write. + if acct.TOTP == nil { + return userError("this account has no authenticator") + } + acct.TOTP.Recovery = keep + return nil + }) + if err != nil { + f.problem(w, r, http.StatusConflict, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "recovery codes were regenerated", + "New recovery codes were made for "+name+" on "+f.siteName()+". The old ones no longer work.\n") + + d := f.page(r) + d.Notice = "write these down now: each one works once, and they are not shown again" + d.Data = f.enrollData(name, u, "", show) + f.render(w, "totp-enroll.html", http.StatusOK, d) +} + +func (f *Forge) handleLogout(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + f.clearSession(w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// settingsPage is the account's own record, for its own page. +type settingsPage struct { + Email string + EmailConfirmed bool + Notify bool + Local bool // an external account has no password to change here + Tokens []Token + Secret string // a push token just minted, shown once +} + +func settingsFor(u User, d pageData) pageData { + d.Data = settingsPage{ + Email: u.Email, EmailConfirmed: u.EmailConfirmed, + Notify: u.Notify, Local: u.Origin == originLocal, Tokens: u.Tokens, + } + return d +} + +func (f *Forge) handleSettings(w http.ResponseWriter, r *http.Request) { + _, u, ok := f.requireUser(w, r) + if !ok { + return + } + f.render(w, "settings.html", http.StatusOK, settingsFor(u, f.page(r))) +} + +// The current password is required: a stolen cookie must not be enough. +func (f *Forge) handlePassword(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + + current := r.PostFormValue("current") + next := r.PostFormValue("new") + again := r.PostFormValue("again") + + d := f.page(r) + switch { + case u.Origin != originLocal: + d.Error = "this account signs in through an external provider" + case !passwordOK(u.Password, current): + d.Error = "the current password is wrong" + case next != again: + d.Error = "the two new passwords are not the same" + case len(next) < minPassword: + d.Error = fmt.Sprintf("the new password must be at least %d characters", minPassword) + } + if d.Error != "" { + f.render(w, "settings.html", http.StatusBadRequest, settingsFor(u, d)) + return + } + + hash := hashPassword(next) + err := f.store.UpdateUser(name, "users: change password for "+name, f.store.author(name, u), + func(acct *User) error { + acct.Password = hash + acct.Epoch++ // every other session for this account dies here + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + + // The bump killed this browser's cookie too; reissue it. + u, _ = f.store.User(name) + f.mailAccount(u, "your password changed", + "The password for "+name+" on "+f.siteName()+" was just changed, and every other session was signed out.\n\n"+ + "If that was not you, ask an admin for a new set-password link.\n") + d = f.pageFor(name, u, f.setSession(w, name, u.Epoch)) + d.Notice = "password changed, and every other session was signed out" + f.render(w, "settings.html", http.StatusOK, settingsFor(u, d)) +} + +type setPasswordPage struct{ Name, Expires, Token string } + +func (f *Forge) handleSetPassword(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if _, _, ok := f.checkSetPassword(q); !ok { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + d := f.page(r) + d.Data = setPasswordPage{Name: q.Get("name"), Expires: q.Get("expires"), Token: q.Get("t")} + f.render(w, "set-password.html", http.StatusOK, d) +} + +func (f *Forge) handleSetPasswordPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.checkSetPassword(r.PostForm) + if !ok { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + + next, again := r.PostFormValue("new"), r.PostFormValue("again") + d := f.page(r) + d.Data = setPasswordPage{Name: name, Expires: r.PostFormValue("expires"), Token: r.PostFormValue("t")} + switch { + case next != again: + d.Error = "the two passwords are not the same" + case len(next) < minPassword: + d.Error = fmt.Sprintf("the password must be at least %d characters", minPassword) + } + if d.Error != "" { + f.render(w, "set-password.html", http.StatusBadRequest, d) + return + } + + hash := hashPassword(next) + err := f.store.UpdateUser(name, "users: set password for "+name, f.store.author(name, u), + func(acct *User) error { + acct.Password = hash + acct.Epoch++ + // Following the link is what proves the address, when there is one. + acct.EmailConfirmed = acct.EmailConfirmed || acct.Email != "" + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "your password was set", + "A password for "+name+" on "+f.siteName()+" was just set through a one time link.\n") + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// Reading is what allows minting: a token is a credential, not a permission, +// and what it may do is decided on arrival. +func (f *Forge) handleTokenMint(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + v := f.viewer(r) + path := strings.Trim(strings.TrimSpace(r.PostFormValue("repo")), "/") + repo, exists := f.store.Repo(path) + if !exists || !v.CanRead(repo) { + d := settingsFor(u, f.page(r)) + d.Error = "there is no repository called " + path + " that you can reach" + f.render(w, "settings.html", http.StatusForbidden, d) + return + } + // Every mint is a commit, and every account can reach every public repo. + if !f.tokenLimit.allow(f.peer(r), v, tokensPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many tokens minted in the last hour, try again later") + return + } + + secret, token := mintToken(path) + err := f.store.UpdateUser(name, "users: mint a push token for "+name+" on "+path, f.store.author(name, u), + func(acct *User) error { + // Under the lock, so two at once cannot both find room. + if len(acct.Tokens) >= maxTokens { + return userError(fmt.Sprintf( + "this account already holds %d tokens, which is all it may hold. revoke one first", maxTokens)) + } + acct.Tokens = append(append([]Token(nil), acct.Tokens...), token) + return nil + }) + if err != nil { + f.problem(w, r, http.StatusConflict, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "a push token was minted", + "A push token for "+path+" was minted for "+name+" on "+f.siteName()+ + ", with the id "+token.ID+".\n\nIf that was not you, revoke it at "+f.url("/settings")+"\n") + + d := settingsFor(u, f.page(r)) + d.Notice = "copy this token now: it is the password for git over http, it carries your own " + + "access to " + path + " and nothing more, and it is not shown again" + page := d.Data.(settingsPage) + page.Secret = secret + d.Data = page + f.render(w, "settings.html", http.StatusOK, d) +} + +func (f *Forge) handleTokenRevoke(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + id := r.PostFormValue("id") + var revoked Token + err := f.store.UpdateUser(name, "users: revoke a push token for "+name, f.store.author(name, u), + func(acct *User) error { + left := []Token{} + for _, t := range acct.Tokens { + if t.ID == id { + revoked = t + continue + } + left = append(left, t) + } + if revoked.ID == "" { + return userError("no such token") + } + acct.Tokens = left + return nil + }) + if err != nil { + f.problem(w, r, http.StatusNotFound, err) + return + } + u, _ = f.store.User(name) + f.mailAccount(u, "a push token was revoked", + "The push token "+revoked.ID+" for "+revoked.Repo+" on "+f.siteName()+" was revoked.\n") + http.Redirect(w, r, "/settings", http.StatusSeeOther) +} + +func (f *Forge) handleConfirmEmail(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + name, expires := q.Get("name"), q.Get("expires") + unix, err := strconv.ParseInt(expires, 10, 64) + u, ok := f.store.User(name) + if err != nil || !ok || time.Now().After(time.Unix(unix, 0)) { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + if !hmac.Equal([]byte(q.Get("t")), []byte(f.mac("confirm-email", name, expires, u.Email))) { + f.fail(w, r, http.StatusNotFound, "this link has expired or has already been used") + return + } + err = f.store.UpdateUser(name, "users: confirm the address of "+name, f.store.author(name, u), + func(acct *User) error { + acct.EmailConfirmed = true + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + d := f.page(r) + d.Notice = "that address is confirmed, and will now receive notifications" + f.render(w, "home.html", http.StatusOK, f.withIndex(r, d)) +} + +// A new address is unconfirmed until a link sent to it is followed, and an +// unconfirmed address receives nothing else. +func (f *Forge) handleEmail(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + email := strings.TrimSpace(r.PostFormValue("email")) + notify := r.PostFormValue("notify") != "" + if !validEmail(email) { + d := f.page(r) + d.Error = "that does not look like an email address" + f.render(w, "settings.html", http.StatusBadRequest, settingsFor(u, d)) + return + } + + changed := email != u.Email + err := f.store.UpdateUser(name, "users: set the address and notifications of "+name, f.store.author(name, u), + func(acct *User) error { + acct.Notify = notify + if changed { + acct.Email, acct.EmailConfirmed = email, false + } + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + + u, _ = f.store.User(name) + d := settingsFor(u, f.page(r)) + d.Notice = "saved" + // An address still unproved gets another link. + if email != "" && (changed || !u.EmailConfirmed) { + f.mailLink(email, "confirm this address", + "Follow this link to confirm this address for "+name+":\n\n"+f.confirmEmailLink(name, u)+"\n") + d.Notice = "saved, and a link to confirm the address is on its way to it" + } + f.render(w, "settings.html", http.StatusOK, d) +} + +// The same sentence whether or not the account exists. +func (f *Forge) handleReset(w http.ResponseWriter, r *http.Request) { + f.render(w, "reset.html", http.StatusOK, f.page(r)) +} + +func (f *Forge) handleResetPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + d := f.page(r) + d.Notice = "if that account exists and has an address on file, a link to set a new password is on its way to it" + + if !f.resetLimit.allow(f.peer(r), f.viewer(r), resetsPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many reset requests in the last hour, try again later") + return + } + name := accountName(r.PostFormValue("name")) + if u, ok := f.store.User(name); ok && u.Origin == originLocal && u.Email != "" { + f.mailLink(u.Email, "set a new password", + "Somebody asked for a new password for "+name+". Follow this link within an hour to set one:\n\n"+ + f.passwordLink(name, u, resetLife)+"\n\nIf that was not you, nothing has happened and you can ignore this.\n") + } + f.render(w, "reset.html", http.StatusOK, d) +} + +// Without a session store, signing out everywhere is an epoch bump. +func (f *Forge) handleSignOut(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + name, u, ok := f.requireUser(w, r) + if !ok { + return + } + err := f.store.UpdateUser(name, "users: sign out every session for "+name, f.store.author(name, u), + func(acct *User) error { + acct.Epoch++ + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + f.clearSession(w) + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..cb03131 --- /dev/null +++ b/admin.go @@ -0,0 +1,720 @@ +package main + +import ( + "crypto/hmac" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strings" +) + +// Administration is web only, so the first admin cannot come from a shell. +func (f *Forge) setupOpen() bool { + return f.setupToken.Load() != nil && len(f.store.Users()) == 0 +} + +func (f *Forge) setupTokenOK(given string) bool { + want := f.setupToken.Load() + return want != nil && hmac.Equal([]byte(given), []byte(*want)) +} + +func (f *Forge) handleSetup(w http.ResponseWriter, r *http.Request) { + if !f.setupOpen() { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + f.render(w, "setup.html", http.StatusOK, f.page(r)) +} + +func (f *Forge) handleSetupPost(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) { + return + } + if !f.setupOpen() { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + + token := r.PostFormValue("token") + name := accountName(r.PostFormValue("name")) + email := strings.TrimSpace(r.PostFormValue("email")) + password, again := r.PostFormValue("password"), r.PostFormValue("again") + + d := f.page(r) + switch { + case !f.setupTokenOK(token): + d.Error = "wrong setup token, it was printed to the log at startup" + case !localNameRE.MatchString(name): + d.Error = "the account name must be lowercase letters, digits, dot, dash, or underscore" + case !validEmail(email): + d.Error = "that does not look like an email address" + case password != again: + d.Error = "the two passwords are not the same" + case len(password) < minPassword: + d.Error = fmt.Sprintf("the password must be at least %d characters", minPassword) + } + if d.Error != "" { + f.render(w, "setup.html", http.StatusBadRequest, d) + return + } + + admin := User{Origin: originLocal, Admin: true, Email: email, Notify: true, Password: hashPassword(password)} + err := f.store.UpdateUsers("users: create the first admin, "+name, Author{Name: name, Email: email}, + func(m map[string]User) error { + if len(m) > 0 { + return userError("an account already exists") + } + m[name] = admin + return nil + }) + if err != nil { + f.problem(w, r, http.StatusConflict, err) + return + } + f.setupToken.Store(nil) + if email != "" { + f.mailLink(email, "confirm this address", + "Follow this link to confirm this address for "+name+":\n\n"+f.confirmEmailLink(name, admin)+"\n") + } + f.setSession(w, name, admin.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// requireAdmin gates the global admin pages; repository grants do not reach +// them. +func (f *Forge) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + name, u, ok := f.user(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return false + } + if !u.Admin { + f.fail(w, r, http.StatusForbidden, "this page is for global admins") + return false + } + if u.TOTP == nil { + log.Printf("admin: %s has the admin flag and no authenticator yet", name) + http.Redirect(w, r, "/settings/totp", http.StatusSeeOther) + return false + } + return true +} + +// adminPage is the one admin page. +type adminPage struct { + New Repo // the defaults the create form starts from + Repos []RepoEntry + Users map[string]User + Site *Site + Link string // a set-password link just minted, for the admin to convey + For string +} + +func (f *Forge) adminData() adminPage { + return adminPage{ + New: orphanRepo(), // private, both features off: the safe defaults + Repos: f.store.Repos(), Users: f.store.Users(), Site: f.site.Load(), + } +} + +// formImage reads and checks one upload, writing nothing. A nil body is no +// upload. +func formImage(r *http.Request, field string) ([]byte, error) { + // Not multipart, or no file in it: no upload. + file, _, err := r.FormFile(field) + if errors.Is(err, http.ErrMissingFile) || errors.Is(err, http.ErrNotMultipart) { + return nil, nil + } + if err != nil { + return nil, err + } + defer file.Close() + + body, err := io.ReadAll(io.LimitReader(file, maxImage+1)) + if err != nil { + return nil, err + } + if len(body) == 0 { + return nil, nil + } + if err := checkImage(body); err != nil { + return nil, err + } + return body, nil +} + +// The name, subtitle, logo, and favicon are the only parts of forge.json a +// form may touch: the rest decides how the process starts. An upload goes in +// the config directory, so it is in the backup and the history. +func (f *Forge) handleSiteSettings(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + name := strings.TrimSpace(r.PostFormValue("site_name")) + desc := strings.TrimSpace(r.PostFormValue("site_desc")) + site := f.site.Load() + logo, icon := site.Logo.Name, site.Icon.Name + + // Refused before anything is written, so a refused save changes nothing. + if !validSiteName(name) { + f.adminError(w, r, errSiteName) + return + } + logoBody, err := formImage(r, "logo_file") + if err != nil { + f.adminError(w, r, err) + return + } + iconBody, err := formImage(r, "icon_file") + if err != nil { + f.adminError(w, r, err) + return + } + + // What this request wrote over, and what was there, so a save that fails + // afterwards can put it back. + var written []swap + if r.PostFormValue("logo") == "cgit" { + logo = "" // back to the one the image ships + } else if logoBody != nil { + if err := writeFile(f.store.File("logo"), logoBody, 0600); err != nil { + f.adminError(w, r, err) + return + } + written = append(written, swap{"logo", site.Logo}) + logo = "logo" + } + if r.PostFormValue("icon") == "cgit" { + icon = "" + } else if iconBody != nil { + if err := writeFile(f.store.File("icon"), iconBody, 0600); err != nil { + f.unwrite(written) + f.adminError(w, r, err) + return + } + written = append(written, swap{"icon", site.Icon}) + icon = "icon" + } + + // Read back before the config names it. + next, err := loadSite(f.store.configDir, name, desc, logo, icon) + if err == nil { + err = f.store.WriteConfig(f.actor(r), func(cfg *Config) { + cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon = name, desc, logo, icon + }) + } + if err != nil { + f.unwrite(written) + f.adminError(w, r, err) + return + } + f.site.Store(next) + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// swap is one image file a save wrote over, and what was there. +type swap struct { + file string + old Image +} + +// unwrite puts back what a failed save wrote over: the next start reads the +// file the config names. +func (f *Forge) unwrite(written []swap) { + for _, w := range written { + path := f.store.File(w.file) + err := os.Remove(path) + if w.old.Name == w.file { + err = writeFile(path, w.old.Body, 0600) + } + if err != nil { + log.Printf("admin: %s holds a refused image: %v", path, err) + } + } +} + +func (f *Forge) handleAdmin(w http.ResponseWriter, r *http.Request) { + if !f.requireAdmin(w, r) { + return + } + d := f.page(r) + d.Data = f.adminData() + f.render(w, "admin.html", http.StatusOK, d) +} + +// confirmCode asks the acting account for a code: the session says who they +// are, this says they are at the keyboard. Only a wrong code is counted, on +// the budget the login form spends. +func (f *Forge) confirmCode(w http.ResponseWriter, r *http.Request) bool { + name, u, ok := f.user(r) + if !ok || u.TOTP == nil { + f.fail(w, r, http.StatusForbidden, + "this asks for an authenticator code, and this account holds no authenticator. enrol one under settings") + return false + } + if f.totpLimit.over(accountKey(name), totpPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many codes tried in the last hour, try again later") + return false + } + if !f.codeOK(name, u, r.PostFormValue("code")) { + f.totpLimit.charge(accountKey(name)) + f.fail(w, r, http.StatusForbidden, + "that authenticator code is not right, or was used already. wait for the next one. nothing was changed") + return false + } + return true +} + +// adminError draws /admin again with a message on it: there is nowhere to keep +// one across a redirect. +func (f *Forge) adminError(w http.ResponseWriter, r *http.Request, err error) { + var mine userError + if !errors.As(err, &mine) { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + d := f.page(r) + d.Data = f.adminData() + d.Error = mine.Error() + f.render(w, "admin.html", http.StatusBadRequest, d) +} + +func (f *Forge) handleRepoCreate(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + section := strings.TrimSpace(r.PostFormValue("section")) + name := strings.TrimSpace(r.PostFormValue("name")) + path := name + if section != "" { + path = section + "/" + name + } + repo := Repo{ + Desc: strings.TrimSpace(r.PostFormValue("desc")), + Visibility: r.PostFormValue("visibility"), + Issues: r.PostFormValue("issues"), + Wiki: r.PostFormValue("wiki"), + Labels: []string{}, + Grants: map[string]string{}, + } + if err := f.store.CreateRepo(path, repo, f.actor(r)); err != nil { + f.adminError(w, r, err) + return + } + who := f.viewer(r).Name + f.mailAdmins(who, "repository "+path+" created", + who+" created "+path+", "+repo.Visibility+".\n\n"+f.url("/"+path+"/")+"\n") + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// handleRepoAdopt records the safe settings a directory with no entry in +// repos.json is already being served with. +func (f *Forge) handleRepoAdopt(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + path := r.PostFormValue("path") + if _, ok := f.store.Repo(path); !ok { + f.fail(w, r, http.StatusNotFound, "no such repository") + return + } + err := f.store.UpdateRepos("repos: adopt "+path, f.actor(r), func(m map[string]Repo) error { + if _, ok := m[path]; ok { + return userError(path + " already has an entry") + } + m[path] = orphanRepo() + return nil + }) + if err != nil { + f.adminError(w, r, err) + return + } + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// An admin never handles a password: a one time link is shown and, with mail, +// sent. It outlives this session, so it costs the admin's own code. +func (f *Forge) handleUserCreate(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + email := strings.TrimSpace(r.PostFormValue("email")) + + switch { + case !localNameRE.MatchString(name): + f.adminError(w, r, userError("the account name must be lowercase letters, digits, dot, dash, or underscore")) + return + case !validEmail(email): + f.adminError(w, r, userError("that does not look like an email address")) + return + } + + // No admin flag here: handing that out is a separate act. + acct := User{Origin: originLocal, Email: email, Notify: true} + err := f.store.UpdateUsers("users: create "+name, f.actor(r), func(m map[string]User) error { + if _, ok := m[name]; ok { + return userError(name + " already exists") + } + m[name] = acct + return nil + }) + if err != nil { + f.adminError(w, r, err) + return + } + + link := f.passwordLink(name, acct, setPasswordLife) + if acct.Email != "" { + f.mailLink(acct.Email, "set a password for "+name, + "An admin made you an account named "+name+" on "+f.siteName()+ + ". Follow this link within two days to set a password:\n\n"+link+"\n") + } + d := f.page(r) + data := f.adminData() + data.Link, data.For = link, name + d.Data = data + f.render(w, "admin.html", http.StatusOK, d) +} + +func (f *Forge) handleUserAdmin(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + admin := r.PostFormValue("admin") != "" + verb := "clear" + if admin { + verb = "set" + } + + err := f.store.UpdateUsers(fmt.Sprintf("users: %s the admin flag on %s", verb, name), f.actor(r), + func(m map[string]User) error { + acct, ok := m[name] + if !ok { + return userError(name + " does not exist") + } + acct.Admin = admin + m[name] = acct + return lastAdminCheck(m) + }) + if err != nil { + f.adminError(w, r, err) + return + } + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// handleUserTOTP clears another account's second factor, the way back in when +// both authenticator and recovery codes are lost. Never an account's own. +func (f *Forge) handleUserTOTP(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + if acting, _, _ := f.user(r); acting == name { + f.fail(w, r, http.StatusForbidden, "clear your own authenticator from your settings, where it asks for your password") + return + } + if !f.confirmCode(w, r) { + return + } + err := f.store.UpdateUsers("users: clear the authenticator for "+name, f.actor(r), + func(m map[string]User) error { + acct, ok := m[name] + if !ok { + return userError(name + " does not exist") + } + if acct.TOTP == nil { + return userError(name + " has no authenticator") + } + acct.TOTP = nil + acct.Epoch++ // and every session for that account with it + m[name] = acct + return nil + }) + if err != nil { + f.adminError(w, r, err) + return + } + if u, ok := f.store.User(name); ok { + f.mailAccount(u, "your authenticator was cleared", + "An admin cleared the authenticator for "+name+" on "+f.siteName()+ + ", and signed out every session for it. Enrol a new one when you next sign in.\n") + } + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// Every grant pointing at the account goes with it. +func (f *Forge) handleUserDelete(w http.ResponseWriter, r *http.Request) { + if !f.checkPost(w, r) || !f.requireAdmin(w, r) { + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("name")) + err := f.store.UpdateUsers("users: delete "+name, f.actor(r), func(m map[string]User) error { + if _, ok := m[name]; !ok { + return userError(name + " does not exist") + } + delete(m, name) + return lastAdminCheck(m) + }) + if err != nil { + f.adminError(w, r, err) + return + } + + err = f.store.UpdateRepos("repos: drop grants for "+name, f.actor(r), func(m map[string]Repo) error { + for path, repo := range m { + if _, ok := repo.Grants[name]; !ok { + continue + } + grants := map[string]string{} + for who, level := range repo.Grants { + if who != name { + grants[who] = level + } + } + repo.Grants = grants + m[path] = repo + } + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// lastAdminCheck refuses the change that would leave the forge with no way in. +func lastAdminCheck(m map[string]User) error { + for _, u := range m { + if u.Admin { + return nil + } + } + return userError("that would leave no global admin, and there is no way to make one from a shell") +} + +// repoSettings is the per repository page, for the admin grant. +func (f *Forge) repoSettings(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + v := f.viewer(r) + if !v.CanAdmin(repo) { + f.fail(w, r, http.StatusForbidden, "you do not administer this repository") + return + } + _, u, _ := f.user(r) // administering a repository means being signed in + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "settings", repo) + d.Data = repoSettingsPage{ + Path: path, Repo: repo, Labels: strings.Join(repo.Labels, "\n"), + Delete: v.Admin, Enrolled: u.TOTP != nil, + } + f.render(w, "repo-settings.html", http.StatusOK, d) +} + +type repoSettingsPage struct { + Path string + Repo Repo + Labels string // one per line, for the textarea + Delete bool // deletion is the global admin flag's alone + // Enrolled says whether the grant forms are drawn: they ask for a code. + Enrolled bool +} + +func (f *Forge) repoSettingsSave(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + v := f.viewer(r) + if !v.CanAdmin(repo) { + f.fail(w, r, http.StatusForbidden, "you do not administer this repository") + return + } + next := repo + next.Desc = strings.TrimSpace(r.PostFormValue("desc")) + next.Visibility = r.PostFormValue("visibility") + next.Issues = r.PostFormValue("issues") + next.Wiki = r.PostFormValue("wiki") + next.Labels = splitLabels(r.PostFormValue("labels")) + + if errs := next.validate(path); len(errs) > 0 { + _, u, _ := f.user(r) + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "settings", repo) + d.Data = repoSettingsPage{ + Path: path, Repo: next, Labels: r.PostFormValue("labels"), + Delete: v.Admin, Enrolled: u.TOTP != nil, + } + d.Error = errors.Join(errs...).Error() + f.render(w, "repo-settings.html", http.StatusBadRequest, d) + return + } + // These three hand out access that outlives the session, the way a grant + // does; a description or a label does not. + if next.Visibility != repo.Visibility || next.Issues != repo.Issues || next.Wiki != repo.Wiki { + if !f.confirmCode(w, r) { + return + } + } + // First, so a knob is never on with no repository behind it. + if err := f.store.EnsureSiblings(path, next); err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + err := f.store.UpdateRepos("repos: update settings for "+path, f.actor(r), func(m map[string]Repo) error { + // Onto the record as it is now: a grant that landed meanwhile has to + // survive. + entry, ok := m[path] + if !ok { + entry = orphanRepo() + } + entry.Desc, entry.Visibility = next.Desc, next.Visibility + entry.Issues, entry.Wiki, entry.Labels = next.Issues, next.Wiki, next.Labels + m[path] = entry + return nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + if next.Visibility != repo.Visibility { + f.mailAdmins(v.Name, "repository "+path+" is now "+next.Visibility, + v.Name+" changed "+path+" from "+repo.Visibility+" to "+next.Visibility+".\n\n"+f.url("/"+path+"/")+"\n") + } + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// splitLabels reads the textarea, one label per line, once each. +func splitLabels(s string) []string { + seen := map[string]bool{} + out := []string{} + for _, line := range strings.Split(s, "\n") { + label := strings.TrimSpace(line) + if label == "" || seen[label] { + continue + } + seen[label] = true + out = append(out, label) + } + sort.Strings(out) + return out +} + +// repoGrant adds, changes, or revokes one grant. An empty level revokes. +func (f *Forge) repoGrant(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + if !f.viewer(r).CanAdmin(repo) { + f.fail(w, r, http.StatusForbidden, "you do not administer this repository") + return + } + if !f.confirmCode(w, r) { + return + } + name := accountName(r.PostFormValue("account")) + level := r.PostFormValue("level") + + if _, ok := f.store.User(name); !ok && level != "" { + f.fail(w, r, http.StatusBadRequest, "no such account: "+name) + return + } + subject := fmt.Sprintf("repos: grant %s %s on %s", name, level, path) + if level == "" { + subject = fmt.Sprintf("repos: revoke %s on %s", name, path) + } + err := f.store.UpdateRepos(subject, f.actor(r), func(m map[string]Repo) error { + // A grant on an unadopted directory adopts it as served. + entry, ok := m[path] + if !ok { + entry = orphanRepo() + } + grants := map[string]string{} + for who, l := range entry.Grants { + if who != name { + grants[who] = l + } + } + if level != "" { + grants[name] = level + } + entry.Grants = grants + if errs := entry.validate(path); len(errs) > 0 { + return userError(errors.Join(errs...).Error()) + } + m[path] = entry + return nil + }) + if err != nil { + f.problem(w, r, http.StatusBadRequest, err) + return + } + if u, ok := f.store.User(name); ok { + what := "you now have " + level + " on " + path + if level == "" { + what = "your access to " + path + " has been revoked" + } + f.mailAccount(u, "access to "+path+" changed", what+".\n\n"+f.url("/"+path+"/")+"\n") + } + http.Redirect(w, r, "/"+path+"/settings", http.StatusSeeOther) +} + +// repoDelete removes the repository and both siblings, confirmed by typing the +// path: there is no undo. +func (f *Forge) repoDelete(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + if !f.viewer(r).Admin { + f.fail(w, r, http.StatusForbidden, "deleting a repository is for global admins") + return + } + if r.PostFormValue("confirm") != path { + f.fail(w, r, http.StatusBadRequest, "type the repository path exactly to confirm") + return + } + if !f.confirmCode(w, r) { + return + } + if err := f.store.DeleteRepo(path, f.actor(r)); err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + // A token is scoped to a path, and a path can be created again. + err := f.store.UpdateUsers("users: drop push tokens for "+path, f.actor(r), func(m map[string]User) error { + for name, u := range m { + left := []Token{} + for _, t := range u.Tokens { + if t.Repo != path { + left = append(left, t) + } + } + if len(left) != len(u.Tokens) { + u.Tokens = left + m[name] = u + } + } + return nil + }) + if err != nil { + log.Printf("repos: tokens for the deleted %s: %v", path, err) + } + who := f.viewer(r).Name + f.mailAdmins(who, "repository "+path+" deleted", + who+" deleted "+path+", with its issues and its wiki.\n") + http.Redirect(w, r, "/admin", http.StatusSeeOther) +} + +// validEmail is shallow: an address is proved by a link arriving at it. +func validEmail(email string) bool { + if email == "" { + return true + } + local, domain, ok := strings.Cut(email, "@") + return ok && local != "" && domain != "" && !strings.ContainsAny(email, " \t\r\n<>,;") +} diff --git a/admin_test.go b/admin_test.go new file mode 100644 index 0000000..94ba8c7 --- /dev/null +++ b/admin_test.go @@ -0,0 +1,692 @@ +package main + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// The name is on the header, in every mail subject, in the otpauth URI, and in +// the realm git prompts with. All four have to move together. +func TestSiteRenameReachesEverything(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + addRepo(t, f, "priv", Repo{Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled}) + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + + form := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, + "site_name": {"Renamed"}, "site_desc": {"a subtitle"}, + } + if w := post(h, "/admin/site", form, session); w.Code != http.StatusSeeOther { + t.Fatalf("saving the site: status %d", w.Code) + } + + if got := f.subject("hello"); got != "[Renamed] hello" { + t.Errorf("a mail subject reads %q", got) + } + if got := f.totpURI("alice", knownSecret); !strings.Contains(got, "Renamed") { + t.Errorf("the otpauth uri reads %q", got) + } + challenge := get(h, "/priv.git/info/refs?service=git-upload-pack", nil) + if got := challenge.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Renamed") { + t.Errorf("git is prompted with %q", got) + } + if body := get(h, "/", nil).Body.String(); !strings.Contains(body, "Renamed") { + t.Error("the header does not carry the new name") + } + + // Refused at the door rather than escaped everywhere the name lands. + form.Set("site_name", `Ren"amed`) + if w := post(h, "/admin/site", form, session); w.Code != http.StatusBadRequest { + t.Errorf("a name with a quote in it was accepted: status %d", w.Code) + } + if got := f.siteName(); got != "Renamed" { + t.Errorf("the refused name was stored anyway: %q", got) + } +} + +// saveSite posts the /admin/site form. A nil image leaves that part out, which +// is how the form arrives when no file was chosen. +func saveSite(t *testing.T, f *Forge, h http.Handler, session []*http.Cookie, name string, logo, icon []byte) int { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + mw.WriteField("csrf", f.csrf(withCookies("/", session))) + mw.WriteField("site_name", name) + mw.WriteField("site_desc", "") + add := func(field string, image []byte) { + if image == nil { + return + } + part, err := mw.CreateFormFile(field, field) + if err != nil { + t.Fatal(err) + } + part.Write(image) + } + add("logo_file", logo) + add("icon_file", icon) + mw.Close() + + r := httptest.NewRequest("POST", "/admin/site", &body) + r.Header.Set("Content-Type", mw.FormDataContentType()) + for _, c := range session { + r.AddCookie(c) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code +} + +// A save that was refused must change nothing. Both images are written to the +// config directory, so anything refused after one of them would leave the new +// file there, serving from the next restart. +func TestARefusedSaveKeepsTheImages(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + + kept := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("k", 64)) + refused := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("r", 64)) + svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`) + + if code := saveSite(t, f, h, session, "Good Name", kept, nil); code != http.StatusSeeOther { + t.Fatalf("saving a logo: status %d", code) + } + if code := saveSite(t, f, h, session, `Bad"Name`, refused, nil); code != http.StatusBadRequest { + t.Fatalf("a name with a quote in it: status %d", code) + } + // And a logo this save would otherwise have taken, refused for the favicon + // posted beside it. + if code := saveSite(t, f, h, session, "Good Name", refused, svg); code != http.StatusBadRequest { + t.Fatalf("an svg favicon: status %d", code) + } + + // What the running process serves, and what the next start would. + if got := f.site.Load().Logo.Body; !bytes.Equal(got, kept) { + t.Error("the refused image is already being served") + } + cfg, err := readConfig(f.store.File("forge.json")) + if err != nil { + t.Fatal(err) + } + site, err := loadSite(f.store.configDir, cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(site.Logo.Body, kept) { + t.Error("the refused image is what a restart would serve") + } + + // A save that fails after the image has landed: with no config file to + // rewrite, the config write fails, and the image has to be put back. + if err := os.Remove(f.store.File("forge.json")); err != nil { + t.Fatal(err) + } + if code := saveSite(t, f, h, session, "Good Name", refused, nil); code != http.StatusInternalServerError { + t.Fatalf("a save with no config file to write: status %d", code) + } + if got, err := os.ReadFile(f.store.File("logo")); err != nil || !bytes.Equal(got, kept) { + t.Errorf("the failed save left %d bytes where the next start reads the logo: %v", len(got), err) + } +} + +// A scalar override is an overlay. Reading through it to rewrite the file +// would turn the override into the file's own value, permanently. +func TestSavingTheSiteKeepsTheEnvironmentOut(t *testing.T) { + t.Setenv("GIT_FORGE_LISTEN", "127.0.0.1:9999") + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + if f.cfg.Listen != "127.0.0.1:9999" { + t.Fatalf("the override did not take: the process is on %q", f.cfg.Listen) + } + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + + form := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, + "site_name": {"Renamed"}, "site_desc": {""}, + } + if w := post(h, "/admin/site", form, session); w.Code != http.StatusSeeOther { + t.Fatalf("saving the site: status %d", w.Code) + } + data, err := os.ReadFile(f.store.File("forge.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"site_name": "Renamed"`) { + t.Fatalf("the name was not saved at all:\n%s", data) + } + if strings.Contains(string(data), "9999") { + t.Errorf("the environment was written into the file:\n%s", data) + } +} + +// /setup hands out the global admin flag, so two people posting the form at +// the same moment must not both get through it. +func TestSetupClosesOnce(t *testing.T) { + f := testForge(t) + token := randomToken(16) + f.setupToken.Store(&token) + h := f.routes() + + var wg sync.WaitGroup + for i := range 4 { + wg.Add(2) + go func() { + defer wg.Done() + post(h, "/setup", url.Values{ + "token": {token}, "name": {fmt.Sprintf("admin%d", i)}, + "password": {"hunter2hunter2"}, "again": {"hunter2hunter2"}, + }, nil) + }() + // And a reader of the same field, for the race detector. + go func() { defer wg.Done(); get(h, "/setup", nil) }() + } + wg.Wait() + + if n := len(f.store.Users()); n != 1 { + t.Errorf("%d accounts came out of one setup form", n) + } + if code := get(h, "/setup", nil).Code; code != http.StatusNotFound { + t.Errorf("/setup is still there after setup: status %d", code) + } +} + +// An uploaded image is bytes handed back to a browser. +func TestImageIsCheckedBeforeItIsKept(t *testing.T) { + png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 64)) + if err := checkImage(png); err != nil { + t.Errorf("a png was refused: %v", err) + } + svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`) + if err := checkImage(svg); err == nil { + t.Error("an svg was accepted, and svg can carry script") + } + if err := checkImage([]byte("#!/bin/sh\nrm -rf /\n")); err == nil { + t.Error("a shell script was accepted as an image") + } + if err := checkImage(append(png, make([]byte, maxImage)...)); err == nil { + t.Error("an oversized image was accepted") + } +} + +// One shape in repos.json, not two. Driven through the route, because what +// adopting records is the settings it was already being served with. +func TestAdoptedRepoLooksLikeACreatedOne(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + if err := gitInit(filepath.Join(f.store.reposDir, "orphan.git"), true); err != nil { + t.Fatal(err) + } + if err := f.store.scanRepos(); err != nil { + t.Fatal(err) + } + + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "path": {"orphan"}} + if w := post(h, "/admin/repo/adopt", form, session); w.Code != http.StatusSeeOther { + t.Fatalf("adopting: status %d", w.Code) + } + repo, ok := f.store.Repo("orphan") + if !ok || repo.Visibility != visPrivate || repo.Issues != featDisabled || repo.Wiki != featDisabled { + t.Errorf("adopting changed how it is served: %+v", repo) + } + data, err := os.ReadFile(f.store.File("repos.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "null") { + t.Errorf("an adopted repository is written differently from a created one:\n%s", data) + } + if w := post(h, "/admin/repo/adopt", form, session); w.Code != http.StatusBadRequest { + t.Errorf("adopting a repository that already has an entry: status %d", w.Code) + } +} + +// The sweep runs across every repository rather than the one an admin happens +// to be looking at, and it takes nobody else's grant with it. +func TestDeletingAnAccountTakesItsGrants(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addAdmin(t, f, "alice") + for _, path := range []string{"one", "two"} { + addRepo(t, f, path, Repo{ + Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, + Grants: map[string]string{"alice": grantAdmin, "bob": grantWrite}, + }) + } + + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + form := func(name string) url.Values { + return url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "name": {name}, + "code": {freshCode(t, f, "alice", knownSecret)}, + } + } + // Capitalised, because the shift key is forgiven wherever a name is typed. + if w := post(h, "/admin/user/delete", form("Bob"), session); w.Code != http.StatusSeeOther { + t.Fatalf("deleting bob: status %d", w.Code) + } + if _, ok := f.store.User("bob"); ok { + t.Error("the account survived being deleted") + } + for _, path := range []string{"one", "two"} { + repo, _ := f.store.Repo(path) + if _, ok := repo.Grants["bob"]; ok { + t.Errorf("%s still grants to a deleted account: %v", path, repo.Grants) + } + if repo.Grants["alice"] != grantAdmin { + t.Errorf("%s lost somebody else's grant: %v", path, repo.Grants) + } + } + // Administration is web only, so the last way in cannot be deleted either. + if w := post(h, "/admin/user/delete", form("alice"), session); w.Code != http.StatusBadRequest { + t.Errorf("deleting the last admin: status %d", w.Code) + } + if _, ok := f.store.User("alice"); !ok { + t.Error("the last admin was deleted anyway") + } +} + +// A grant on an unadopted directory has to record the settings it is already +// served with, rather than fail validation on the fields it does not have. +func TestGrantOnAnUnadoptedRepo(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addAdmin(t, f, "alice") + if err := gitInit(filepath.Join(f.store.reposDir, "orphan.git"), true); err != nil { + t.Fatal(err) + } + if err := f.store.scanRepos(); err != nil { + t.Fatal(err) + } + + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + form := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "account": {"bob"}, + "level": {grantRead}, "code": {freshCode(t, f, "alice", knownSecret)}, + } + if w := post(h, "/orphan/settings/grant", form, session); w.Code != http.StatusSeeOther { + t.Fatalf("granting on an unadopted repository: status %d", w.Code) + } + repo, ok := f.store.Repo("orphan") + if !ok || repo.Grants["bob"] != grantRead { + t.Fatalf("the grant did not land: %+v", repo) + } + if repo.Visibility != visPrivate || repo.Issues != featDisabled || repo.Wiki != featDisabled { + t.Errorf("adopting it by granting changed how it is served: %+v", repo) + } +} + +// A repository admin holding no authenticator is refused rather than quietly +// exempted, and the page says so rather than drawing a form that cannot work. +func TestGrantNeedsAnAuthenticator(t *testing.T) { + f := testForge(t) + addUser(t, f, "owner", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addRepo(t, f, "notes", Repo{ + Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, + Grants: map[string]string{"owner": grantAdmin}, + }) + h := f.routes() + session := signIn(t, f, h, "owner") + + page := get(h, "/notes/settings", session) + if page.Code != http.StatusOK { + t.Fatalf("the settings page: status %d", page.Code) + } + if body := page.Body.String(); !strings.Contains(body, "/settings/totp") { + t.Error("the page does not say where to enrol an authenticator") + } + if strings.Contains(page.Body.String(), `name="level"`) { + t.Error("the page draws a grant form this account cannot submit") + } + + form := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "account": {"bob"}, "level": {grantRead}, + } + if w := post(h, "/notes/settings/grant", form, session); w.Code != http.StatusForbidden { + t.Errorf("a grant with no authenticator behind it: status %d", w.Code) + } + if repo, _ := f.store.Repo("notes"); repo.Grants["bob"] != "" { + t.Error("the grant landed anyway") + } +} + +// Creating a repository, turning a feature on, and deleting the lot are the +// three most consequential things an admin does, and none of them was driven +// through its route by anything but the container smoke test. +func TestRepoLifeThroughTheRoutes(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + form := func(v url.Values) url.Values { + v.Set("csrf", f.csrf(withCookies("/", session))) + return v + } + + create := form(url.Values{ + "section": {"infra"}, "name": {"dns"}, "desc": {"zones"}, + "visibility": {visPrivate}, "issues": {featMembers}, "wiki": {featDisabled}, + }) + if w := post(h, "/admin/repo/create", create, session); w.Code != http.StatusSeeOther { + t.Fatalf("creating a repository in a section: status %d, %s", w.Code, w.Body) + } + repo, ok := f.store.Repo("infra/dns") + if !ok || repo.Desc != "zones" || repo.Visibility != visPrivate || repo.Issues != featMembers { + t.Fatalf("the record is not what the form said: %+v", repo) + } + // The wiki was off, so it has no repository yet. + if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra/dns.wiki.git")); err == nil { + t.Error("a disabled wiki was given a repository anyway") + } + // A name that is not a usable path is refused rather than coerced. + bad := form(url.Values{"name": {"Notes"}, "visibility": {visPrivate}, "issues": {featDisabled}, "wiki": {featDisabled}}) + if w := post(h, "/admin/repo/create", bad, session); w.Code != http.StatusBadRequest { + t.Errorf("an uppercase name: status %d", w.Code) + } + + // Turning the wiki on has to bring its repository with it, or the tab + // leads to a page with nothing behind it. + save := form(url.Values{ + "desc": {"zones"}, "visibility": {visPrivate}, + "issues": {featMembers}, "wiki": {featMembers}, "labels": {"bug\n bug \n\nazz"}, + "code": {freshCode(t, f, "alice", knownSecret)}, // the wiki knob moves + }) + if w := post(h, "/infra/dns/settings", save, session); w.Code != http.StatusSeeOther { + t.Fatalf("saving settings: status %d", w.Code) + } + if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra/dns.wiki.git")); err != nil { + t.Errorf("turning the wiki on left it with no repository: %v", err) + } + if code := get(h, "/infra/dns/wiki", session).Code; code != http.StatusOK { + t.Errorf("the wiki that was just turned on answered %d", code) + } + // One label once, sorted, and the blank line is not a label. + if repo, _ := f.store.Repo("infra/dns"); len(repo.Labels) != 2 || + repo.Labels[0] != "azz" || repo.Labels[1] != "bug" { + t.Errorf("the labels are %q", repo.Labels) + } + // users on a private repository is refused, not coerced. + if w := post(h, "/infra/dns/settings", form(url.Values{ + "visibility": {visPrivate}, "issues": {featUsers}, "wiki": {featDisabled}, + }), session); w.Code != http.StatusBadRequest { + t.Errorf("issues set to users on a private repository: status %d", w.Code) + } + + // Deletion takes all three directories and every token pointing at the + // path, because the path can be created again. + addToken(t, f, "alice", "infra/dns") + addToken(t, f, "alice", "elsewhere") + del := form(url.Values{"confirm": {"infra/dns"}, "code": {freshCode(t, f, "alice", knownSecret)}}) + if w := post(h, "/infra/dns/settings/delete", del, session); w.Code != http.StatusSeeOther { + t.Fatalf("deleting: status %d", w.Code) + } + if _, ok := f.store.Repo("infra/dns"); ok { + t.Error("the repository survived deletion") + } + for _, suffix := range []string{".git", ".issues.git", ".wiki.git"} { + if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra/dns"+suffix)); err == nil { + t.Errorf("infra/dns%s is still on disk", suffix) + } + } + // The section it was alone in goes with it. + if _, err := os.Stat(filepath.Join(f.store.reposDir, "infra")); err == nil { + t.Error("the empty section was left behind") + } + u, _ := f.store.User("alice") + if len(u.Tokens) != 1 || u.Tokens[0].Repo != "elsewhere" { + t.Errorf("the token sweep took the wrong ones: %+v", u.Tokens) + } +} + +// An admin never handles a password: the account is made with none and a one +// time link is displayed for the admin to convey. +func TestUserCreateThroughTheRoute(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + form := func(name, code string) url.Values { + return url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "name": {name}, + "email": {"bob@example.com"}, "code": {code}, + } + } + + // It hands out a way in, so it costs the acting admin's own code. + if w := post(h, "/admin/user/create", form("bob", "000000"), session); w.Code != http.StatusForbidden { + t.Errorf("creating an account with a wrong code: status %d", w.Code) + } + if _, ok := f.store.User("bob"); ok { + t.Fatal("the account was made anyway") + } + + w := post(h, "/admin/user/create", form("Bob", freshCode(t, f, "alice", knownSecret)), session) + if w.Code != http.StatusOK { + t.Fatalf("creating an account: status %d", w.Code) + } + bob, ok := f.store.User("bob") + if !ok { + t.Fatalf("no account was made: %v", f.store.Users()) + } + if bob.Admin || bob.Password != "" || bob.Origin != originLocal { + t.Errorf("a new account should hold no flag and no password: %+v", bob) + } + // Off the page as the template wrote it, so the separators are escaped. + link := strings.ReplaceAll(between(w.Body.String(), "<pre>", "</pre>"), "&", "&") + if !strings.Contains(link, "/set-password?") { + t.Fatalf("no one time link on the page: %q", link) + } + // And it works, once. + if _, _, ok := f.checkSetPassword(mustQuery(t, link)); !ok { + t.Error("the link the admin was given does not verify") + } + if w := post(h, "/admin/user/create", form("bob", freshCode(t, f, "alice", knownSecret)), session); w.Code != http.StatusBadRequest { + t.Errorf("creating the same account twice: status %d", w.Code) + } +} + +func mustQuery(t *testing.T, link string) url.Values { + t.Helper() + u, err := url.Parse(link) + if err != nil { + t.Fatal(err) + } + return u.Query() +} + +// The logo an admin uploads is served back from the config directory, and an +// empty setting means cgit's own, which nothing links to. +func TestUploadedLogoIsServed(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + + if code := get(h, "/logo", nil).Code; code != http.StatusNotFound { + t.Errorf("/logo with none configured answered %d", code) + } + logo := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("p", 64)) + if code := saveSite(t, f, h, session, "Forge", logo, nil); code != http.StatusSeeOther { + t.Fatalf("saving a logo: status %d", code) + } + + got := get(h, "/logo", nil) + if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), logo) { + t.Errorf("/logo answered %d with %d bytes", got.Code, got.Body.Len()) + } + if kind := got.Header().Get("Content-Type"); kind != "image/png" { + t.Errorf("/logo is served as %q", kind) + } + // And the header points at it rather than at cgit's. + if page := get(h, "/", nil).Body.String(); !strings.Contains(page, `src="/logo"`) { + t.Error("the header does not point at the uploaded logo") + } +} + +// The favicon is its own setting, served with the type its bytes say, and +// cgit's own until one is uploaded. +func TestUploadedFaviconIsServed(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addAdmin(t, f, "alice") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + + // Guarded, because cgit's own file is what there is to fall back to. + if want := f.cgit.favicon; len(want) > 0 { + if got := get(h, "/favicon.ico", nil); !bytes.Equal(got.Body.Bytes(), want) { + t.Errorf("/favicon.ico with none configured answered %d, not cgit's own", got.Code) + } + } + + icon := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("i", 64)) + if code := saveSite(t, f, h, session, "Forge", nil, icon); code != http.StatusSeeOther { + t.Fatalf("saving a favicon: status %d", code) + } + + got := get(h, "/favicon.ico", nil) + if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), icon) { + t.Errorf("/favicon.ico answered %d with %d bytes", got.Code, got.Body.Len()) + } + if kind := got.Header().Get("Content-Type"); kind != "image/png" { + t.Errorf("/favicon.ico is served as %q", kind) + } + // The logo is its own setting, and posting one did not touch it. + if code := get(h, "/logo", nil).Code; code != http.StatusNotFound { + t.Errorf("uploading a favicon configured a logo too: /logo answered %d", code) + } + + // A later save with no file chosen is not a save that clears it. + if code := saveSite(t, f, h, session, "Forge", nil, nil); code != http.StatusSeeOther { + t.Fatalf("a save with no file chosen: status %d", code) + } + if got := get(h, "/favicon.ico", nil); !bytes.Equal(got.Body.Bytes(), icon) { + t.Error("a save with no file chosen dropped the favicon") + } +} + +// Who may read or write outlives the session that changes it, so moving one of +// those knobs costs a code. A description does not. +func TestKnobChangeNeedsACode(t *testing.T) { + f := testForge(t) + addUser(t, f, "owner", "hunter2hunter2") + addTOTP(t, f, "owner") + addRepo(t, f, "notes", Repo{ + Desc: "before", Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, + Labels: []string{}, Grants: map[string]string{"owner": grantAdmin}, + }) + h := f.routes() + session := codeSession(t, f, h, "owner", "hunter2hunter2") + form := func(v url.Values) url.Values { + v.Set("csrf", f.csrf(withCookies("/", session))) + return v + } + + desc := form(url.Values{ + "desc": {"after"}, "visibility": {visPrivate}, + "issues": {featDisabled}, "wiki": {featDisabled}, + }) + if w := post(h, "/notes/settings", desc, session); w.Code != http.StatusSeeOther { + t.Fatalf("a description with no code: status %d", w.Code) + } + if repo, _ := f.store.Repo("notes"); repo.Desc != "after" { + t.Errorf("the description is %q", repo.Desc) + } + + open := url.Values{ + "desc": {"after"}, "visibility": {visPublic}, + "issues": {featDisabled}, "wiki": {featDisabled}, + } + if w := post(h, "/notes/settings", form(open), session); w.Code != http.StatusForbidden { + t.Fatalf("opening the repository with no code: status %d", w.Code) + } + if repo, _ := f.store.Repo("notes"); repo.Visibility != visPrivate { + t.Fatal("a repository was opened without a code") + } + open.Set("code", freshCode(t, f, "owner", knownSecret)) + if w := post(h, "/notes/settings", form(open), session); w.Code != http.StatusSeeOther { + t.Fatalf("opening the repository with a code: status %d", w.Code) + } + if repo, _ := f.store.Repo("notes"); repo.Visibility != visPublic { + t.Error("the code was right and the repository is still private") + } +} + +// A save carries the fields the form owns and nothing else, so a grant handed +// out while it was open is still there afterwards. +func TestSettingsSaveKeepsGrants(t *testing.T) { + f := testForge(t) + addUser(t, f, "owner", "hunter2hunter2") + addTOTP(t, f, "owner") + addRepo(t, f, "notes", Repo{ + Desc: "before", Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, + Labels: []string{}, Grants: map[string]string{"owner": grantAdmin}, + }) + h := f.routes() + session := codeSession(t, f, h, "owner", "hunter2hunter2") + + // Straight into repos.json, standing in for a grant that landed while the + // request that follows was in flight. + err := f.store.UpdateRepos("repos: grant bob", f.store.forgeAuthor(), func(m map[string]Repo) error { + entry := m["notes"] + entry.Grants = map[string]string{"owner": grantAdmin, "bob": grantWrite} + m["notes"] = entry + return nil + }) + if err != nil { + t.Fatal(err) + } + stale, _ := f.store.Repo("notes") + stale.Grants = map[string]string{"owner": grantAdmin} // what the request read + f.repoSettingsSave(httptest.NewRecorder(), withForm(f, session, url.Values{ + "desc": {"after"}, "visibility": {visPrivate}, + "issues": {featDisabled}, "wiki": {featDisabled}, + }), "notes", stale) + + repo, _ := f.store.Repo("notes") + if repo.Desc != "after" { + t.Errorf("the description is %q", repo.Desc) + } + if repo.Grants["bob"] != grantWrite { + t.Errorf("the save dropped a grant it never read: %v", repo.Grants) + } +} + +// withForm is a parsed POST, for driving a handler below the router. +func withForm(f *Forge, session []*http.Cookie, v url.Values) *http.Request { + v.Set("csrf", f.csrf(withCookies("/", session))) + r := httptest.NewRequest("POST", "/notes/settings", strings.NewReader(v.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range session { + r.AddCookie(c) + } + r.ParseForm() + return r +} @@ -0,0 +1,510 @@ +package main + +// The primitives: hashing, signing, cookies, one time links, TOTP, push +// tokens, and rate limits. + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/crypto/argon2" +) + +// Password hashing parameters, stored in every hash, so old hashes keep +// verifying. +const ( + argonTime = 3 + argonMemory = 64 * 1024 // KiB + argonThreads = 4 + argonSaltLen = 16 + argonKeyLen = 32 + + minPassword = 8 +) + +const ( + sessionCookie = "session" + sessionLifetime = 30 * 24 * time.Hour +) + +// What a form may weigh; field limits are the handlers' own. +const ( + // Percent encoding can treble a byte, and the largest field is maxBody. + maxForm = 4*maxBody + 64<<10 + // Both images at their limit plus one a little over, so it still gets the + // sentence saying so. + maxUpload = 3 * maxImage +) + +// argon2id is expensive in memory by design, so concurrent hashes are bounded: +// otherwise an unauthenticated POST to /login sets the peak. +var hashing = make(chan struct{}, 2) + +func hashSlot() func() { + hashing <- struct{}{} + return func() { <-hashing } +} + +// hashPassword returns a PHC string: $argon2id$v=19$m=...,t=...,p=...$salt$hash. +func hashPassword(password string) string { + defer hashSlot()() + + salt := make([]byte, argonSaltLen) + rand.Read(salt) // crypto/rand does not fail, it panics if the kernel does + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + b64 := base64.RawStdEncoding.EncodeToString + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(key)) +} + +// passwordOK hashes even when the encoding is unusable, so a wrong account +// name costs the same as a wrong password. +func passwordOK(encoded, password string) bool { + defer hashSlot()() + + salt, want, time, memory, threads, err := parseHash(encoded) + if err != nil { + salt, want, time, memory, threads = make([]byte, argonSaltLen), make([]byte, argonKeyLen), argonTime, argonMemory, argonThreads + } + got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) + return err == nil && subtle.ConstantTimeCompare(got, want) == 1 +} + +func parseHash(encoded string) (salt, key []byte, time, memory uint32, threads uint8, err error) { + fail := func(why string) ([]byte, []byte, uint32, uint32, uint8, error) { + return nil, nil, 0, 0, 0, fmt.Errorf("argon2id hash: %s", why) + } + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { + return fail("not a $argon2id$ string") + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { + return fail("unsupported version") + } + var m, t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return fail("unreadable parameters") + } + salt, err = base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return fail("unreadable salt") + } + key, err = base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(key) == 0 { + return fail("unreadable hash") + } + return salt, key, t, m, p, nil +} + +// mac signs fields for one purpose, so a session cookie is never a reset link; +// the separator keeps two field lists apart. +func (f *Forge) mac(purpose string, fields ...string) string { + h := hmac.New(sha256.New, f.store.cookieKey) + h.Write([]byte(purpose)) + for _, s := range fields { + h.Write([]byte{0}) + h.Write([]byte(s)) + } + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// There is no session store: the cookie carries the name and expiry, and the +// epoch is mixed into the signature, so bumping it invalidates every cookie. +func (f *Forge) setSession(w http.ResponseWriter, name string, epoch int) string { + expiry := time.Now().Add(sessionLifetime) + unix := strconv.FormatInt(expiry.Unix(), 10) + value := base64.RawURLEncoding.EncodeToString([]byte(name)) + "." + unix + "." + + f.mac("session", name, unix, strconv.Itoa(epoch)) + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: value, + Path: "/", + Expires: expiry, + MaxAge: int(sessionLifetime / time.Second), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"), + }) + return value +} + +func (f *Forge) clearSession(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"), + }) +} + +func (f *Forge) user(r *http.Request) (string, User, bool) { + c, err := r.Cookie(sessionCookie) + if err != nil { + return "", User{}, false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 3 { + return "", User{}, false + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", User{}, false + } + unix, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || time.Now().After(time.Unix(unix, 0)) { + return "", User{}, false + } + name := string(raw) + u, ok := f.store.User(name) + if !ok { + return "", User{}, false + } + want := f.mac("session", name, parts[1], strconv.Itoa(u.Epoch)) + if !hmac.Equal([]byte(parts[2]), []byte(want)) { + return "", User{}, false + } + return name, u, true +} + +// csrf is the form token, bound to the session cookie. +func (f *Forge) csrf(r *http.Request) string { + c, err := r.Cookie(sessionCookie) + if err != nil { + return f.mac("csrf", "") + } + return f.mac("csrf", c.Value) +} + +// checkPost gates the HTML forms, not the git endpoints. Signed in, the form +// token is required; signed out, only the Origin header can say, and a client +// sending none is let through. +func (f *Forge) checkPost(w http.ResponseWriter, r *http.Request) bool { + // ParseMultipartForm's argument bounds memory only; the rest goes to a + // temporary file, so MaxBytesReader is what bounds the disk. + var err error + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + r.Body = http.MaxBytesReader(w, r.Body, maxUpload) + err = r.ParseMultipartForm(maxImage) + } else { + r.Body = http.MaxBytesReader(w, r.Body, maxForm) + err = r.ParseForm() + } + if err != nil { + f.fail(w, r, http.StatusBadRequest, "unreadable form, or one too big to be one") + return false + } + if origin := r.Header.Get("Origin"); origin != "" && origin != f.cfg.BaseURL { + f.fail(w, r, http.StatusForbidden, "cross-site post refused") + return false + } + if _, _, ok := f.user(r); ok { + if !hmac.Equal([]byte(r.PostFormValue("csrf")), []byte(f.csrf(r))) { + f.fail(w, r, http.StatusForbidden, "stale form, reload the page and try again") + return false + } + } + return true +} + +// maxTokens bounds users.json and the audit log, and keeps a three byte id +// unambiguous. +const maxTokens = 32 + +// Only the digest is kept. +func mintToken(repo string) (secret string, token Token) { + secret = randomToken(24) + return secret, Token{ + ID: randomToken(3), Repo: repo, Hash: tokenHash(secret), Created: today(), + } +} + +// today is the granularity every date on a token is kept at. +func today() string { return time.Now().UTC().Format("2006-01-02") } + +// tokenHash is SHA-256, not argon2id: a token is high entropy, and this runs +// on every git request. +func tokenHash(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// checkToken returns the account's own name, not the one typed: grants and +// REMOTE_USER key off it. +func (f *Forge) checkToken(given, secret, repo string) (string, User, bool) { + name := accountName(given) + u, ok := f.store.User(name) + if !ok { + return "", User{}, false + } + want := tokenHash(secret) + for _, t := range u.Tokens { + if t.Repo == repo && hmac.Equal([]byte(t.Hash), []byte(want)) { + f.touchToken(name, t) + return name, u, true + } + } + return "", User{}, false +} + +// touchToken writes once a day: git makes several requests per clone. +func (f *Forge) touchToken(name string, used Token) { + if used.Used == today() { + return + } + err := f.store.touchUser(name, func(acct *User) error { + for i := range acct.Tokens { + if acct.Tokens[i].ID == used.ID { + acct.Tokens[i].Used = today() + } + } + return nil + }) + if err != nil { + log.Printf("tokens: %v", err) // not worth failing a push over + } +} + +// TOTP is RFC 6238 with the parameters every authenticator assumes: HMAC-SHA1, +// six digits, thirty seconds. +const ( + totpDigits = 6 + totpPeriod = 30 * time.Second + totpSkew = 1 // steps either side + totpSecretLen = 20 + recoveryCodes = 10 + totpPendLife = 5 * time.Minute +) + +// totpSecret is base32 without padding, as an otpauth URI carries it. +func totpSecret() string { + raw := make([]byte, totpSecretLen) + rand.Read(raw) + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw) +} + +func totpCode(secret string, at time.Time) string { + raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) + if err != nil { + return "" + } + counter := make([]byte, 8) + binary.BigEndian.PutUint64(counter, uint64(at.Unix())/uint64(totpPeriod.Seconds())) + + mac := hmac.New(sha1.New, raw) + mac.Write(counter) + sum := mac.Sum(nil) + offset := sum[len(sum)-1] & 0x0f + value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff + return fmt.Sprintf("%0*d", totpDigits, value%1000000) +} + +// validSecret is base32 of exactly the bytes this forge mints. The secret +// arrives in a form, so its strength is checked here. +func validSecret(secret string) bool { + raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + return err == nil && len(raw) == totpSecretLen +} + +// totpStep is the time step the code is right for, or -1. One step either +// side of now is accepted, because clocks disagree. +func totpStep(secret, code string, now time.Time) int64 { + code = strings.TrimSpace(code) + for skew := -totpSkew; skew <= totpSkew; skew++ { + at := now.Add(time.Duration(skew) * totpPeriod) + want := totpCode(secret, at) + if want != "" && hmac.Equal([]byte(code), []byte(want)) { + return at.Unix() / int64(totpPeriod.Seconds()) + } + } + return -1 +} + +var errBadCode = errors.New("that code is not right, or was used already") + +// codeOK accepts a code once: the step it was accepted at is recorded on the +// account and a step at or below it is refused, so a code seen over a shoulder +// cannot be replayed within its window. Recorded without a commit, like a +// token's last use. A record that cannot be written refuses the code. +func (f *Forge) codeOK(name string, u User, code string) bool { + if u.TOTP == nil { + return false + } + step := totpStep(u.TOTP.Secret, code, time.Now()) + if step < 0 || step <= u.TOTP.Used { + return false + } + err := f.store.touchUser(name, func(acct *User) error { + if acct.TOTP == nil || step <= acct.TOTP.Used { + return errBadCode + } + acct.TOTP.Used = step + return nil + }) + if err != nil && !errors.Is(err, errBadCode) { + log.Printf("totp: %v", err) + } + return err == nil +} + +// totpURI is given as text: a QR code is useless in a terminal browser. +func (f *Forge) totpURI(name, secret string) string { + label := url.PathEscape(f.siteName() + ":" + name) + q := url.Values{ + "secret": {secret}, + "issuer": {f.siteName()}, + "algorithm": {"SHA1"}, + "digits": {strconv.Itoa(totpDigits)}, + "period": {strconv.Itoa(int(totpPeriod.Seconds()))}, + } + return "otpauth://totp/" + label + "?" + q.Encode() +} + +// newRecoveryCodes returns the codes to show once and the digests to keep. +func newRecoveryCodes() (show []string, keep []string) { + for range recoveryCodes { + code := randomToken(5) + show = append(show, code) + keep = append(keep, tokenHash(code)) + } + return show, keep +} + +func randomToken(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +// A first password and a reset are the same link with different lives. +const ( + setPasswordLife = 48 * time.Hour + resetLife = time.Hour +) + +// passwordLink is stateless: a MAC over the account, an expiry, and the +// current password hash, so it dies when the password changes. +func (f *Forge) passwordLink(name string, u User, life time.Duration) string { + expires := strconv.FormatInt(time.Now().Add(life).Unix(), 10) + q := url.Values{ + "name": {name}, + "expires": {expires}, + "t": {f.mac("set-password", name, expires, u.Password)}, + } + return f.cfg.BaseURL + "/set-password?" + q.Encode() +} + +func (f *Forge) checkSetPassword(v url.Values) (string, User, bool) { + name, expires := v.Get("name"), v.Get("expires") + unix, err := strconv.ParseInt(expires, 10, 64) + if err != nil || time.Now().After(time.Unix(unix, 0)) { + return "", User{}, false + } + u, ok := f.store.User(name) + if !ok { + return "", User{}, false + } + want := f.mac("set-password", name, expires, u.Password) + if !hmac.Equal([]byte(v.Get("t")), []byte(want)) { + return "", User{}, false + } + return name, u, true +} + +const confirmEmailLife = 24 * time.Hour + +// confirmEmailLink is bound to the address, so changing it kills the link. +func (f *Forge) confirmEmailLink(name string, u User) string { + expires := strconv.FormatInt(time.Now().Add(confirmEmailLife).Unix(), 10) + q := url.Values{ + "name": {name}, + "expires": {expires}, + "t": {f.mac("confirm-email", name, expires, u.Email)}, + } + return f.cfg.BaseURL + "/confirm-email?" + q.Encode() +} + +// Every budget, per hour. Constants: nobody tunes these. +const ( + loginsPerHour = 60 + totpPerHour = 30 + resetsPerHour = 5 + issuesPerHour = 20 + commentsPerHour = 60 + // maxTokens bounds what is kept; this bounds what is written to get there. + tokensPerHour = 20 +) + +// limiter is a fixed window counter, per account and per address. In memory, +// so it resets on restart. +type limiter struct { + mu sync.Mutex + opened time.Time + counts map[string]int +} + +func newLimiter() *limiter { + return &limiter{opened: time.Now(), counts: map[string]int{}} +} + +func (l *limiter) hit(key string, perHour int) bool { + l.charge(key) + return !l.over(key, perHour) +} + +// charge counts; over asks without counting, for budgets spent only by +// failures. +func (l *limiter) charge(key string) { + l.mu.Lock() + defer l.mu.Unlock() + l.rollLocked() + l.counts[key]++ +} + +func (l *limiter) over(key string, perHour int) bool { + l.mu.Lock() + defer l.mu.Unlock() + l.rollLocked() + return l.counts[key] > perHour +} + +// Both callers roll the window: a budget spent only by failures never reaches +// charge once it has run out. +func (l *limiter) rollLocked() { + if time.Since(l.opened) >= time.Hour { + l.opened, l.counts = time.Now(), map[string]int{} + } +} + +// allow charges both the account and the address. +func (l *limiter) allow(addr string, v Viewer, perHour int) bool { + ok := l.hit("ip:"+addr, perHour) + if v.SignedIn() { + ok = l.hit(accountKey(v.Name), perHour) && ok + } + return ok +} + +// accountKey is one account's budget, wherever it is spent from. +func accountKey(name string) string { return "account:" + name } diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..35d1818 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,395 @@ +package main + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func testForge(t *testing.T) *Forge { + t.Helper() + store, err := openStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + f, err := newForge(store) + if err != nil { + t.Fatal(err) + } + // Only close removes the runtime directory newForge makes. + t.Cleanup(f.cgit.close) + return f +} + +func addUser(t *testing.T, f *Forge, name, password string) { + t.Helper() + hash := hashPassword(password) + err := f.store.UpdateUsers("users: add "+name, f.store.forgeAuthor(), func(m map[string]User) error { + m[name] = User{Origin: originLocal, Notify: true, Password: hash} + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func post(h http.Handler, path string, form url.Values, cookies []*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// withCookies is a request carrying a session, for asking the forge what the +// form token bound to it is. +func withCookies(path string, cookies []*http.Cookie) *http.Request { + r := httptest.NewRequest("GET", path, nil) + for _, c := range cookies { + r.AddCookie(c) + } + return r +} + +func get(h http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", path, nil) + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestPasswordHash(t *testing.T) { + hash := hashPassword("correct horse battery staple") + if !strings.HasPrefix(hash, "$argon2id$v=19$m=") { + t.Fatalf("not a PHC string: %q", hash) + } + if !passwordOK(hash, "correct horse battery staple") { + t.Error("the right password was rejected") + } + if passwordOK(hash, "correct horse battery stapl") { + t.Error("a wrong password was accepted") + } + for _, bad := range []string{"", "hunter2", "$argon2id$v=19$m=1,t=1,p=1$..$..", "$argon2i$v=19$m=1,t=1,p=1$AAAA$AAAA"} { + if passwordOK(bad, "hunter2") { + t.Errorf("password accepted against hash %q", bad) + } + } +} + +func TestSessionEpochRevokes(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + + w := httptest.NewRecorder() + alice, _ := f.store.User("alice") + f.setSession(w, "alice", alice.Epoch) + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("expected one cookie, got %d", len(cookies)) + } + c := cookies[0] + if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" { + t.Errorf("session cookie is not HttpOnly, Lax, and rooted: %+v", c) + } + + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(c) + if name, _, ok := f.user(r); !ok || name != "alice" { + t.Fatal("a fresh session was not accepted") + } + + tampered := httptest.NewRequest("GET", "/", nil) + tampered.AddCookie(&http.Cookie{Name: sessionCookie, Value: c.Value[:len(c.Value)-1] + "x"}) + if _, _, ok := f.user(tampered); ok { + t.Error("a tampered session was accepted") + } + + err := f.store.UpdateUsers("users: revoke", f.store.forgeAuthor(), func(m map[string]User) error { + u := m["alice"] + u.Epoch++ + m["alice"] = u + return nil + }) + if err != nil { + t.Fatal(err) + } + if _, _, ok := f.user(r); ok { + t.Error("a session survived an epoch bump") + } +} + +// Signing out everywhere has to take the browser asking for it as well as the +// others, or the session somebody wants gone most is the one that survives. +func TestSignOutEverywhere(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + sessions := [][]*http.Cookie{signIn(t, f, h, "alice"), signIn(t, f, h, "alice")} + for i, s := range sessions { + if code := get(h, "/settings", s).Code; code != http.StatusOK { + t.Fatalf("session %d was not usable to begin with: status %d", i, code) + } + } + + form := url.Values{"csrf": {f.csrf(withCookies("/", sessions[0]))}} + if w := post(h, "/settings/sessions", form, sessions[0]); w.Code != http.StatusSeeOther { + t.Fatalf("signing out everywhere: status %d", w.Code) + } + for i, s := range sessions { + if code := get(h, "/settings", s).Code; code != http.StatusSeeOther { + t.Errorf("session %d survived: status %d", i, code) + } + } +} + +func TestLoginLogout(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + + if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil); w.Code != http.StatusUnauthorized { + t.Fatalf("wrong password: status %d", w.Code) + } + // The account name is lowercase by construction, so the shift key is forgiven. + w := post(h, "/login", url.Values{"name": {"Alice"}, "password": {"hunter2hunter2"}}, nil) + if w.Code != http.StatusSeeOther { + t.Fatalf("right password: status %d", w.Code) + } + session := w.Result().Cookies() + + if body := get(h, "/", session).Body.String(); !strings.Contains(body, `<a href="/settings">alice</a>`) { + t.Errorf("the bar does not show the session: %s", body) + } + if w := get(h, "/settings", nil); w.Code != http.StatusSeeOther { + t.Errorf("settings without a session: status %d", w.Code) + } + + if w := post(h, "/logout", nil, session); w.Code != http.StatusForbidden { + t.Error("a signed in post without the form token was accepted") + } + r := httptest.NewRequest("GET", "/", nil) + for _, c := range session { + r.AddCookie(c) + } + token := url.Values{"csrf": {f.csrf(r)}} + if w := post(h, "/logout", token, session); w.Code != http.StatusSeeOther { + t.Fatalf("logout: status %d", w.Code) + } +} + +func TestPasswordChange(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + + session := signIn(t, f, h, "alice") + r := httptest.NewRequest("GET", "/settings", nil) + for _, c := range session { + r.AddCookie(c) + } + form := url.Values{"csrf": {f.csrf(r)}, "current": {"hunter2hunter2"}, "new": {"opensesame1"}, "again": {"opensesame1"}} + + w := post(h, "/settings/password", form, session) + if w.Code != http.StatusOK { + t.Fatalf("password change: status %d, %s", w.Code, w.Body.String()) + } + // The bump killed the cookie the request carried, so the response has to + // both reissue it and still render as signed in. + if len(w.Result().Cookies()) != 1 { + t.Error("the password change did not reissue the session cookie") + } + // The header only names the account when there is a session, so this is + // how the page says the reissued cookie took. + if !strings.Contains(w.Body.String(), `<a href="/settings">alice</a>`) { + t.Error("the page after a password change looks signed out") + } + if get(h, "/settings", session).Code != http.StatusSeeOther { + t.Error("the old session cookie still works") + } + if get(h, "/settings", w.Result().Cookies()).Code != http.StatusOK { + t.Error("the reissued session cookie does not work") + } + if alice, _ := f.store.User("alice"); !passwordOK(alice.Password, "opensesame1") { + t.Error("the new password was not stored") + } +} + +func TestCrossSitePostRefused(t *testing.T) { + f := testForge(t) + h := f.routes() + + r := httptest.NewRequest("POST", "/login", strings.NewReader("name=alice&password=x")) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.Header.Set("Origin", "https://evil.example") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusForbidden { + t.Errorf("cross-site post: status %d", w.Code) + } +} + +// A budget spent by successful sign ins would refuse people doing nothing +// wrong. +func TestLoginLimitCountsFailuresOnly(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil)) + + for range 3 { + if code := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil).Code; code != http.StatusSeeOther { + t.Fatalf("a correct password was refused: status %d", code) + } + } + if f.loginLimit.over(key, 0) { + t.Error("signing in correctly spent the budget for guessing") + } + for range 3 { + post(h, "/login", url.Values{"name": {"alice"}, "password": {"nope"}}, nil) + } + if !f.loginLimit.over(key, 2) { + t.Error("failures were not counted") + } + if f.loginLimit.over(key, 3) { + t.Error("more was counted than was tried") + } +} + +// Behind something that terminates the connection, every request arrives from +// the same address and every budget becomes one bucket. Naming a header is the +// way out, and it is a trust decision: an unnamed one is never read, and a +// value that is not a single address is never counted. +func TestClientAddressFromAHeader(t *testing.T) { + f := testForge(t) + r := httptest.NewRequest("POST", "/login", nil) + r.RemoteAddr = "127.0.0.1:5555" + r.Header.Set("CF-Connecting-IP", "203.0.113.9") + + if got := f.peer(r); got != "127.0.0.1" { + t.Errorf("a header nobody named was read: %q", got) + } + f.cfg.ClientIPHeader = "CF-Connecting-IP" + if got := f.peer(r); got != "203.0.113.9" { + t.Errorf("the named header was not read: %q", got) + } + // Anything that is not one address is nobody's address. + for _, junk := range []string{"", " ", "not-an-ip", "203.0.113.9, 70.41.3.18", "203.0.113.9:443"} { + r.Header.Set("CF-Connecting-IP", junk) + if got := f.peer(r); got != "127.0.0.1" { + t.Errorf("%q was taken as an address: %q", junk, got) + } + } + + // And the budgets follow it. Filled directly, to save the password checks. + for range loginsPerHour + 1 { + f.loginLimit.charge("login:203.0.113.9") + } + h := f.routes() + attempt := func(ip string) int { + body := url.Values{"name": {"alice"}, "password": {"wrong"}}.Encode() + req := httptest.NewRequest("POST", "/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("CF-Connecting-IP", ip) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w.Code + } + if code := attempt("203.0.113.9"); code != http.StatusTooManyRequests { + t.Errorf("the client that spent the budget was not refused: %d", code) + } + if code := attempt("198.51.100.4"); code != http.StatusUnauthorized { + t.Errorf("another client behind the same proxy shared its budget: %d", code) + } + + // A name that could never match is refused at startup. + cfg := defaultConfig() + cfg.ClientIPHeader = "CF-Connecting-IP:" + if err := cfg.validate(); err == nil { + t.Error("a header name with a colon in it was accepted") + } + cfg.ClientIPHeader = "CF-Connecting-IP" + if err := cfg.validate(); err != nil { + t.Errorf("a usable header name was refused: %v", err) + } +} + +// A budget spent only by failures is read by over, and a request over refuses +// never reaches charge. Rolling the window in charge alone would lock an +// address out for ever. +func TestLoginLimitLetsGoAfterAnHour(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + + key := "login:" + f.peer(httptest.NewRequest("POST", "/login", nil)) + for range loginsPerHour + 1 { + f.loginLimit.charge(key) + } + right := url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}} + if code := post(h, "/login", right, nil).Code; code != http.StatusTooManyRequests { + t.Fatalf("the budget did not run out: status %d", code) + } + + f.loginLimit.mu.Lock() + f.loginLimit.opened = time.Now().Add(-90 * time.Minute) + f.loginLimit.mu.Unlock() + + if code := post(h, "/login", right, nil).Code; code != http.StatusSeeOther { + t.Errorf("still refused an hour after the window ran out: status %d", code) + } +} + +// Without a cap on the body itself, an unauthenticated POST to any route fills +// the disk. Asserted on the route that needs no account at all. +func TestFormBodyIsCapped(t *testing.T) { + f := testForge(t) + h := f.routes() + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, err := mw.CreateFormFile("logo_file", "big.bin") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(make([]byte, maxUpload+1<<20)); err != nil { + t.Fatal(err) + } + mw.Close() + + r := httptest.NewRequest("POST", "/login", &body) + r.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("an oversized multipart body was accepted: status %d", w.Code) + } + + long := url.Values{"name": {strings.Repeat("x", maxForm+1)}, "password": {"x"}} + if code := post(h, "/login", long, nil).Code; code != http.StatusBadRequest { + t.Errorf("an oversized form body was accepted: status %d", code) + } + + // And the cap is generous enough for what the forms are for: a wiki page at + // the size limit, in text where every byte percent encodes to six. + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) + session := signIn(t, f, h, "alice") + page := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "base": {""}, "action": {"save"}, + "body": {strings.Repeat("é", maxBody/2)}, + } + if code := post(h, "/notes/wiki/", page, session).Code; code != http.StatusSeeOther { + t.Errorf("a wiki page at the size limit was refused: status %d", code) + } +} diff --git a/avatar.go b/avatar.go new file mode 100644 index 0000000..4342890 --- /dev/null +++ b/avatar.go @@ -0,0 +1,45 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "net/http" + "strings" +) + +// An avatar is drawn from the name alone: fetching one tells somewhere else +// who reads what, and an upload needs a parser. +func avatarSVG(name string) []byte { + sum := sha256.Sum256([]byte(name)) + // Fixed saturation and lightness, so every one is readable. + hue := int(sum[0]) * 360 / 256 + var b strings.Builder + fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" width="20" height="20" role="img" aria-label="%s">`, name) + fmt.Fprintf(&b, `<rect width="5" height="5" fill="#eee"/>`) + fmt.Fprintf(&b, `<g fill="hsl(%d,45%%,35%%)">`, hue) + // Three columns mirrored into five. + for x := range 3 { + for y := range 5 { + if sum[1+x*5+y]&1 == 0 { + continue + } + fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1"/>`, x, y) + if x < 2 { + fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1"/>`, 4-x, y) + } + } + } + b.WriteString(`</g></svg>`) + return []byte(b.String()) +} + +func (f *Forge) handleAvatar(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !accountNameRE.MatchString(name) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "max-age=86400") + w.Write(avatarSVG(name)) +} @@ -0,0 +1,304 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "net/http" + "net/http/cgi" + "os" + "path/filepath" + "strings" +) + +// Cgit is the browser. One config per repository, because under scan-path any +// request reaching cgit's index lists every repository. The configs are +// derived state in a temporary directory. +type Cgit struct { + binary string + css []byte // cgit's own stylesheet, read from beside the binary + logo []byte // and its logo, which is the default one + favicon []byte + dir string + store *Store +} + +// cgitBinary is the CGI in Alpine's cgit package; the override is for running +// outside that image. +func cgitBinary() string { + if path := os.Getenv("GIT_FORGE_CGIT"); path != "" { + return path + } + return "/usr/share/webapps/cgit/cgit.cgi" +} + +func newCgit(store *Store, forgeCSS []byte) (*Cgit, error) { + dir, err := os.MkdirTemp("", "git-forge-cgit") + if err != nil { + return nil, err + } + c := &Cgit{binary: cgitBinary(), dir: dir, store: store} + if _, err := os.Stat(c.binary); err != nil { + log.Printf("cgit: %v, so browsing will not work", err) + } + // cgit's own, read beside the CGI: its licence, and it changes with the + // package. + beside := filepath.Dir(c.binary) + if c.css, err = os.ReadFile(filepath.Join(beside, "cgit.css")); err != nil { + log.Printf("cgit: %v, so pages will be unstyled", err) + } + // One file at the path cgit links to, or the injected bar is unstyled. + c.css = append(c.css, forgeCSS...) + // The defaults, linked to when no logo is configured. + if c.logo, err = os.ReadFile(filepath.Join(beside, "cgit.png")); err != nil { + log.Printf("cgit: %v, so /cgit.png answers 404", err) + } + if c.favicon, err = os.ReadFile(filepath.Join(beside, "favicon.ico")); err != nil { + log.Printf("cgit: %v, so /favicon.ico answers 404", err) + } + log.Printf("cgit configs in %s, written on demand and never backed up", dir) + return c, c.writeFilters() +} + +func (c *Cgit) close() { os.RemoveAll(c.dir) } + +// writeFilters generates the two filter shims: an exec filter is not split on +// spaces, so "git-forge md" cannot be named directly. +func (c *Cgit) writeFilters() error { + exe, err := os.Executable() + if err != nil { + return err + } + // Quoted: an unquoted path with a space in it execs its first word. + quoted := "'" + strings.ReplaceAll(exe, "'", `'\''`) + "'" + for _, sub := range []string{"md", "highlight"} { + shim := fmt.Sprintf("#!/bin/sh\nexec %s %s \"$@\"\n", quoted, sub) + if err := writeFile(filepath.Join(c.dir, sub), []byte(shim), 0700); err != nil { + return err + } + } + return nil +} + +// config writes one repository's cgitrc if it is not there. The content +// depends on the path alone. +func (c *Cgit) config(path, kind string) (string, error) { + name := c.configPath(path, kind) + if _, err := os.Stat(name); err == nil { + return name, nil + } + return c.write(path, kind) +} + +func (c *Cgit) configPath(path, kind string) string { + // A path holds at most one slash and no colon, so this is unambiguous. + return filepath.Join(c.dir, strings.ReplaceAll(urlPath(path, kind), "/", ":")+".rc") +} + +func (c *Cgit) repoDir(path, kind string) string { + return filepath.Join(c.store.reposDir, path+gitSuffix(kind)) +} + +// virtual-root is unset: with SCRIPT_NAME empty, cgit emits links of the form +// /infra/dns/log/, the forge's own routes. No repo.desc: the description is in +// repos.json. +func (c *Cgit) write(path, kind string) (string, error) { + url := urlPath(path, kind) + var b strings.Builder + // css stays /cgit.css; the injected bar carries the name. + fmt.Fprintf(&b, "logo=\n") + fmt.Fprintf(&b, "noheader=1\n") + fmt.Fprintf(&b, "enable-blame=1\n") + fmt.Fprintf(&b, "enable-http-clone=0\n") + fmt.Fprintf(&b, "enable-log-filecount=1\n") + fmt.Fprintf(&b, "enable-log-linecount=1\n") + fmt.Fprintf(&b, "snapshots=tar.gz\n") + // Both spellings, since a repository may carry either. + fmt.Fprintf(&b, "readme=:README.md\n") + fmt.Fprintf(&b, "readme=:README\n") + // highlight is fed arbitrary blobs, so capped. In kilobytes. + fmt.Fprintf(&b, "max-blob-size=512\n") + // No mimetype mapping, so every blob comes back as text, not run on this + // origin. + fmt.Fprintf(&b, "mimetype-file=\n") + fmt.Fprintf(&b, "about-filter=%s\n", filepath.Join(c.dir, "md")) + fmt.Fprintf(&b, "source-filter=%s\n", filepath.Join(c.dir, "highlight")) + fmt.Fprintf(&b, "clone-url=%s/%s.git\n", c.store.cfg.BaseURL, url) + fmt.Fprintf(&b, "repo.url=%s\n", url) + fmt.Fprintf(&b, "repo.path=%s\n", c.repoDir(path, kind)) + + name := c.configPath(path, kind) + return name, writeFile(name, []byte(b.String()), 0600) +} + +// urlPath is how a repository and its siblings appear in a URL: infra/dns, +// infra/dns.issues, infra/dns.wiki. +func urlPath(path, kind string) string { + if kind == "" { + return path + } + return path + "." + kind +} + +// browsing bounds the cgit children in flight. Its own budget: a page must not +// queue behind a clone. +var browsing = make(chan struct{}, 8) + +// Authorisation has already happened: cgit is only ever told about a +// repository the viewer may read. +func (f *Forge) handleCgit(w http.ResponseWriter, r *http.Request, path, kind, rest string, repo Repo) { + config, err := f.cgit.config(path, kind) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + // The neutral configuration gitEnv gives: cgit reads repositories with + // git's library. + env := []string{ + "CGIT_CONFIG=" + config, + "PATH_INFO=/" + urlPath(path, kind) + "/" + rest, + "SCRIPT_NAME=", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + } + nav := &injector{ResponseWriter: w, nav: f.navPart(r, "header", path, kind, repo)} + // The forge's tabs go inside cgit's row; a sibling's history has none. + if kind == "" { + nav.tabs = f.navPart(r, "tabs", path, kind, repo) + } + if strings.HasPrefix(rest, "about") { + nav.summary = "/" + urlPath(path, kind) + "/" + } + release, ok := childSlot(browsing, r) + if !ok { + return + } + defer release() + (&cgi.Handler{Path: f.cgit.binary, Env: env, Stderr: os.Stdout}).ServeHTTP(nav, r) + nav.flush() +} + +// navPart renders one of nav.html's partials for injection into a cgit page: +// injected, so it follows the knobs and names the account. +func (f *Forge) navPart(r *http.Request, part, path, kind string, repo Repo) []byte { + d := f.page(r) + d.Repo = f.repoBar(r, path, kind, "", repo) + var buf bytes.Buffer + if err := f.nav.ExecuteTemplate(&buf, part, d); err != nil { + log.Printf("nav %s: %v", part, err) + return nil + } + return buf.Bytes() +} + +// injector puts the header inside cgit's div and the tabs inside its tab row. +// Only text/html is buffered, and only until both are placed or the window +// runs out. +type injector struct { + http.ResponseWriter + nav []byte + tabs []byte + summary string // where to send an about page cgit declined to answer + html bool + wrote bool + injected bool + buf bytes.Buffer +} + +// cgitTabs is where cgit's own tab cell ends; the forge's go in before it. +var cgitTabs = []byte("</td><td class='form'>") + +// cgitDiv is the element cgit puts its whole page in. +var cgitDiv = []byte("<div id='cgit'>") + +// maxInject bounds the wait for markup that may never come. +const maxInject = 64 << 10 + +func (w *injector) WriteHeader(status int) { + if w.wrote { + return + } + w.wrote = true + // Without a readme cgit redirects the about page to a path that is not a + // route. + if w.summary != "" && status >= 300 && status < 400 { + if strings.Contains(w.Header().Get("Location"), "..") { + w.Header().Set("Location", w.summary) + } + } + if strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") { + w.html = true + // The bar changes the length. + w.Header().Del("Content-Length") + } + w.ResponseWriter.WriteHeader(status) +} + +func (w *injector) Write(p []byte) (int, error) { + if !w.wrote { + w.WriteHeader(http.StatusOK) + } + if !w.html || w.injected { + return w.ResponseWriter.Write(p) + } + w.buf.Write(p) + // Both go in the first kilobyte of a cgit page. + done := w.placed() + if done || w.buf.Len() > maxInject { + if !done { + cgitMoved() + } + w.injected = true + if err := w.drain(); err != nil { + return 0, err + } + } + return len(p), nil +} + +// cgitMoved is a cgit whose markup no longer carries what the bar goes on: a +// package upgrade. +func cgitMoved() { + log.Printf("cgit: the bar was not placed, so cgit's markup has moved") +} + +// placed reports whether anything is left to wait for. The tabs go in first: +// the header carries a cell of its own. +func (w *injector) placed() bool { + tabs := insertNear(&w.buf, cgitTabs, w.tabs, false) + return insertNear(&w.buf, cgitDiv, w.nav, true) && tabs +} + +// insertNear reports whether what went in. Nothing to insert, or something +// already inserted, counts as done. +func insertNear(buf *bytes.Buffer, needle, what []byte, after bool) bool { + if len(what) == 0 || bytes.Contains(buf.Bytes(), what) { + return true + } + at := bytes.Index(buf.Bytes(), needle) + if at < 0 { + return false + } + if after { + at += len(needle) + } + rest := append([]byte(nil), buf.Bytes()[at:]...) + buf.Truncate(at) + buf.Write(what) + buf.Write(rest) + return true +} + +func (w *injector) flush() { + if w.buf.Len() > 0 { + cgitMoved() + w.drain() + } +} + +func (w *injector) drain() error { + out := w.buf.Bytes() + w.buf.Reset() + _, err := w.ResponseWriter.Write(out) + return err +} diff --git a/cgit_test.go b/cgit_test.go new file mode 100644 index 0000000..1d62694 --- /dev/null +++ b/cgit_test.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// Every cgit page forks a child, and browsing a public repository takes no +// credential, so the budget is what stands between a visitor and one child per +// connection. A request that cannot have a slot waits for one, but not on +// behalf of somebody who has already hung up. +func TestBrowsingIsBounded(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled}) + h := f.routes() + + for range cap(browsing) { + browsing <- struct{}{} + } + defer func() { + for range cap(browsing) { + <-browsing + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan struct{}) + go func() { + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/notes/", nil).WithContext(ctx)) + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("a browse held a goroutine waiting to fork for a client that had gone") + } +} + +// A tab that leads to a 404 is worse than no tab. One template draws the row +// for both kinds of page, so both are asked here. +func TestTabsFollowTheKnobs(t *testing.T) { + f := testForge(t) + addRepo(t, f, "docs", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) + addRepo(t, f, "code", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + + for _, c := range []struct { + path, page string + issues, wiki bool + }{ + {"docs", "/docs/wiki", false, true}, + {"code", "/code/issues", true, false}, + } { + repo, _ := f.store.Repo(c.path) + r := httptest.NewRequest("GET", "/"+c.path+"/", nil) + rows := []struct{ where, html string }{ + {"injected into cgit's row", string(f.navPart(r, "tabs", c.path, "", repo))}, + {"on our own page", get(h, c.page, nil).Body.String()}, + } + for _, row := range rows { + for _, tab := range []struct { + name string + want bool + }{{"issues", c.issues}, {"wiki", c.wiki}} { + got := strings.Contains(row.html, ">"+tab.name+"</a>") + if got != tab.want { + t.Errorf("%s %s: the %s tab is present %v, wanted %v", + c.path, row.where, tab.name, got, tab.want) + } + } + } + } +} + +// The bar is spliced into cgit's own markup by matching it, so a cgit whose +// page has changed shape has to fail here rather than quietly serve a page +// with no bar on it. +func TestTheBarGoesInsideCgitsPage(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + if _, err := os.Stat(cgitBinary()); err != nil { + t.Skip("cgit is not installed, and this is a test of cgit's markup") + } + page := get(f.routes(), "/notes/", nil).Body.String() + + div := strings.Index(page, string(cgitDiv)) + header := strings.Index(page, `<table id="header">`) + cell := strings.Index(page, string(cgitTabs)) + tabs := strings.Index(page, `>issues</a>`) + switch { + case div < 0: + t.Fatalf("cgit no longer opens its page with %s:\n%s", cgitDiv, page) + case cell < 0: + t.Fatalf("cgit no longer ends its tab cell with %s:\n%s", cgitTabs, page) + case header < 0: + t.Error("the header was not injected") + case header < div: + t.Error("the header landed outside cgit's own div") + case tabs < 0: + t.Error("the forge's tabs were not injected") + case tabs > cell: + t.Error("the forge's tabs landed after cgit's own tab cell rather than in it") + } + if n := strings.Count(page, `<table id="header">`); n != 1 { + t.Errorf("the header went in %d times", n) + } +} + +// Only text/html is held back, and only until there is nothing left to wait +// for: a snapshot has to stream through whole and untouched. +func TestTheInjectorPassesThroughWhatItCannotPlace(t *testing.T) { + body := bytes.Repeat([]byte("x"), 3*maxInject) + for _, kind := range []string{"text/html; charset=utf-8", "application/x-gzip"} { + w := httptest.NewRecorder() + w.Header().Set("Content-Type", kind) + nav := &injector{ResponseWriter: w, nav: []byte("NAV"), tabs: []byte("TABS")} + nav.WriteHeader(http.StatusOK) + if _, err := nav.Write(body); err != nil { + t.Fatalf("%s: %v", kind, err) + } + nav.flush() + if got := w.Body.Bytes(); !bytes.Equal(got, body) { + t.Errorf("%s: %d bytes came back, of the %d that went in", kind, len(got), len(body)) + } + } +} + +// Bodies are handed to goldmark straight and the result is trusted as HTML, so +// leaving raw HTML unrendered is the whole reason that is safe. +func TestMarkdownIsNotHTML(t *testing.T) { + cases := map[string]string{ + "<script>alert(1)</script>": "alert", + "<img src=x onerror=alert(1)>": "onerror", + "[click](javascript:alert(1))": "javascript:", + "[click](data:text/html,%3Cs)": "data:", + "<a href='vbscript:x'>hello</a>": "vbscript:", + } + for source, forbidden := range cases { + out := string(renderMarkdown(source)) + if strings.Contains(out, forbidden) { + t.Errorf("%q rendered to %q, which still carries %q", source, out, forbidden) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..0739de5 --- /dev/null +++ b/config.go @@ -0,0 +1,263 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "regexp" + "strings" +) + +// Config is {datadir}/config/forge.json. It has no field for a secret: every +// secret is environment only. SiteName, SiteDesc, Logo, and Icon are read once +// at startup, into Site. +type Config struct { + Listen string `json:"listen"` + SiteName string `json:"site_name"` + SiteDesc string `json:"site_desc"` + Logo string `json:"logo"` + Icon string `json:"icon"` + BaseURL string `json:"base_url"` + OIDC []OIDCProvider `json:"oidc"` + Mail MailConfig `json:"mail"` + + // ClientIPHeader names the header the client address arrives in, behind a + // proxy. Empty means the connection is the address. + ClientIPHeader string `json:"client_ip_header"` + + host string // BaseURL's host without the port, for generated addresses +} + +type OIDCProvider struct { + Key string `json:"key"` + Label string `json:"label"` + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + Allow []string `json:"allow,omitempty"` + + ClientSecret string `json:"-"` // GIT_FORGE_OIDC_<key>_CLIENT_SECRET +} + +// An entry beginning with @ is a whole domain, anything else is one address, +// and an empty list admits anyone. Checked at every login, so removing an +// entry revokes. +func (p OIDCProvider) allows(email string) bool { + if len(p.Allow) == 0 { + return true + } + email = strings.ToLower(email) + _, domain, split := strings.Cut(email, "@") + for _, entry := range p.Allow { + entry = strings.ToLower(entry) + if want, isDomain := strings.CutPrefix(entry, "@"); isDomain { + // Whole label, not suffix. + if split && domain == want { + return true + } + continue + } + if email == entry { + return true + } + } + return false +} + +type MailConfig struct { + Driver string `json:"driver"` + From string `json:"from"` + Mailgun MailgunConfig `json:"mailgun"` +} + +type MailgunConfig struct { + Domain string `json:"domain"` + BaseURL string `json:"base_url"` + + APIKey string `json:"-"` // GIT_FORGE_MAIL_MAILGUN_API_KEY +} + +// An OIDC key prefixes the accounts it mints, as in sso:carol, and names the +// client secret's environment variable. +var oidcKeyRE = regexp.MustCompile(`^[a-z][a-z0-9]{0,15}$`) + +// A header name. One with a colon in it never matches, which looks like the +// proxy failing to set it. +var headerNameRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) + +// The name reaches the realm git is prompted with, where a quote or backslash +// ends it early. +func validSiteName(name string) bool { + return name != "" && !strings.ContainsAny(name, `"\`) && !hasControlByte(name) +} + +var errSiteName = userError("the site needs a name, without a quote, a backslash, or a control byte in it") + +func defaultConfig() *Config { + return &Config{ + Listen: ":8080", + SiteName: "git-forge", + SiteDesc: "a small self-hosted git forge", + BaseURL: "http://localhost:8080", + OIDC: []OIDCProvider{}, + Mail: MailConfig{ + Driver: "none", + From: "git-forge <git-forge@localhost>", + Mailgun: MailgunConfig{BaseURL: "https://api.mailgun.net/v3"}, + }, + } +} + +// readConfig is the file alone. An unknown field is an error, so a typo cannot +// silently do nothing. +func readConfig(path string) (*Config, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + cfg := &Config{} + dec := json.NewDecoder(f) + dec.DisallowUnknownFields() + if err := dec.Decode(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +// loadConfig is the file with the environment on top, validated. +func loadConfig(path string) (*Config, error) { + cfg, err := readConfig(path) + if err != nil { + return nil, err + } + cfg.applyEnv() + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func (c *Config) applyEnv() { + fromEnv(&c.Listen, "GIT_FORGE_LISTEN") + fromEnv(&c.SiteName, "GIT_FORGE_SITE_NAME") + fromEnv(&c.SiteDesc, "GIT_FORGE_SITE_DESC") + fromEnv(&c.BaseURL, "GIT_FORGE_BASE_URL") + fromEnv(&c.ClientIPHeader, "GIT_FORGE_CLIENT_IP_HEADER") + + c.Mail.Mailgun.APIKey = os.Getenv("GIT_FORGE_MAIL_MAILGUN_API_KEY") + for i := range c.OIDC { + key := strings.ToUpper(c.OIDC[i].Key) + c.OIDC[i].ClientSecret = os.Getenv("GIT_FORGE_OIDC_" + key + "_CLIENT_SECRET") + } +} + +func fromEnv(dst *string, name string) { + if v, ok := os.LookupEnv(name); ok { + *dst = v + } +} + +// validate reports everything wrong at once. +func (c *Config) validate() error { + var errs []error + fail := func(format string, a ...any) { + errs = append(errs, fmt.Errorf(format, a...)) + } + + if c.Listen == "" { + fail("listen: empty") + } + if !validSiteName(c.SiteName) { + fail("site_name: empty, or holds a quote, a backslash, or a control byte") + } + if c.ClientIPHeader != "" && !headerNameRE.MatchString(c.ClientIPHeader) { + fail("client_ip_header: %q is not a header name", c.ClientIPHeader) + } + + c.BaseURL = strings.TrimSuffix(c.BaseURL, "/") + u, err := url.Parse(c.BaseURL) + switch { + case err != nil: + fail("base_url: %v", err) + case u.Scheme != "http" && u.Scheme != "https": + fail("base_url: scheme must be http or https, not %q", u.Scheme) + case u.Host == "": + fail("base_url: no host") + case u.Path != "" || u.RawQuery != "" || u.Fragment != "": + fail("base_url: scheme and host only, git-forge serves the root") + default: + c.host = u.Hostname() // no port: this ends up in mail addresses + } + + seen := map[string]bool{} + for i, p := range c.OIDC { + at := fmt.Sprintf("oidc[%d]", i) + switch { + case !oidcKeyRE.MatchString(p.Key): + fail("%s: key %q must match %s, it prefixes account names", at, p.Key, oidcKeyRE) + case seen[p.Key]: + fail("%s: duplicate key %q", at, p.Key) + default: + seen[p.Key] = true + } + if p.Label == "" { + fail("%s: label: empty", at) + } + if p.ClientID == "" { + fail("%s: client_id: empty", at) + } + iss, err := url.Parse(p.Issuer) + switch { + case err != nil: + fail("%s: issuer: %v", at, err) + case iss.Scheme != "https": + fail("%s: issuer: must be https", at) + case iss.Host == "": + fail("%s: issuer: no host", at) + case strings.HasSuffix(p.Issuer, "/"): + fail("%s: issuer: no trailing slash, it is compared to the iss claim", at) + } + // An entry that can never match is a lockout, refused here rather than + // at login. + for j, entry := range p.Allow { + switch { + case entry == "" || strings.TrimSpace(entry) != entry: + fail("%s: allow[%d]: empty or padded with spaces", at, j) + case entry == "@": + fail("%s: allow[%d]: a domain entry needs a domain after the @", at, j) + case !strings.Contains(entry, "@"): + fail("%s: allow[%d]: %q is neither an address nor an @domain", at, j, entry) + } + } + } + + if c.Mail.From == "" { + fail("mail.from: empty") + } + switch c.Mail.Driver { + case "none", "log": + case "mailgun": + if c.Mail.Mailgun.Domain == "" { + fail("mail.mailgun.domain: empty") + } + if c.Mail.Mailgun.APIKey == "" { + fail("mail.mailgun: GIT_FORGE_MAIL_MAILGUN_API_KEY is not set") + } + base, err := url.Parse(c.Mail.Mailgun.BaseURL) + switch { + case err != nil: + fail("mail.mailgun.base_url: %v", err) + case base.Scheme != "https" || base.Host == "": + fail("mail.mailgun.base_url: must be an https url") + case strings.HasSuffix(c.Mail.Mailgun.BaseURL, "/"): + fail("mail.mailgun.base_url: no trailing slash") + } + default: + fail("mail.driver: %q is not none, log, or mailgun", c.Mail.Driver) + } + + return errors.Join(errs...) +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..fe7406d --- /dev/null +++ b/config_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "strings" + "testing" +) + +// Startup refuses an incoherent file rather than guessing, and reports +// everything wrong at once. What each rule catches matters most for the ones +// that would otherwise fail silently much later: an allow entry that can never +// match is a lockout, and a bad issuer is a login that never works. +func TestConfigValidate(t *testing.T) { + provider := OIDCProvider{Key: "sso", Label: "SSO", Issuer: "https://id.example", ClientID: "forge"} + cases := []struct { + what string + spoil func(*Config) + want string + }{ + {"a good file", func(*Config) {}, ""}, + {"no listener", func(c *Config) { c.Listen = "" }, "listen"}, + {"no name", func(c *Config) { c.SiteName = "" }, "site_name"}, + {"a quote in the name", func(c *Config) { c.SiteName = `a"b` }, "site_name"}, + {"a header name with a colon", func(c *Config) { c.ClientIPHeader = "X-Real-IP:" }, "client_ip_header"}, + {"a base url with a path", func(c *Config) { c.BaseURL = "https://git.example/forge" }, "base_url"}, + {"a base url with no scheme", func(c *Config) { c.BaseURL = "git.example" }, "base_url"}, + {"an oidc key that prefixes nothing usable", func(c *Config) { + p := provider + p.Key = "SSO" + c.OIDC = []OIDCProvider{p} + }, "key"}, + {"two providers under one key", func(c *Config) { c.OIDC = []OIDCProvider{provider, provider} }, "duplicate"}, + {"a provider with no label", func(c *Config) { + p := provider + p.Label = "" + c.OIDC = []OIDCProvider{p} + }, "label"}, + {"an issuer that is not https", func(c *Config) { + p := provider + p.Issuer = "http://id.example" + c.OIDC = []OIDCProvider{p} + }, "issuer"}, + {"an issuer with a trailing slash", func(c *Config) { + p := provider + p.Issuer = "https://id.example/" + c.OIDC = []OIDCProvider{p} + }, "trailing slash"}, + {"an allow entry that is neither", func(c *Config) { + p := provider + p.Allow = []string{"everyone"} + c.OIDC = []OIDCProvider{p} + }, "allow[0]"}, + {"an allow entry that is a bare at", func(c *Config) { + p := provider + p.Allow = []string{"@"} + c.OIDC = []OIDCProvider{p} + }, "allow[0]"}, + {"an allow entry padded with spaces", func(c *Config) { + p := provider + p.Allow = []string{" carol@example.com"} + c.OIDC = []OIDCProvider{p} + }, "allow[0]"}, + {"a good provider", func(c *Config) { + p := provider + p.Allow = []string{"carol@example.com", "@example.com"} + c.OIDC = []OIDCProvider{p} + }, ""}, + {"no sender", func(c *Config) { c.Mail.From = "" }, "mail.from"}, + {"a driver that is neither", func(c *Config) { c.Mail.Driver = "sendmail" }, "mail.driver"}, + {"mailgun with no domain", func(c *Config) { c.Mail.Driver = "mailgun" }, "mailgun.domain"}, + {"mailgun with no key in the environment", func(c *Config) { + c.Mail.Driver = "mailgun" + c.Mail.Mailgun.Domain = "mg.example" + }, "MAILGUN_API_KEY"}, + } + for _, c := range cases { + cfg := defaultConfig() + c.spoil(cfg) + err := cfg.validate() + switch { + case c.want == "" && err != nil: + t.Errorf("%s was refused: %v", c.what, err) + case c.want != "" && err == nil: + t.Errorf("%s was accepted", c.what) + case c.want != "" && !strings.Contains(err.Error(), c.want): + t.Errorf("%s was refused for the wrong reason: %v", c.what, err) + } + } +} + +// Everything wrong at once, because fixing a config one restart at a time is +// miserable. +func TestConfigReportsEveryFault(t *testing.T) { + cfg := defaultConfig() + cfg.Listen, cfg.SiteName, cfg.BaseURL, cfg.Mail.From = "", "", "ftp://x", "" + err := cfg.validate() + if err == nil { + t.Fatal("a file with four faults in it was accepted") + } + for _, want := range []string{"listen", "site_name", "base_url", "mail.from"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q is not in the report:\n%v", want, err) + } + } +} + +// The host is what generated addresses are built from, so the port has to come +// off and a trailing slash must not survive into it. +func TestConfigBaseURLIsNormalised(t *testing.T) { + cfg := defaultConfig() + cfg.BaseURL = "https://git.example.com:8443/" + if err := cfg.validate(); err != nil { + t.Fatal(err) + } + if cfg.BaseURL != "https://git.example.com:8443" { + t.Errorf("base_url is %q", cfg.BaseURL) + } + if cfg.host != "git.example.com" { + t.Errorf("addresses would be built from %q", cfg.host) + } +} diff --git a/filters.go b/filters.go new file mode 100644 index 0000000..64a1d21 --- /dev/null +++ b/filters.go @@ -0,0 +1,73 @@ +package main + +// The two filters: in process for the forge's pages, as programs for cgit. + +import ( + "bytes" + "fmt" + "html/template" + "io" + "log" + "os" + "os/exec" + "path" + + "github.com/yuin/goldmark" +) + +// Raw HTML is left unrendered, goldmark's default, which is what makes user +// text safe to hand it. +var markdown = goldmark.New() + +func renderMarkdown(source string) template.HTML { + var buf bytes.Buffer + if err := markdown.Convert([]byte(source), &buf); err != nil { + log.Printf("markdown: %v", err) + return template.HTML(template.HTMLEscapeString(source)) + } + return template.HTML(buf.String()) +} + +// runMarkdown is the about-filter: markdown on stdin, HTML on stdout. +func runMarkdown() int { + source, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, "md:", err) + return 1 + } + if err := markdown.Convert(source, os.Stdout); err != nil { + // A readable page beats an empty one. + fmt.Fprintln(os.Stderr, "md:", err) + template.HTMLEscape(os.Stdout, source) + return 1 + } + return 0 +} + +// runHighlight is the source-filter: cgit's bundled one issues a command line +// the installed highlight refuses, silently. +func runHighlight(args []string) int { + source, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, "highlight:", err) + return 1 + } + name := "source" + if len(args) > 0 && args[0] != "" { + name = path.Base(args[0]) + } + // --force: an unknown syntax comes back as escaped text, not as nothing. + cmd := exec.Command("highlight", "--fragment", "--force", "--out-format=html", "--syntax-by-name="+name) + cmd.Stdin = bytes.NewReader(source) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil || len(bytes.TrimSpace(out)) == 0 { + if err != nil { + fmt.Fprintln(os.Stderr, "highlight:", err) + } + template.HTMLEscape(os.Stdout, source) + return 0 + } + os.Stdout.Write(out) + return 0 +} diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..866863d --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +// The parsers that are handed bytes from outside: an id token, a claim, a URL +// path, and a secret typed into a form. Each target states the property the +// caller relies on, so the seed corpus runs with the rest of the tests and a +// longer search is one flag away. + +// splitJWT takes a token apart before anything is known about it, and +// verifySignature is then handed whatever came out. +func FuzzSplitJWT(f *testing.F) { + f.Add("aGVhZGVy.cGF5bG9hZA.c2ln") + f.Add("...") + f.Add("") + f.Fuzz(func(t *testing.T, token string) { + signed, sig, kid, alg, _, err := splitJWT(token) + if err != nil { + return + } + if verifySignature(jwk{Kty: "RSA", N: kid, E: "AQAB"}, alg, signed, sig) == nil { + t.Fatalf("a made up rsa key verified %q", token) + } + if verifySignature(jwk{Kty: "EC", Crv: "P-256", X: kid, Y: kid}, alg, signed, sig) == nil { + t.Fatalf("a made up ec key verified %q", token) + } + }) +} + +// The name becomes a key in repos.json and a component of a URL, out of a +// claim from somewhere else. +func FuzzSanitizeName(f *testing.F) { + f.Add("Carol") + f.Add("../../config") + f.Fuzz(func(t *testing.T, in string) { + if out := sanitizeName(in); out != "" && !localNameRE.MatchString(out) { + t.Fatalf("sanitizeName(%q) = %q, which is not a usable account name", in, out) + } + }) +} + +// Whatever this lets through is handed to both CGIs in an environment +// variable. +func FuzzServablePath(f *testing.F) { + f.Add("/notes/log/") + f.Add("/a/../b") + f.Fuzz(func(t *testing.T, p string) { + if !servablePath(p) { + return + } + if hasControlByte(p) { + t.Fatalf("servablePath(%q) accepted a control byte", p) + } + parts := strings.Split(strings.TrimPrefix(p, "/"), "/") + for i, part := range parts { + if part == "." || part == ".." || (part == "" && i < len(parts)-1) { + t.Fatalf("servablePath(%q) accepted a %q component", p, part) + } + } + }) +} + +// Whatever this lets through is joined onto the repos directory, for the +// repository and for both of its siblings. +func FuzzValidRepoPath(f *testing.F) { + f.Add("infra/dns") + f.Add("../etc") + f.Fuzz(func(t *testing.T, p string) { + if !validRepoPath(p) { + return + } + for _, suffix := range []string{".git", ".issues.git", ".wiki.git"} { + joined := filepath.Join("/repos", p+suffix) + if joined != filepath.Clean(joined) || !strings.HasPrefix(joined, "/repos/") { + t.Fatalf("validRepoPath(%q) accepted a path that lands on %q", p, joined) + } + if strings.Count(strings.TrimPrefix(joined, "/repos/"), "/") > 1 { + t.Fatalf("validRepoPath(%q) accepted more than one section deep", p) + } + } + }) +} + +// A page listed in the index has to open the file it was listed from. A push +// can put any name in the tree. +func FuzzWikiNameRoundTrip(f *testing.F) { + f.Add("docs/index.md") + f.Add("myindex.md") + f.Add(".md") + f.Fuzz(func(t *testing.T, file string) { + page := wikiPage(file) + files, ok := wikiFiles(page) + if !ok || !slices.Contains(files, file) { + return // not listed, so never linked to + } + if wikiPage(files[0]) != page { + t.Fatalf("%q is listed as %q, which reads back as %q", file, page, wikiPage(files[0])) + } + }) +} + +// The secret being enrolled arrives in a form, so this decodes whatever was +// typed. +func FuzzTOTPSecret(f *testing.F) { + f.Add("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + f.Add("!!!") + f.Fuzz(func(t *testing.T, secret string) { + code := totpCode(secret, time.Unix(1234567890, 0)) + if code != "" && len(code) != totpDigits { + t.Fatalf("totpCode(%q) = %q", secret, code) + } + if code == "" && totpStep(secret, "", time.Unix(1234567890, 0)) >= 0 { + t.Fatalf("an unreadable secret accepted an empty code") + } + }) +} diff --git a/git-forge.initd b/git-forge.initd new file mode 100755 index 0000000..25c0cfd --- /dev/null +++ b/git-forge.initd @@ -0,0 +1,22 @@ +#!/sbin/openrc-run +# OpenRC service for git-forge. Install as /etc/init.d/git-forge with a +# forge user present; secrets and overrides go in /etc/conf.d/git-forge, +# mode 0600, as VAR=value lines. + +description="git-forge" +datadir="${GIT_FORGE_DATADIR:-/var/lib/git-forge}" +command=/usr/local/bin/git-forge +command_args="-datadir $datadir" +command_user="forge:forge" +command_background=yes +pidfile=/run/git-forge.pid +output_log=/var/log/git-forge.log +error_log=/var/log/git-forge.log + +depend() { + need net +} + +start_pre() { + checkpath -d -m 0700 -o forge:forge "$datadir" +} @@ -0,0 +1,296 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// Git is driven by exec'ing it: cgit and git-http-backend already require the +// binary, and a Go implementation could only disagree with it. + +// gitEnv neutralises the system and global configuration. +func gitEnv(extra []string) []string { + env := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null") + return append(env, extra...) +} + +// git returns stdout alone; stderr goes into the error, since stdout is +// usually an object id. +func git(dir string, env []string, args ...string) ([]byte, error) { + return gitIn(dir, nil, env, args...) +} + +func gitIn(dir string, stdin []byte, env []string, args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = gitEnv(env) + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + var out, fail bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &fail + if err := cmd.Run(); err != nil { + // %w, so a caller can ask git's exit status. + if msg := bytes.TrimSpace(fail.Bytes()); len(msg) > 0 { + return out.Bytes(), fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg) + } + return out.Bytes(), fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return out.Bytes(), nil +} + +// gitInit uses an empty template, so no sample hooks or placeholder +// description, and an explicit branch, so nothing is inherited from +// init.defaultBranch. +func gitInit(dir string, bare bool) error { + tmpl, err := os.MkdirTemp("", "git-forge-template") + if err != nil { + return err + } + defer os.RemoveAll(tmpl) + + args := []string{"init", "--quiet", "--template=" + tmpl, "--initial-branch=master"} + if bare { + args = append(args, "--bare") + } + _, err = git("", nil, append(args, dir)...) + return err +} + +// change is one path in a tree. Nil data removes it. +type change struct { + path string + data []byte +} + +// branch is the only branch git-forge ever writes. +const branch = "refs/heads/master" + +// commitWith moves the branch with a compare and swap, retrying when it moved. +// build is handed the commit the branch points at, "" when empty, on every +// attempt: a sequential id must be read again after a lost race. It returns +// the subject too. +func commitWith(dir string, author, committer Author, build func(base string) ([]change, string, error)) error { + defer lockRepo(dir)() + + var err error + for attempt := 0; attempt < 8; attempt++ { + if attempt > 0 { + // Git holds its ref lock for a moment. + time.Sleep(time.Duration(attempt) * 10 * time.Millisecond) + } + var base string + if base, err = resolveRef(dir, branch); err != nil { + return err + } + var changes []change + var subject string + if changes, subject, err = build(base); err != nil { + return err + } + var tree string + if tree, err = writeTree(dir, base, changes); err != nil { + return err + } + var commit string + if commit, err = commitTree(dir, tree, base, subject, author, committer); err != nil { + return err + } + // An empty old value means the branch must not exist yet. + if _, err = git(dir, nil, "update-ref", branch, commit, base); err == nil { + // Plumbing runs no maintenance, and nothing else collects these + // repositories. + gcAuto(dir) + return nil + } + } + return fmt.Errorf("%s: gave up after eight attempts: %v", dir, err) +} + +// Git's own thresholds decide whether anything happens; gc.autoDetach keeps +// the work off the request that triggered it. +func gcAuto(dir string) { + env := []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=gc.autoDetach", "GIT_CONFIG_VALUE_0=true"} + if _, err := git(dir, env, "gc", "--auto", "--quiet"); err != nil { + log.Printf("git: gc --auto in %s: %v", dir, err) + } +} + +// lockRepo serialises this process's writers to one repository, so they do not +// burn their retries on each other. Git's compare and swap is what makes them +// correct. +var writeLocks sync.Map // repository directory to *sync.Mutex + +func lockRepo(dir string) func() { + value, _ := writeLocks.LoadOrStore(dir, &sync.Mutex{}) + mu := value.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +// resolveRef returns "" for a ref that is not there, and an error for a +// repository that is not: rev-parse exits 1 for the first and 128 for the +// second, and a missing directory must not read as an empty page. +func resolveRef(dir, ref string) (string, error) { + out, err := git(dir, nil, "rev-parse", "--verify", "--quiet", ref) + if err == nil { + return strings.TrimSpace(string(out)), nil + } + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 { + return "", nil + } + return "", err +} + +// writeTree uses a temporary index rather than mktree, because these paths +// nest and update-index rewrites the intermediate trees. The index sits +// outside the repository: nothing the forge writes goes inside one. +func writeTree(dir, base string, changes []change) (string, error) { + // A directory, not a file: git refuses an index file that exists and is + // empty. + tmp, err := os.MkdirTemp("", "git-forge-index") + if err != nil { + return "", err + } + defer os.RemoveAll(tmp) + env := []string{"GIT_INDEX_FILE=" + filepath.Join(tmp, "index")} + + if base != "" { + if _, err := git(dir, env, "read-tree", base); err != nil { + return "", err + } + } + for _, c := range changes { + if c.data == nil { + if _, err := git(dir, env, "update-index", "--force-remove", c.path); err != nil { + return "", err + } + continue + } + blob, err := gitIn(dir, c.data, nil, "hash-object", "-w", "--stdin") + if err != nil { + return "", err + } + id := strings.TrimSpace(string(blob)) + if _, err := git(dir, env, "update-index", "--add", "--cacheinfo", "100644,"+id+","+c.path); err != nil { + return "", err + } + } + tree, err := git(dir, env, "write-tree") + if err != nil { + return "", err + } + return strings.TrimSpace(string(tree)), nil +} + +func commitTree(dir, tree, parent, subject string, author, committer Author) (string, error) { + args := []string{"commit-tree", tree, "-m", subject} + if parent != "" { + args = append(args, "-p", parent) + } + env := []string{ + "GIT_AUTHOR_NAME=" + author.Name, + "GIT_AUTHOR_EMAIL=" + author.Email, + "GIT_COMMITTER_NAME=" + committer.Name, + "GIT_COMMITTER_EMAIL=" + committer.Email, + } + out, err := git(dir, env, args...) + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// catFile reads one blob, named by its id or as commit:path. +func catFile(dir, rev string) ([]byte, error) { + return git(dir, nil, "cat-file", "blob", rev) +} + +func listTreeRecursive(dir, commit string) ([]string, error) { + return treePaths(git(dir, nil, "ls-tree", "-r", "--name-only", commit)) +} + +func listTree(dir, commit, prefix string) ([]string, error) { + return treePaths(git(dir, nil, "ls-tree", "--name-only", commit, prefix)) +} + +func treePaths(out []byte, err error) ([]string, error) { + if err != nil { + return nil, err + } + var paths []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + paths = append(paths, line) + } + } + return paths, nil +} + +// catFiles reads many blobs in one exec, in the order asked; a missing path +// yields nil. +func catFiles(dir string, specs []string) ([][]byte, error) { + if len(specs) == 0 { + return nil, nil + } + out, err := gitIn(dir, []byte(strings.Join(specs, "\n")+"\n"), nil, "cat-file", "--batch") + if err != nil { + return nil, err + } + + blobs := make([][]byte, 0, len(specs)) + for rest := out; len(blobs) < len(specs); { + line, tail, ok := bytes.Cut(rest, []byte("\n")) + if !ok { + break + } + fields := bytes.Fields(line) + // "<oid> <type> <size>" for a hit, "<spec> missing" for a miss. + if len(fields) != 3 { + blobs = append(blobs, nil) + rest = tail + continue + } + size, err := strconv.Atoi(string(fields[2])) + if err != nil || size > len(tail) { + return nil, fmt.Errorf("cat-file --batch: unreadable record %q", line) + } + blobs = append(blobs, tail[:size]) + rest = tail[size:] + rest = bytes.TrimPrefix(rest, []byte("\n")) + } + return blobs, nil +} + +// grepTree is the whole search. A fixed string, not a regular expression: the +// input is hostile. +func grepTree(dir, commit, query, pathspec string) ([]string, error) { + out, err := git(dir, nil, "grep", "-i", "-l", "-F", "-e", query, commit, "--", pathspec) + if err != nil { + // Exit 1 is nothing matched; anything else must not read as an empty + // result. + var exit *exec.ExitError + if errors.As(err, &exit) && exit.ExitCode() == 1 { + return nil, nil + } + return nil, err + } + var paths []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + // Lines are "<commit>:<path>". + if _, path, ok := strings.Cut(line, ":"); ok { + paths = append(paths, path) + } + } + return paths, nil +} @@ -0,0 +1,10 @@ +module git-forge + +go 1.25.0 + +require ( + github.com/yuin/goldmark v1.8.5 + golang.org/x/crypto v0.55.0 +) + +require golang.org/x/sys v0.47.0 // indirect @@ -0,0 +1,6 @@ +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/httpgit.go b/httpgit.go new file mode 100644 index 0000000..f8244ed --- /dev/null +++ b/httpgit.go @@ -0,0 +1,175 @@ +package main + +import ( + "log" + "net/http" + "net/http/cgi" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" +) + +// gitBackend is git's own CGI, located by asking git. On first use, not at +// startup: the filter subcommands must not fork git. +var gitBackend = sync.OnceValue(func() string { + out, err := git("", nil, "--exec-path") + if err != nil { + log.Printf("git: no exec path, so no push or pull over http: %v", err) + return "" + } + return filepath.Join(strings.TrimSpace(string(out)), "git-http-backend") +}) + +var gitProtocolRE = regexp.MustCompile(`^[a-zA-Z0-9=:._-]+$`) + +// transferring bounds the git-http-backend children in flight: an upload-pack +// packs with a thread per core. +var transferring = make(chan struct{}, 8) + +// What one push may weigh; git has no bound of its own. The wiki is held to +// far less, because its knob can be set to any account at all. +const ( + maxPushCode = 1 << 30 + maxPushWiki = 16 << 20 +) + +// handleGitHTTP authorises before anything is exec'd. No form token and no +// session cookie: a push authenticated by a cookie could be driven from +// another site. +func (f *Forge) handleGitHTTP(w http.ResponseWriter, r *http.Request, path, kind, rest string) { + backend := gitBackend() + if backend == "" { + http.Error(w, "git-http-backend is not installed\n", http.StatusInternalServerError) + return + } + v, ok := f.gitViewer(w, r, path) + if !ok { + return + } + // What the viewer may not read does not exist, as on the web. Anonymous is + // challenged instead, so git sends credentials, and challenged whether or + // not the repository is there: a 401 beside a 404 would name the private + // ones. + repo, exists := f.store.Repo(path) + if !exists || !v.CanRead(repo) { + if v.SignedIn() { + http.NotFound(w, r) + } else { + f.gitChallenge(w, "authentication required") + } + return + } + // A feature turned off keeps its repository on disk. + if (kind == "issues" && repo.Issues == featDisabled) || (kind == "wiki" && repo.Wiki == featDisabled) { + http.NotFound(w, r) + return + } + dir := path + gitSuffix(kind) + if _, err := os.Stat(filepath.Join(f.store.reposDir, dir)); err != nil { + http.NotFound(w, r) + return + } + + write := isPush(r, rest) + if write && kind == "issues" { + // The tracker is well formed because the server is its only writer. + http.Error(w, "the issue tracker is written by the forge, not by push. clone and read it, but file issues through the web\n", + http.StatusForbidden) + return + } + if write { + // The wiki knob, not the code write grant: documentation fixes and + // code are separate permissions. + allowed := v.CanWrite(repo) + if kind == "wiki" { + allowed = v.CanWiki(repo) + } + switch { + case allowed: + case v.SignedIn(): + http.Error(w, "you do not have that access to this repository\n", http.StatusForbidden) + return + default: + f.gitChallenge(w, "authentication required") + return + } + } + + weight := maxPushCode + if kind == "wiki" { + weight = maxPushWiki + } + // PATH_INFO comes from the resolved repository, not the URL. The receive + // settings come through the environment: nothing the forge writes goes + // inside a repository. + env := []string{ + "GIT_PROJECT_ROOT=" + f.store.reposDir, + "GIT_HTTP_EXPORT_ALL=1", + "REMOTE_USER=" + v.Name, + "PATH_INFO=/" + dir + "/" + rest, + "SCRIPT_NAME=", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_COUNT=2", + "GIT_CONFIG_KEY_0=receive.unpackLimit", + "GIT_CONFIG_VALUE_0=1", + "GIT_CONFIG_KEY_1=receive.maxInputSize", + "GIT_CONFIG_VALUE_1=" + strconv.Itoa(weight), + } + // As HTTP_GIT_PROTOCOL git-http-backend would not read it, and every + // client would fall back to protocol v0. + if p := r.Header.Get("Git-Protocol"); p != "" && gitProtocolRE.MatchString(p) { + env = append(env, "GIT_PROTOCOL="+p) + } + // git sends a push past http.postBuffer chunked, which net/http/cgi + // refuses; the framing is already decoded here. + r.TransferEncoding = nil + release, ok := childSlot(transferring, r) + if !ok { + return + } + defer release() + (&cgi.Handler{Path: backend, Env: env, Stderr: os.Stdout}).ServeHTTP(w, r) + + if write { + // A push that failed leaves nothing to collect. + gcAuto(filepath.Join(f.store.reposDir, dir)) + } +} + +// gitViewer reads Basic auth carrying a scoped push token, never a password: +// git cannot be asked for a second factor, and a token is separately scoped +// and separately revoked. No credential is the anonymous viewer. +func (f *Forge) gitViewer(w http.ResponseWriter, r *http.Request, path string) (Viewer, bool) { + given, secret, sent := r.BasicAuth() + if !sent { + return Viewer{}, true + } + name, u, ok := f.checkToken(given, secret, path) + if !ok { + f.gitChallenge(w, "bad account name or token") + return Viewer{}, false + } + // adminInEffect, not the raw flag: the web and git have to agree. + return Viewer{Name: name, Admin: u.adminInEffect()}, true +} + +func (f *Forge) gitChallenge(w http.ResponseWriter, msg string) { + w.Header().Set("WWW-Authenticate", `Basic realm="`+f.siteName()+`", charset="UTF-8"`) + http.Error(w, msg+"\n", http.StatusUnauthorized) +} + +// isPush reports whether this request is the start or the body of a push. +func isPush(r *http.Request, rest string) bool { + return rest == "git-receive-pack" || r.URL.Query().Get("service") == "git-receive-pack" +} + +func gitSuffix(kind string) string { + if kind == "" { + return ".git" + } + return "." + kind + ".git" +} diff --git a/httpgit_test.go b/httpgit_test.go new file mode 100644 index 0000000..307d724 --- /dev/null +++ b/httpgit_test.go @@ -0,0 +1,457 @@ +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(), `<pre id="token">`, "</pre>") + 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(), `<pre id="token">`, "</pre>") + 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) + } +} diff --git a/issues.go b/issues.go new file mode 100644 index 0000000..6c300d1 --- /dev/null +++ b/issues.go @@ -0,0 +1,555 @@ +package main + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// Issues live in {repo}.issues.git, where the server is the only writer. These +// are paths inside the git tree, not files on disk: +// +// issues/.next next sequential id +// issues/{id}.json one issue +// issues/{id}/{nnnn}.json one comment, zero padded so they sort +type Issue struct { + ID int `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Author string `json:"author"` + Labels []string `json:"labels"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +type Comment struct { + Author string `json:"author"` + Body string `json:"body"` + Created time.Time `json:"created"` +} + +const ( + issueOpen = "open" + issueClosed = "closed" + + maxTitle = 200 + maxBody = 64 << 10 +) + +func (f *Forge) issuesDir(path string) string { + return filepath.Join(f.store.reposDir, path+".issues.git") +} + +// handleIssues routes everything under {repo}/issues. +func (f *Forge) handleIssues(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) { + if repo.Issues == featDisabled { + f.fail(w, r, http.StatusNotFound, "this repository has issues turned off") + return + } + // rest is issues, issues/new, issues/{id}, or issues/{id}/{action}. + parts := strings.Split(rest, "/") + switch { + case len(parts) == 1 && isGet(r): + f.issueList(w, r, path, repo) + case len(parts) == 1 && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.issueCreate(w, r, path, repo) + } + case len(parts) == 2 && parts[1] == "new" && isGet(r): + f.issueForm(w, r, path, repo) + case len(parts) == 2 && isGet(r): + f.issueShow(w, r, path, repo, parts[1]) + case len(parts) == 3 && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.issueAction(w, r, path, repo, parts[1], parts[2]) + } + default: + f.fail(w, r, http.StatusNotFound, "no such page") + } +} + +type issueListPage struct { + Path string + Issues []Issue + Query string + State string + Labels []string + Can bool // whether the viewer may open one +} + +type issuePage struct { + Path string + Issue Issue + Rendered template.HTML + Comments []renderedComment + Labels []labelState + Can bool // may comment + Triage bool // may close, reopen, and label +} + +// labelState is a repository's labels with this issue's ticked. +type labelState struct { + Name string + On bool +} + +func labelStates(repo Repo, issue Issue) []labelState { + on := map[string]bool{} + for _, label := range issue.Labels { + on[label] = true + } + out := make([]labelState, 0, len(repo.Labels)) + for _, label := range repo.Labels { + out = append(out, labelState{Name: label, On: on[label]}) + } + return out +} + +type renderedComment struct { + Comment Comment + Rendered template.HTML +} + +// issueList is the index, filtered by state and by git grep. +func (f *Forge) issueList(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + dir := f.issuesDir(path) + state := r.URL.Query().Get("state") + if state != issueClosed { + state = issueOpen + } + query := strings.TrimSpace(r.URL.Query().Get("q")) + // The query becomes an argument to git grep, which cannot hold a NUL. + if hasControlByte(query) { + f.fail(w, r, http.StatusBadRequest, "that search has a control byte in it") + return + } + + issues, err := f.readIssues(dir, query) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + shown := []Issue{} + for _, issue := range issues { + if issue.State == state { + shown = append(shown, issue) + } + } + sort.Slice(shown, func(i, j int) bool { return shown[i].ID > shown[j].ID }) + + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "issues", repo) + d.Data = issueListPage{ + Path: path, Issues: shown, Query: query, State: state, + Labels: repo.Labels, Can: f.viewer(r).CanIssues(repo), + } + f.render(w, "issues.html", http.StatusOK, d) +} + +// readIssues loads every issue, or those a search matched; a hit in a comment +// counts for its issue. +func (f *Forge) readIssues(dir, query string) ([]Issue, error) { + head, err := resolveRef(dir, branch) + if err != nil || head == "" { + return nil, err + } + + var want []string + if query == "" { + paths, err := listTree(dir, head, "issues/") + if err != nil { + return nil, err + } + for _, p := range paths { + if strings.HasSuffix(p, ".json") { + want = append(want, head+":"+p) + } + } + } else { + hits, err := grepTree(dir, head, query, "issues/") + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, p := range hits { + id, ok := issueIDFromPath(p) + if !ok || seen[id] { + continue + } + seen[id] = true + want = append(want, head+":issues/"+id+".json") + } + } + + blobs, err := catFiles(dir, want) + if err != nil { + return nil, err + } + issues := []Issue{} + for i, blob := range blobs { + var issue Issue + if blob == nil || json.Unmarshal(blob, &issue) != nil { + // Only a hand edit or a bad restore puts one here. + log.Printf("issues: %q does not read as an issue, leaving it out", want[i]) + continue + } + issues = append(issues, issue) + } + return issues, nil +} + +// issueIDFromPath maps issues/7.json and issues/7/0003.json alike to 7. +func issueIDFromPath(p string) (string, bool) { + rest, ok := strings.CutPrefix(p, "issues/") + if !ok { + return "", false + } + id, _, _ := strings.Cut(rest, "/") + id = strings.TrimSuffix(id, ".json") + if _, err := strconv.Atoi(id); err != nil { + return "", false + } + return id, true +} + +func (f *Forge) issueForm(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + if !f.viewer(r).CanIssues(repo) { + f.fail(w, r, http.StatusForbidden, "you may not open an issue here") + return + } + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "issues", repo) + d.Data = issueListPage{Path: path, Labels: repo.Labels, Can: true} + f.render(w, "issue-new.html", http.StatusOK, d) +} + +func (f *Forge) issueShow(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText string) { + dir := f.issuesDir(path) + head, err := resolveRef(dir, branch) + if err != nil || head == "" { + f.fail(w, r, http.StatusNotFound, "no such issue") + return + } + id, err := strconv.Atoi(idText) + if err != nil || id < 1 { + f.fail(w, r, http.StatusNotFound, "no such issue") + return + } + issue, err := readIssue(dir, head, id) + if err != nil { + f.fail(w, r, http.StatusNotFound, "no such issue") + return + } + + paths, err := listTree(dir, head, fmt.Sprintf("issues/%d/", id)) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + sort.Strings(paths) // zero padded, so this is chronological + specs := make([]string, 0, len(paths)) + for _, p := range paths { + specs = append(specs, head+":"+p) + } + blobs, err := catFiles(dir, specs) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + comments := []renderedComment{} + for i, b := range blobs { + var c Comment + if b == nil || json.Unmarshal(b, &c) != nil { + log.Printf("issues: %q does not read as a comment, leaving it out", specs[i]) + continue + } + comments = append(comments, renderedComment{Comment: c, Rendered: renderMarkdown(c.Body)}) + } + + v := f.viewer(r) + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "issues", repo) + d.Data = issuePage{ + Path: path, Issue: issue, Rendered: renderMarkdown(issue.Body), + Comments: comments, Labels: labelStates(repo, issue), + Can: v.CanIssues(repo), Triage: f.canTriage(v, repo, issue), + } + f.render(w, "issue.html", http.StatusOK, d) +} + +// canTriage is who may close, reopen, and label: the author, and whoever may +// push. +func (f *Forge) canTriage(v Viewer, repo Repo, issue Issue) bool { + return (v.SignedIn() && v.Name == issue.Author) || v.CanWrite(repo) +} + +func (f *Forge) issueCreate(w http.ResponseWriter, r *http.Request, path string, repo Repo) { + v := f.viewer(r) + if !v.CanIssues(repo) { + f.fail(w, r, http.StatusForbidden, "you may not open an issue here") + return + } + if !f.issueLimit.allow(f.peer(r), v, issuesPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many issues opened in the last hour, try again later") + return + } + title := strings.TrimSpace(r.PostFormValue("title")) + body := strings.TrimSpace(r.PostFormValue("body")) + if title == "" || len(title) > maxTitle || len(body) > maxBody { + f.fail(w, r, http.StatusBadRequest, "an issue needs a title, and neither it nor the body may be enormous") + return + } + // The title becomes a mail subject and a log line, both one line. + if hasControlByte(title) { + f.fail(w, r, http.StatusBadRequest, "a title is one line, with no control byte in it") + return + } + + now := time.Now().UTC().Truncate(time.Second) + issue := Issue{ + Title: title, Body: body, State: issueOpen, Author: v.Name, + Labels: pickLabels(r, repo), Created: now, Updated: now, + } + dir := f.issuesDir(path) + author, committer := f.actor(r), f.store.forgeAuthor() + + // Allocated inside the retry, so two at once cannot get the same number. + err := commitWith(dir, author, committer, func(base string) ([]change, string, error) { + next := 1 + if base != "" { + if data, err := catFile(dir, base+":issues/.next"); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + next = n + } + } + } + issue.ID = next + data, err := marshalJSON(issue) + if err != nil { + return nil, "", err + } + return []change{ + {path: "issues/.next", data: []byte(strconv.Itoa(next+1) + "\n")}, + {path: fmt.Sprintf("issues/%d.json", next), data: data}, + }, fmt.Sprintf("issues: open %d", next), nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + f.notifyIssue(path, repo, issue, v.Name, "opened") + http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", path, issue.ID), http.StatusSeeOther) +} + +// issueRef is one issue as a request resolved it; every write reads it again +// inside the retry. +type issueRef struct { + dir string + path string + id int + issue Issue +} + +// Each action answers a refusal itself, and reports whether to redirect. +func (f *Forge) issueAction(w http.ResponseWriter, r *http.Request, path string, repo Repo, idText, action string) { + ref := issueRef{dir: f.issuesDir(path), path: path} + id, err := strconv.Atoi(idText) + if err != nil || id < 1 { + f.fail(w, r, http.StatusNotFound, "no such issue") + return + } + ref.id = id + head, err := resolveRef(ref.dir, branch) + if err != nil || head == "" { + f.fail(w, r, http.StatusNotFound, "no such issue") + return + } + if ref.issue, err = readIssue(ref.dir, head, id); err != nil { + f.fail(w, r, http.StatusNotFound, "no such issue") + return + } + + done := false + switch action { + case "comment": + done = f.issueComment(w, r, repo, ref) + case "state": + done = f.issueState(w, r, repo, ref) + case "labels": + done = f.issueLabels(w, r, repo, ref) + default: + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + if done { + http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", ref.path, ref.id), http.StatusSeeOther) + } +} + +func (f *Forge) issueComment(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool { + v := f.viewer(r) + if !v.CanIssues(repo) { + f.fail(w, r, http.StatusForbidden, "you may not comment here") + return false + } + if !f.commentLimit.allow(f.peer(r), v, commentsPerHour) { + f.fail(w, r, http.StatusTooManyRequests, "too many comments in the last hour, try again later") + return false + } + body := strings.TrimSpace(r.PostFormValue("body")) + if body == "" || len(body) > maxBody { + f.fail(w, r, http.StatusBadRequest, "a comment needs a body, and it may not be enormous") + return false + } + comment := Comment{Author: v.Name, Body: body, Created: time.Now().UTC().Truncate(time.Second)} + data, err := marshalJSON(comment) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return false + } + + var updated Issue + err = commitWith(ref.dir, f.actor(r), f.store.forgeAuthor(), func(base string) ([]change, string, error) { + // The sequence number and timestamp come from the tree as it is now. + issue, err := readIssue(ref.dir, base, ref.id) + if err != nil { + return nil, "", err + } + seq, err := nextComment(ref.dir, base, ref.id) + if err != nil { + return nil, "", err + } + issue.Updated = comment.Created + meta, err := marshalJSON(issue) + if err != nil { + return nil, "", err + } + updated = issue + return []change{ + {path: fmt.Sprintf("issues/%d/%04d.json", ref.id, seq), data: data}, + {path: issuePath(ref.id), data: meta}, + }, fmt.Sprintf("issues: comment on %d", ref.id), nil + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return false + } + f.notifyIssue(ref.path, repo, updated, v.Name, "commented on") + return true +} + +func (f *Forge) issueState(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool { + v := f.viewer(r) + if !f.canTriage(v, repo, ref.issue) { + f.fail(w, r, http.StatusForbidden, "you may not close or reopen this issue") + return false + } + // Decided against the tree, so two people pressing at once cannot both + // close it. + var verb string + issue, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string { + if i.State == issueClosed { + i.State, verb = issueOpen, "reopened" + return fmt.Sprintf("issues: reopen %d", ref.id) + } + i.State, verb = issueClosed, "closed" + return fmt.Sprintf("issues: close %d", ref.id) + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return false + } + f.notifyIssue(ref.path, repo, issue, v.Name, verb) + return true +} + +func (f *Forge) issueLabels(w http.ResponseWriter, r *http.Request, repo Repo, ref issueRef) bool { + if !f.canTriage(f.viewer(r), repo, ref.issue) { + f.fail(w, r, http.StatusForbidden, "you may not label this issue") + return false + } + labels := pickLabels(r, repo) + _, err := f.updateIssue(ref.dir, ref.id, f.actor(r), f.store.forgeAuthor(), func(i *Issue) string { + i.Labels = labels + return fmt.Sprintf("issues: label %d", ref.id) + }) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return false + } + return true +} + +func readIssue(dir, commit string, id int) (Issue, error) { + var issue Issue + blob, err := catFile(dir, commit+":"+issuePath(id)) + if err != nil { + return issue, err + } + return issue, json.Unmarshal(blob, &issue) +} + +func issuePath(id int) string { return fmt.Sprintf("issues/%d.json", id) } + +// updateIssue reads the issue again on every attempt, or the second writer +// would commit a record built from an overtaken read. apply returns the +// subject, known only after the read. +func (f *Forge) updateIssue(dir string, id int, author, committer Author, apply func(*Issue) string) (Issue, error) { + var final Issue + err := commitWith(dir, author, committer, func(base string) ([]change, string, error) { + issue, err := readIssue(dir, base, id) + if err != nil { + return nil, "", err + } + subject := apply(&issue) + issue.Updated = time.Now().UTC().Truncate(time.Second) + data, err := marshalJSON(issue) + if err != nil { + return nil, "", err + } + final = issue + return []change{{path: issuePath(id), data: data}}, subject, nil + }) + return final, err +} + +func nextComment(dir, base string, id int) (int, error) { + if base == "" { + return 1, nil + } + paths, err := listTree(dir, base, fmt.Sprintf("issues/%d/", id)) + if err != nil { + return 0, err + } + next := 1 + for _, p := range paths { + seq, err := strconv.Atoi(strings.TrimSuffix(filepath.Base(p), ".json")) + if err == nil && seq >= next { + next = seq + 1 + } + } + return next, nil +} + +// pickLabels keeps only labels the repository defines. +func pickLabels(r *http.Request, repo Repo) []string { + chosen := map[string]bool{} + for _, label := range r.PostForm["label"] { + chosen[label] = true + } + out := []string{} + for _, label := range repo.Labels { + if chosen[label] { + out = append(out, label) + } + } + return out +} diff --git a/issues_test.go b/issues_test.go new file mode 100644 index 0000000..1dacb5e --- /dev/null +++ b/issues_test.go @@ -0,0 +1,345 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// The loser of a race has to read the counter again rather than reuse the +// number it already had. +func TestIssueIdsUnderConcurrency(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + + const n = 10 + var wg sync.WaitGroup + codes := make([]int, n) + for i := range n { + wg.Add(1) + go func() { + defer wg.Done() + form := url.Values{"csrf": {token}, "title": {fmt.Sprintf("issue %d", i)}, "body": {"body"}} + codes[i] = post(h, "/notes/issues", form, session).Code + }() + } + wg.Wait() + for i, code := range codes { + if code != http.StatusSeeOther { + t.Fatalf("issue %d: status %d", i, code) + } + } + + dir := f.issuesDir("notes") + head, err := resolveRef(dir, branch) + if err != nil || head == "" { + t.Fatalf("no commits in the tracker: %v", err) + } + paths, err := listTree(dir, head, "issues/") + if err != nil { + t.Fatal(err) + } + seen := map[int]bool{} + for _, p := range paths { + if !strings.HasSuffix(p, ".json") { + continue + } + blob, err := catFile(dir, head+":"+p) + if err != nil { + t.Fatal(err) + } + var issue Issue + if err := json.Unmarshal(blob, &issue); err != nil { + t.Fatalf("%s does not parse: %v", p, err) + } + if seen[issue.ID] { + t.Errorf("id %d was handed out twice", issue.ID) + } + seen[issue.ID] = true + } + for id := 1; id <= n; id++ { + if !seen[id] { + t.Errorf("id %d is missing", id) + } + } +} + +// The compare and swap protects the branch; this protects the record. Two +// people acting at once must not undo each other. +func TestIssueNoLostUpdate(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, + Labels: []string{"bug"}, Grants: map[string]string{"alice": grantWrite}}) + h := f.routes() + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + post(h, "/notes/issues", url.Values{"csrf": {token}, "title": {"t"}, "body": {"b"}}, session) + + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); post(h, "/notes/issues/1/state", url.Values{"csrf": {token}}, session) }() + go func() { + defer wg.Done() + post(h, "/notes/issues/1/labels", url.Values{"csrf": {token}, "label": {"bug"}}, session) + }() + wg.Wait() + + dir := f.issuesDir("notes") + head, _ := resolveRef(dir, branch) + issue, err := readIssue(dir, head, 1) + if err != nil { + t.Fatal(err) + } + if issue.State != issueClosed { + t.Errorf("the close was lost: state %q", issue.State) + } + if len(issue.Labels) != 1 || issue.Labels[0] != "bug" { + t.Errorf("the label was lost: %v", issue.Labels) + } +} + +// The tracker is the one repository nothing else can collect: it is written +// with plumbing, and it refuses every push. +// +// Git estimates the loose object count from one shard multiplied by 256, so a +// shard of empty files puts a small repository over gc.auto. +func TestTrackerIsCollected(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + session := signIn(t, f, h, "alice") + + dir := f.issuesDir("notes") + shard := filepath.Join(dir, "objects", "17") + if err := os.MkdirAll(shard, 0700); err != nil { + t.Fatal(err) + } + for i := range 30 { + if err := os.WriteFile(filepath.Join(shard, fmt.Sprintf("%038x", i)), nil, 0600); err != nil { + t.Fatal(err) + } + } + + form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "title": {"one"}, "body": {"b"}} + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("opening an issue: status %d", code) + } + + // gc.autoDetach puts the work in the background, so this waits for it. + for range 40 { + if packs, _ := filepath.Glob(filepath.Join(dir, "objects/pack/*.pack")); len(packs) > 0 { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Error("the tracker is still unpacked two seconds after a write that should have collected it") +} + +// A miss has to be a miss, a tracker that will not open has to be a failure +// rather than an empty page, and a query that could never be an argument is +// refused before anything is exec'd. +func TestIssueSearch(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + for _, issue := range [][2]string{ + {"zone transfer fails", "the secondary does not answer axfr"}, + {"something else", "unrelated"}, + } { + form := url.Values{"csrf": {token}, "title": {issue[0]}, "body": {issue[1]}} + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("opening %q: status %d", issue[0], code) + } + } + + for _, c := range []struct { + q string + code int + hit bool + }{ + {"axfr", http.StatusOK, true}, // matched in the body + {"zone transfer", http.StatusOK, true}, // and in the title + {"nothing matches this", http.StatusOK, false}, + {"", http.StatusOK, true}, // no search at all lists everything + {"a\x00b", http.StatusBadRequest, false}, + {"a\nb", http.StatusBadRequest, false}, + } { + w := get(h, "/notes/issues?q="+url.QueryEscape(c.q), nil) + if w.Code != c.code { + t.Errorf("q=%q: status %d, wanted %d", c.q, w.Code, c.code) + } + if hit := strings.Contains(w.Body.String(), "zone transfer fails"); hit != c.hit { + t.Errorf("q=%q: found the issue %v, wanted %v", c.q, hit, c.hit) + } + } + + if err := os.RemoveAll(f.issuesDir("notes")); err != nil { + t.Fatal(err) + } + if code := get(h, "/notes/issues?q=axfr", nil).Code; code != http.StatusInternalServerError { + t.Errorf("a search of a tracker that will not open: status %d", code) + } +} + +// The server is the tracker's only writer, so a record that does not parse +// means a bad restore or a hand edit. Dropping it silently is an issue that +// stops existing with nothing anywhere to say why. +func TestARecordThatDoesNotParseIsNamed(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + session := signIn(t, f, h, "alice") + form := url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "title": {"readable"}, "body": {"b"}, + } + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("opening the readable issue: status %d", code) + } + + dir := f.issuesDir("notes") + author := f.store.forgeAuthor() + err := commitWith(dir, author, author, func(base string) ([]change, string, error) { + return []change{ + {path: "issues/2.json", data: []byte("not json\n")}, + {path: "issues/1/0001.json", data: []byte("not json either\n")}, + }, "issues: written by hand", nil + }) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + list := get(h, "/notes/issues", nil) + show := get(h, "/notes/issues/1", nil) + log.SetOutput(old) + + if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "readable") { + t.Errorf("the unreadable record took the readable one with it: status %d", list.Code) + } + if show.Code != http.StatusOK { + t.Errorf("an issue with an unreadable comment: status %d", show.Code) + } + for _, want := range []string{"issues/2.json", "issues/1/0001.json"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("%s was left out without a word:\n%s", want, buf.String()) + } + } +} + +func TestIssueRateLimit(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featDisabled}) + h := f.routes() + + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + form := url.Values{"csrf": {token}, "title": {"spam"}, "body": {"spam"}} + + for i := range issuesPerHour { + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("issue %d: status %d", i, code) + } + } + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusTooManyRequests { + t.Errorf("issue %d in an hour: status %d", issuesPerHour+1, code) + } +} + +// A title is bounded in length, and has to be bounded in shape too: it becomes +// a mail subject, and the none driver writes whole messages to the log, so a +// title carrying a second line writes that line into the log itself. +func TestIssueTitleCannotForgeALogLine(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + confirm(t, f, "bob", "bob@example.com", true) + addRepo(t, f, "notes", Repo{ + Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, + Grants: map[string]string{"bob": grantRead}, + }) + h := f.routes() + session := signIn(t, f, h, "alice") + open := func(title string) int { + return post(h, "/notes/issues", url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "title": {title}, "body": {"b"}, + }, session).Code + } + + forged := "innocent\n2026-08-22 00:00:00 203.0.113.9 POST /admin/user/delete 303 0 1ms" + if code := open(forged); code != http.StatusBadRequest { + t.Errorf("a title with a line break in it: status %d", code) + } + for _, title := range []string{"a\rb", "a\x00b", "a\x7fb"} { + if code := open(title); code != http.StatusBadRequest { + t.Errorf("a title holding %q: status %d", title, code) + } + } + if code := open("a real title, with punctuation: and éncoding"); code != http.StatusSeeOther { + t.Fatalf("an ordinary title was refused: status %d", code) + } + + // Nothing the refused titles carried reached the log by way of mail. + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + f.mail.drainOnce() + log.SetOutput(old) + for _, line := range strings.Split(buf.String(), "\n") { + if strings.Contains(line, "POST /admin/user/delete") { + t.Errorf("a line the user wrote is in the log: %q", line) + } + } +} + +// The form is the one page template nothing else here draws, so a field +// renamed out from under it would be a 500 that only the container test sees. +func TestIssueFormIsDrawn(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{ + Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, + Labels: []string{"bug", "docs"}, + }) + h := f.routes() + + // It is a form for writing, so a visitor who may not write does not get it. + if code := get(h, "/notes/issues/new", nil).Code; code != http.StatusForbidden { + t.Errorf("a visitor got the form: status %d", code) + } + w := get(h, "/notes/issues/new", signIn(t, f, h, "alice")) + if w.Code != http.StatusOK { + t.Fatalf("the form: status %d", w.Code) + } + body := w.Body.String() + for _, want := range []string{ + `action="/notes/issues"`, `name="csrf"`, `name="title"`, `name="body"`, + `value="bug"`, `value="docs"`, + } { + if !strings.Contains(body, want) { + t.Errorf("the form has no %s:\n%s", want, body) + } + } +} @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Mail is outbound only, plain text. Unsubscribe says the message respects the +// notify toggle, and puts List-Unsubscribe on it. +type Message struct { + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + Unsubscribe bool `json:"unsubscribe,omitempty"` + Attempts int `json:"attempts,omitempty"` +} + +type Mailer interface { + Send(m Message) error +} + +const ( + mailIdle = 60 * time.Second // how often to look when nothing woke Drain + mailBackoff = 5 * time.Minute // the ceiling on backing off after a failure + mailAttempts = 6 +) + +// Mail is the spool and its drainer: a directory, where adding and removing +// are atomic. +type Mail struct { + dir string + driver Mailer + wake chan struct{} +} + +func newMail(cfg *Config, dir string) *Mail { + var driver Mailer = dropMailer{} + switch cfg.Mail.Driver { + case "log": + driver = logMailer{from: cfg.Mail.From} + case "mailgun": + driver = &mailgunMailer{ + from: cfg.Mail.From, + base: cfg.Mail.Mailgun.BaseURL, + domain: cfg.Mail.Mailgun.Domain, + key: cfg.Mail.Mailgun.APIKey, + list: cfg.BaseURL + "/settings", + client: &http.Client{Timeout: 30 * time.Second}, + } + } + return &Mail{dir: dir, driver: driver, wake: make(chan struct{}, 1)} +} + +// Enqueue never fails the request that caused it: mail is a side effect. +func (m *Mail) Enqueue(msg Message) { + if msg.To == "" { + return + } + data, err := marshalJSON(msg) + if err != nil { + log.Printf("mail: %v", err) + return + } + // A sortable timestamp plus a random suffix, so oldest first is a + // directory sort. + name := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + randomToken(4) + ".json" + if err := writeFile(filepath.Join(m.dir, name), data, 0600); err != nil { + log.Printf("mail: %v", err) + return + } + select { + case m.wake <- struct{}{}: + default: + } +} + +// Drain is the only background goroutine. Delivery is at-least-once: a crash +// between sending and deleting resends once. +func (m *Mail) Drain(ctx context.Context) { + backoff := time.Second + for { + failed := m.drainOnce() + wait := mailIdle + if failed { + wait = backoff + if backoff *= 2; backoff > mailBackoff { + backoff = mailBackoff + } + } else { + backoff = time.Second + } + // After a failure a wake would only retry the head early. A nil + // channel never fires. + wake := m.wake + if failed { + wake = nil + } + select { + case <-ctx.Done(): + return + case <-wake: + case <-time.After(wait): + } + } +} + +// drainOnce reports whether it stopped on a failure; it stops rather than +// skipping ahead, to keep oldest first. +func (m *Mail) drainOnce() bool { + entries, err := os.ReadDir(m.dir) + if err != nil { + log.Printf("mail: %v", err) + return true + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".json") { + names = append(names, entry.Name()) + } + } + sort.Strings(names) + + for _, name := range names { + path := filepath.Join(m.dir, name) + data, err := os.ReadFile(path) + if err != nil { + log.Printf("mail: %s: %v", name, err) + return true + } + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("mail: %s does not parse, dropping it: %v", name, err) + os.Remove(path) + continue + } + if err := m.driver.Send(msg); err != nil { + msg.Attempts++ + if msg.Attempts >= mailAttempts { + // Never the body: it may carry a set-password link. + log.Printf("mail: giving up on %q to %s after %d attempts: %v", + msg.Subject, msg.To, msg.Attempts, err) + os.Remove(path) + return true + } + log.Printf("mail: %q to %s failed, attempt %d: %v", msg.Subject, msg.To, msg.Attempts, err) + // Unrecorded, the attempt would repeat for ever. + again, err := marshalJSON(msg) + if err == nil { + err = writeFile(path, again, 0600) + } + if err != nil { + log.Printf("mail: cannot record the attempt on %q to %s, dropping it: %v", + msg.Subject, msg.To, err) + os.Remove(path) + } + return true + } + if err := os.Remove(path); err != nil { + // Or it is sent again on every drain, silently. + log.Printf("mail: %q to %s was sent but is still spooled: %v", msg.Subject, msg.To, err) + return true + } + } + return false +} + +// dropMailer is the none driver, and the default: nothing is sent, and the log +// names what was not, never the body. +type dropMailer struct{} + +func (dropMailer) Send(m Message) error { + log.Printf("mail: none, dropped %q to %s", m.Subject, m.To) + return nil +} + +// logMailer writes whole messages to the log, for development: the one driver +// that puts a set-password link there. +type logMailer struct{ from string } + +func (l logMailer) Send(m Message) error { + log.Printf("mail: from %s to %s\nSubject: %s\n\n%s", l.from, m.To, m.Subject, m.Body) + return nil +} + +// mailgunMailer is one form-encoded POST, the whole API surface in use. +type mailgunMailer struct { + from, base, domain, key, list string + client *http.Client +} + +func (g *mailgunMailer) Send(m Message) error { + form := url.Values{ + "from": {g.from}, + "to": {m.To}, + "subject": {m.Subject}, + "text": {m.Body}, + } + if m.Unsubscribe { + form.Set("h:List-Unsubscribe", "<"+g.list+">") + } + endpoint := g.base + "/" + g.domain + "/messages" + req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.SetBasicAuth("api", g.key) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := g.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("mailgun: %s", resp.Status) + } + return nil +} + +// The notification rules. Mail is sent for these events and no others. + +// mailAccount tells the account holder about their own account: it ignores +// the notify toggle, and still needs a proved address. +func (f *Forge) mailAccount(u User, subject, body string) { + if !u.EmailConfirmed { + return + } + f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body}) +} + +// mailLink carries a capability to an address confirmed or not: following it +// is what confirms it. +func (f *Forge) mailLink(to, subject, body string) { + f.mail.Enqueue(Message{To: to, Subject: f.subject(subject), Body: body}) +} + +// mailRepo is a message to everyone holding any grant on a repository, +// respecting the toggle and never to whoever caused the event. +func (f *Forge) mailRepo(repo Repo, except, subject, body string) { + for name := range repo.Grants { + if name == except { + continue + } + if u, ok := f.store.User(name); ok && u.Notify && u.EmailConfirmed { + f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body, Unsubscribe: true}) + } + } +} + +// mailAdmins is a message to every global admin, respecting the toggle. +func (f *Forge) mailAdmins(except, subject, body string) { + for name, u := range f.store.Users() { + if name == except || !u.Admin || !u.Notify || !u.EmailConfirmed { + continue + } + f.mail.Enqueue(Message{To: u.Email, Subject: f.subject(subject), Body: body, Unsubscribe: true}) + } +} + +func (f *Forge) subject(s string) string { return "[" + f.siteName() + "] " + s } + +func (f *Forge) url(path string) string { return f.cfg.BaseURL + path } + +// notifyIssue tells everyone with a grant that an issue moved. +func (f *Forge) notifyIssue(path string, repo Repo, issue Issue, actor, verb string) { + subject := fmt.Sprintf("%s#%d %s: %s", path, issue.ID, verb, issue.Title) + body := fmt.Sprintf("%s %s %s#%d.\n\n%s\n\n%s\n", + actor, verb, path, issue.ID, issue.Title, + f.url(fmt.Sprintf("/%s/issues/%d", path, issue.ID))) + f.mailRepo(repo, actor, subject, body) +} diff --git a/mail_test.go b/mail_test.go new file mode 100644 index 0000000..c9170f6 --- /dev/null +++ b/mail_test.go @@ -0,0 +1,331 @@ +package main + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync" + "testing" +) + +// recorder is a Mailer that remembers instead of sending. +type recorder struct { + mu sync.Mutex + sent []Message + fail bool +} + +func (m *recorder) Send(msg Message) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail { + return io.ErrUnexpectedEOF + } + m.sent = append(m.sent, msg) + return nil +} + +func (m *recorder) messages() []Message { + m.mu.Lock() + defer m.mu.Unlock() + return append([]Message(nil), m.sent...) +} + +func TestSpoolDrainsOldestFirst(t *testing.T) { + dir := t.TempDir() + driver := &recorder{} + spool := &Mail{dir: dir, driver: driver, wake: make(chan struct{}, 1)} + + for _, to := range []string{"one@example.com", "two@example.com", "three@example.com"} { + spool.Enqueue(Message{To: to, Subject: "hello", Body: "body"}) + } + if entries, _ := os.ReadDir(dir); len(entries) != 3 { + t.Fatalf("expected three spooled messages, got %d", len(entries)) + } + if spool.drainOnce() { + t.Fatal("draining reported a failure") + } + sent := driver.messages() + if len(sent) != 3 || sent[0].To != "one@example.com" || sent[2].To != "three@example.com" { + t.Errorf("sent out of order: %v", sent) + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Errorf("the spool still holds %d messages", len(entries)) + } + // A message with no recipient is not a message. + spool.Enqueue(Message{Subject: "nobody"}) + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Error("a message with no recipient was spooled") + } +} + +// The line that says a message was dropped has to name it without carrying it: +// the body of one of these is a capability. +func TestSpoolGivesUpLoudly(t *testing.T) { + dir := t.TempDir() + spool := &Mail{dir: dir, driver: &recorder{fail: true}, wake: make(chan struct{}, 1)} + spool.Enqueue(Message{To: "one@example.com", Subject: "set a new password", + Body: "https://forge.example/set-password?name=bob&t=the-capability\n"}) + + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + for i := 0; i < mailAttempts; i++ { + if !spool.drainOnce() { + log.SetOutput(old) + t.Fatalf("attempt %d: a failing driver reported success", i) + } + } + log.SetOutput(old) + + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Errorf("a message that will never send is still queued after %d attempts", mailAttempts) + } + for _, want := range []string{"giving up", "set a new password", "one@example.com"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("the log does not say %q:\n%s", want, buf.String()) + } + } + if strings.Contains(buf.String(), "the-capability") { + t.Errorf("a one time link reached the log:\n%s", buf.String()) + } +} + +// The none driver is off: the log names what was not sent and never carries +// it, since the body of one of these is a capability. +func TestNoneDriverNamesWhatItDrops(t *testing.T) { + if _, ok := newMail(&Config{Mail: MailConfig{Driver: "log"}}, t.TempDir()).driver.(logMailer); !ok { + t.Error("the log driver is not the one that logs") + } + if _, ok := newMail(defaultConfig(), t.TempDir()).driver.(dropMailer); !ok { + t.Error("the default driver sends somewhere") + } + + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + err := dropMailer{}.Send(Message{To: "bob@example.com", Subject: "set a new password", + Body: "https://forge.example/set-password?name=bob&t=the-capability\n"}) + log.SetOutput(old) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"set a new password", "bob@example.com"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("the log does not say %q:\n%s", want, buf.String()) + } + } + if strings.Contains(buf.String(), "the-capability") { + t.Errorf("a one time link reached the log:\n%s", buf.String()) + } +} + +func TestMailgunDriver(t *testing.T) { + var got url.Values + var user, key string + var ok bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, key, ok = r.BasicAuth() + r.ParseForm() + got = r.PostForm + if r.URL.Path != "/v3/mg.example.com/messages" { + t.Errorf("posted to %s", r.URL.Path) + } + })) + defer srv.Close() + + driver := &mailgunMailer{ + from: "git-forge <forge@example.com>", base: srv.URL + "/v3", domain: "mg.example.com", + key: "secret", list: "https://git.example.com/settings", client: srv.Client(), + } + err := driver.Send(Message{To: "bob@example.com", Subject: "hello", Body: "body", Unsubscribe: true}) + if err != nil { + t.Fatal(err) + } + if !ok || user != "api" || key != "secret" { + t.Errorf("basic auth was %q %q %v", user, key, ok) + } + for field, want := range map[string]string{ + "from": "git-forge <forge@example.com>", "to": "bob@example.com", + "subject": "hello", "text": "body", + "h:List-Unsubscribe": "<https://git.example.com/settings>", + } { + if got.Get(field) != want { + t.Errorf("%s was %q, wanted %q", field, got.Get(field), want) + } + } + + // A message that ignores the toggle carries no unsubscribe header. + if err := driver.Send(Message{To: "bob@example.com", Subject: "hello", Body: "body"}); err != nil { + t.Fatal(err) + } + if got.Has("h:List-Unsubscribe") { + t.Error("a notice about the account itself carried List-Unsubscribe") + } +} + +func TestIssueNotifiesGranteesOnly(t *testing.T) { + f := testForge(t) + driver := &recorder{} + f.mail.driver = driver + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addUser(t, f, "carol", "hunter2hunter2") + confirm(t, f, "alice", "alice@example.com", true) + confirm(t, f, "bob", "bob@example.com", true) + confirm(t, f, "carol", "carol@example.com", false) // notify off + addRepo(t, f, "notes", Repo{ + Visibility: visPublic, Issues: featUsers, Wiki: featDisabled, + Grants: map[string]string{"alice": grantWrite, "bob": grantRead, "carol": grantRead}, + }) + + h := f.routes() + session := signIn(t, f, h, "alice") + form := url.Values{"csrf": {f.csrf(withCookies("/", session))}, "title": {"A bug"}, "body": {"Details."}} + if code := post(h, "/notes/issues", form, session).Code; code != http.StatusSeeOther { + t.Fatalf("opening an issue: status %d", code) + } + f.mail.drainOnce() + + sent := driver.messages() + if len(sent) != 1 { + t.Fatalf("expected one message, got %v", sent) + } + if sent[0].To != "bob@example.com" { + t.Errorf("mailed %s: alice caused it and carol has notifications off", sent[0].To) + } + if !sent[0].Unsubscribe { + t.Error("an activity message did not carry the unsubscribe flag") + } + if !strings.Contains(sent[0].Body, "/notes/issues/1") { + t.Errorf("the message does not link to the issue: %q", sent[0].Body) + } +} + +// An address receives nothing until a link sent to it has been followed, and +// changing it again unproves it and spends the link sent to the old one. +func TestAddressIsProvedByALink(t *testing.T) { + f := testForge(t) + driver := &recorder{} + f.mail.driver = driver + addUser(t, f, "bob", "hunter2hunter2") + h := f.routes() + session := signIn(t, f, h, "bob") + token := f.csrf(withCookies("/", session)) + + set := func(email string) { + t.Helper() + w := post(h, "/settings/email", url.Values{ + "csrf": {token}, "email": {email}, "notify": {"yes"}}, session) + if w.Code != http.StatusOK { + t.Fatalf("setting %q: status %d", email, w.Code) + } + f.mail.drainOnce() + } + + set("bob@example.com") + if u, _ := f.store.User("bob"); u.EmailConfirmed { + t.Fatal("an address was confirmed without the link being followed") + } + sent := driver.messages() + if len(sent) != 1 || sent[0].To != "bob@example.com" { + t.Fatalf("expected one message to the address, got %v", sent) + } + link := between(sent[0].Body, f.cfg.BaseURL, "\n") + if !strings.HasPrefix(link, "/confirm-email?") { + t.Fatalf("no confirmation link in %q", sent[0].Body) + } + if code := get(h, link, session).Code; code != http.StatusOK { + t.Fatalf("following the link: status %d", code) + } + if u, _ := f.store.User("bob"); !u.EmailConfirmed { + t.Error("the address is still unconfirmed after the link was followed") + } + + set("other@example.com") + if u, _ := f.store.User("bob"); u.EmailConfirmed { + t.Error("a changed address kept the old one's confirmation") + } + if code := get(h, link, session).Code; code != http.StatusNotFound { + t.Errorf("the link to the old address still works: status %d", code) + } +} + +// The one unauthenticated path that sends a capability. The answer is the same +// either way, or the form is a list of which names are taken. +func TestResetTellsNobodyWhichAccountsExist(t *testing.T) { + f := testForge(t) + driver := &recorder{} + f.mail.driver = driver + addUser(t, f, "bob", "hunter2hunter2") + confirm(t, f, "bob", "bob@example.com", true) + h := f.routes() + + ask := func(name string) string { + t.Helper() + w := post(h, "/reset", url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, "name": {name}}, nil) + if w.Code != http.StatusOK { + t.Fatalf("asking for %q: status %d", name, w.Code) + } + f.mail.drainOnce() + return w.Body.String() + } + + const same = "if that account exists" + if page := ask("bob"); !strings.Contains(page, same) { + t.Error("the answer for an account that exists does not carry the notice") + } + sent := driver.messages() + if len(sent) != 1 || sent[0].To != "bob@example.com" { + t.Fatalf("no link went to the account: %v", sent) + } + if page := ask("nobody"); !strings.Contains(page, same) { + t.Error("the answer for an account that does not exist reads differently") + } + if n := len(driver.messages()); n != 1 { + t.Errorf("a reset for an account that does not exist sent mail: %d", n) + } + + // And the link it did send sets a password once. + link := between(sent[0].Body, f.cfg.BaseURL, "\n") + page := get(h, link, nil) + if page.Code != http.StatusOK { + t.Fatalf("following the link: status %d", page.Code) + } + body := page.Body.String() + form := url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, + "name": {between(body, `name="name" value="`, `"`)}, + "expires": {between(body, `name="expires" value="`, `"`)}, + "t": {between(body, `name="t" value="`, `"`)}, + "new": {"opensesame1"}, "again": {"opensesame1"}, + } + if w := post(h, "/set-password", form, nil); w.Code != http.StatusSeeOther { + t.Fatalf("setting the password: status %d", w.Code) + } + if u, _ := f.store.User("bob"); !passwordOK(u.Password, "opensesame1") { + t.Error("the new password was not stored") + } + if w := post(h, "/set-password", form, nil); w.Code != http.StatusNotFound { + t.Errorf("the link worked twice: status %d", w.Code) + } +} + +func confirm(t *testing.T, f *Forge, name, email string, notify bool) { + t.Helper() + err := f.store.UpdateUsers("users: address", f.store.forgeAuthor(), func(m map[string]User) error { + u := m[name] + u.Email, u.EmailConfirmed, u.Notify = email, true, notify + m[name] = u + return nil + }) + if err != nil { + t.Fatal(err) + } +} @@ -0,0 +1,733 @@ +// git-forge is a small self-hosted git forge: one binary, one data directory, +// one listener. +package main + +import ( + "bytes" + "context" + "embed" + "errors" + "flag" + "fmt" + "html/template" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "path" + "path/filepath" + "slices" + "strings" + "sync/atomic" + "syscall" + "time" +) + +const defaultDatadir = "/var/lib/git-forge" + +func main() { + // cgit's filters: the blob on stdin, and neither the datadir nor a listener. + if len(os.Args) > 1 { + switch os.Args[1] { + case "md": + os.Exit(runMarkdown()) + case "highlight": + os.Exit(runHighlight(os.Args[2:])) + } + } + + log.SetFlags(log.LstdFlags | log.LUTC) + log.SetOutput(os.Stdout) + + // run rather than log.Fatalf, which exits without running any defer. + if err := run(); err != nil { + log.Fatalf("%v", err) + } +} + +func run() error { + datadir := flag.String("datadir", "", "data directory (default $GIT_FORGE_DATADIR, else "+defaultDatadir+")") + flag.Parse() + if flag.NArg() > 0 { + return fmt.Errorf("unexpected argument %q", flag.Arg(0)) + } + + dir, err := resolveDatadir(*datadir) + if err != nil { + return fmt.Errorf("datadir: %v", err) + } + unlock, err := lockDatadir(dir) + if err != nil { + return err + } + defer unlock() + + store, err := openStore(dir) + if err != nil { + return fmt.Errorf("datadir %s: %v", dir, err) + } + cfg := store.cfg + log.Printf("datadir %s, site %q, base url %s", store.dir, cfg.SiteName, cfg.BaseURL) + if cfg.ClientIPHeader != "" { + log.Printf("client address from the %s header, trusted", cfg.ClientIPHeader) + } + for _, p := range cfg.OIDC { + // Whether the secret arrived, never the secret. + held := "no client secret" + if p.ClientSecret != "" { + held = "client secret held" + } + log.Printf("oidc %s: %s, callback %s/oidc/%s/callback, %s", + p.Key, p.Issuer, cfg.BaseURL, p.Key, held) + } + + forge, err := newForge(store) + if err != nil { + return err + } + defer forge.cgit.close() + if len(store.Users()) == 0 { + token := randomToken(16) + forge.setupToken.Store(&token) + log.Printf("no accounts yet: open %s/setup and give it this token: %s", cfg.BaseURL, token) + } + drain, stopDraining := context.WithCancel(context.Background()) + defer stopDraining() + go forge.mail.Drain(drain) + + return serve(cfg, forge.routes()) +} + +// Forge is the whole server: the config, the datadir, and the pages. +type Forge struct { + cfg *Config + store *Store + cgit *Cgit + mail *Mail + oidc *OIDC + pages map[string]*template.Template + nav *template.Template // the bar, on forge pages and injected into cgit's + site atomic.Pointer[Site] // the name, subtitle, and logo an admin edits + + issueLimit *limiter + commentLimit *limiter + resetLimit *limiter + totpLimit *limiter + loginLimit *limiter + tokenLimit *limiter + + // setupToken lives in memory only: a restart prints a new one and a + // stolen datadir carries none. + setupToken atomic.Pointer[string] +} + +//go:embed templates static +var assets embed.FS + +// layout is parsed into every page. Everything else in templates/ is a page. +var layout = []string{"templates/base.html", "templates/nav.html"} + +func newForge(store *Store) (*Forge, error) { + cfg := store.cfg + // One template set per page, so two pages can both define main. + files, err := fs.Glob(assets, "templates/*.html") + if err != nil { + return nil, err + } + pages := map[string]*template.Template{} + for _, file := range files { + if slices.Contains(layout, file) { + continue + } + t, err := template.ParseFS(assets, append(slices.Clone(layout), file)...) + if err != nil { + return nil, err + } + pages[path.Base(file)] = t + } + nav, err := template.ParseFS(assets, "templates/nav.html") + if err != nil { + return nil, err + } + forgeCSS, err := assets.ReadFile("static/forge.css") + if err != nil { + return nil, err + } + browser, err := newCgit(store, forgeCSS) + if err != nil { + return nil, err + } + site, err := loadSite(store.configDir, cfg.SiteName, cfg.SiteDesc, cfg.Logo, cfg.Icon) + if err != nil { + return nil, err + } + f := &Forge{ + cfg: cfg, store: store, cgit: browser, mail: newMail(cfg, store.mailqDir), oidc: newOIDC(), + pages: pages, nav: nav, + issueLimit: newLimiter(), commentLimit: newLimiter(), resetLimit: newLimiter(), + totpLimit: newLimiter(), loginLimit: newLimiter(), tokenLimit: newLimiter(), + } + f.site.Store(site) + return f, nil +} + +// Site is what the header draws, swapped whole so a reader never sees half a +// change. +type Site struct { + Name string + Desc string + Logo Image // the header image + Icon Image // the favicon +} + +// Image is a configured image: the path in the config, its bytes, and the +// sniffed type. An empty Name is cgit's own. +type Image struct { + Name string + Body []byte + Kind string +} + +// siteName is the name in effect; the config holds only what the process +// started with. +func (f *Forge) siteName() string { return f.site.Load().Name } + +// Served back to browsers, so decided from the bytes. No SVG: it can carry +// script. +var imageKinds = map[string]bool{ + "image/png": true, "image/jpeg": true, "image/gif": true, + "image/webp": true, "image/x-icon": true, +} + +const maxImage = 512 << 10 + +func loadSite(dir, name, desc, logo, icon string) (*Site, error) { + site := &Site{Name: name, Desc: desc} + var err error + if site.Logo, err = loadImage(dir, logo); err != nil { + return nil, err + } + if site.Icon, err = loadImage(dir, icon); err != nil { + return nil, err + } + return site, nil +} + +// A relative path is under the config directory, where an upload lands. +func loadImage(dir, name string) (Image, error) { + if name == "" { + return Image{}, nil + } + path := name + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + body, err := os.ReadFile(path) + if err != nil { + return Image{}, err + } + if err := checkImage(body); err != nil { + return Image{}, err + } + return Image{Name: name, Body: body, Kind: http.DetectContentType(body)}, nil +} + +func checkImage(body []byte) error { + if len(body) > maxImage { + return userError(fmt.Sprintf("that image is larger than %d KiB", maxImage>>10)) + } + if kind := http.DetectContentType(body); !imageKinds[kind] { + return userError("that is a " + kind + ", and it has to be a png, jpeg, gif, webp, or icon") + } + return nil +} + +func (f *Forge) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", healthz) + mux.HandleFunc("GET /cgit.css", f.handleCgitCSS) + mux.HandleFunc("GET /cgit.png", f.handleCgitLogo) + mux.HandleFunc("GET /favicon.ico", f.handleFavicon) + mux.HandleFunc("GET /logo", f.handleLogo) + mux.HandleFunc("GET /avatar/{name}", f.handleAvatar) + mux.HandleFunc("GET /login", f.handleLogin) + mux.HandleFunc("POST /login", f.handleLoginPost) + mux.HandleFunc("POST /login/totp", f.handleLoginTOTP) + mux.HandleFunc("POST /logout", f.handleLogout) + mux.HandleFunc("GET /settings", f.handleSettings) + mux.HandleFunc("POST /settings/password", f.handlePassword) + mux.HandleFunc("POST /settings/sessions", f.handleSignOut) + mux.HandleFunc("POST /settings/email", f.handleEmail) + mux.HandleFunc("POST /settings/tokens", f.handleTokenMint) + mux.HandleFunc("POST /settings/tokens/revoke", f.handleTokenRevoke) + mux.HandleFunc("GET /settings/totp", f.handleTOTP) + mux.HandleFunc("POST /settings/totp", f.handleTOTPEnroll) + mux.HandleFunc("POST /settings/totp/remove", f.handleTOTPRemove) + mux.HandleFunc("POST /settings/totp/codes", f.handleTOTPCodes) + mux.HandleFunc("GET /confirm-email", f.handleConfirmEmail) + mux.HandleFunc("GET /oidc/{key}", f.handleOIDCStart) + mux.HandleFunc("GET /oidc/{key}/callback", f.handleOIDCCallback) + mux.HandleFunc("GET /reset", f.handleReset) + mux.HandleFunc("POST /reset", f.handleResetPost) + mux.HandleFunc("GET /set-password", f.handleSetPassword) + mux.HandleFunc("POST /set-password", f.handleSetPasswordPost) + mux.HandleFunc("GET /setup", f.handleSetup) + mux.HandleFunc("POST /setup", f.handleSetupPost) + mux.HandleFunc("GET /admin", f.handleAdmin) + mux.HandleFunc("POST /admin/site", f.handleSiteSettings) + mux.HandleFunc("POST /admin/repo/create", f.handleRepoCreate) + mux.HandleFunc("POST /admin/repo/adopt", f.handleRepoAdopt) + mux.HandleFunc("POST /admin/user/create", f.handleUserCreate) + mux.HandleFunc("POST /admin/user/admin", f.handleUserAdmin) + mux.HandleFunc("POST /admin/user/totp", f.handleUserTOTP) + mux.HandleFunc("POST /admin/user/delete", f.handleUserDelete) + mux.HandleFunc("GET /{$}", f.handleHome) + mux.HandleFunc("/{path...}", f.handleRepo) + return f.logRequests(secureHeaders(mux)) +} + +// secureHeaders is the whole header policy, on cgit's pages too. No script +// runs anywhere; images are same origin, so a markdown src cannot tell a third +// party who reads a private page; nothing is cached, because cgit dates its +// own pages ahead. Asset routes override the cache header. +func secureHeaders(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + head := w.Header() + head.Set("Content-Security-Policy", + "script-src 'none'; object-src 'none'; base-uri 'none'; img-src 'self'; frame-ancestors 'none'") + head.Set("X-Content-Type-Options", "nosniff") + head.Set("Cache-Control", "no-store") + h.ServeHTTP(w, r) + }) +} + +func (f *Forge) handleHome(w http.ResponseWriter, r *http.Request) { + f.render(w, "home.html", http.StatusOK, f.withIndex(r, f.page(r))) +} + +func (f *Forge) withIndex(r *http.Request, d pageData) pageData { + v := f.viewer(r) + visible := []RepoEntry{} + for _, entry := range f.store.Repos() { + if v.CanRead(entry.Repo) { + visible = append(visible, entry) + } + } + d.Data = indexPage{Repos: visible} + return d +} + +type indexPage struct{ Repos []RepoEntry } + +// handleRepo owns everything under a repository path: the longest prefix the +// tree holds is the repository, and the rest is the action. +func (f *Forge) handleRepo(w http.ResponseWriter, r *http.Request) { + if !servablePath(r.URL.Path) { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + // .git paths authorise differently and answer in git's protocol. + if path, kind, rest, ok := splitGit(r.URL.Path); ok { + f.handleGitHTTP(w, r, path, kind, rest) + return + } + path, kind, rest, ok := f.store.splitRepo(r.URL.Path) + if !ok { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + repo, _ := f.store.Repo(path) + // A repository the viewer may not read does not exist. + if !f.viewer(r).CanRead(repo) { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + + // {repo}.issues and {repo}.wiki are the siblings' history, cgit only. + if kind != "" { + if (kind == "issues" && repo.Issues == featDisabled) || (kind == "wiki" && repo.Wiki == featDisabled) { + f.fail(w, r, http.StatusNotFound, "no such page") + return + } + f.handleCgit(w, r, path, kind, rest, repo) + return + } + + // The forge's by name, cgit's by default; cgit has no route by these names. + head, _, _ := strings.Cut(rest, "/") + switch head { + case "settings": + f.repoSettingsRoutes(w, r, path, repo, rest) + case "issues": + f.handleIssues(w, r, path, repo, rest) + case "wiki": + f.handleWiki(w, r, path, repo, rest) + default: + f.handleCgit(w, r, path, "", rest, repo) + } +} + +// servablePath is what both CGIs will take: the path goes into an environment +// variable, which cannot hold NUL, and git-http-backend answers a . or .. or +// empty component with a 500. Percent encoded, these survive the mux's +// cleaning. +func servablePath(p string) bool { + if hasControlByte(p) { + return false + } + parts := strings.Split(strings.TrimPrefix(p, "/"), "/") + for i, part := range parts { + // A trailing slash is kept: cgit's own links carry one. + if part == "." || part == ".." || (part == "" && i < len(parts)-1) { + return false + } + } + return true +} + +func hasControlByte(p string) bool { + return strings.ContainsFunc(p, func(c rune) bool { return c < ' ' || c == 0x7f }) +} + +// isGet treats HEAD as GET: net/http drops the body on the way out. +func isGet(r *http.Request) bool { + return r.Method == http.MethodGet || r.Method == http.MethodHead +} + +// childSlot takes one of a bounded number of forked children, or reports +// false once the client has hung up. +func childSlot(budget chan struct{}, r *http.Request) (release func(), ok bool) { + select { + case budget <- struct{}{}: + return func() { <-budget }, true + case <-r.Context().Done(): + return nil, false + } +} + +func (f *Forge) repoSettingsRoutes(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) { + switch { + case rest == "settings" && isGet(r): + f.repoSettings(w, r, path, repo) + case rest == "settings" && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.repoSettingsSave(w, r, path, repo) + } + case rest == "settings/grant" && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.repoGrant(w, r, path, repo) + } + case rest == "settings/delete" && r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.repoDelete(w, r, path, repo) + } + default: + f.fail(w, r, http.StatusNotFound, "no such page") + } +} + +// viewer is whoever is asking. +func (f *Forge) viewer(r *http.Request) Viewer { + if name, u, ok := f.user(r); ok { + return Viewer{Name: name, Admin: u.adminInEffect()} + } + return Viewer{} +} + +// actor is the identity a change made by this request is recorded under. +func (f *Forge) actor(r *http.Request) Author { + name, u, ok := f.user(r) + if !ok { + return f.store.forgeAuthor() + } + return f.store.author(name, u) +} + +// One stylesheet for both kinds of page. +func (f *Forge) handleCgitCSS(w http.ResponseWriter, r *http.Request) { + if len(f.cgit.css) == 0 { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "max-age=300") + w.Write(f.cgit.css) +} + +func (f *Forge) handleCgitLogo(w http.ResponseWriter, r *http.Request) { + serveAsset(w, r, f.cgit.logo, "image/png") +} + +// Browsers ask for this unprompted, so it falls back to cgit's rather than 404. +func (f *Forge) handleFavicon(w http.ResponseWriter, r *http.Request) { + if icon := f.site.Load().Icon; icon.Name != "" { + serveAsset(w, r, icon.Body, icon.Kind) + return + } + serveAsset(w, r, f.cgit.favicon, "image/vnd.microsoft.icon") +} + +func (f *Forge) handleLogo(w http.ResponseWriter, r *http.Request) { + logo := f.site.Load().Logo + serveAsset(w, r, logo.Body, logo.Kind) +} + +func serveAsset(w http.ResponseWriter, r *http.Request, body []byte, kind string) { + if len(body) == 0 { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", kind) + w.Header().Set("Cache-Control", "max-age=3600") + w.Write(body) +} + +// userError is an error whose text is meant for the person who caused it. +type userError string + +func (e userError) Error() string { return string(e) } + +// problem shows a userError and hides anything else behind the log. +func (f *Forge) problem(w http.ResponseWriter, r *http.Request, status int, err error) { + var mine userError + if errors.As(err, &mine) { + f.fail(w, r, status, mine.Error()) + return + } + log.Printf("%s %q: %v", r.Method, r.URL.Path, err) + f.fail(w, r, http.StatusInternalServerError, "something went wrong here, and the log has the detail") +} + +// pageData is what every template is given. Data is the page's own. +type pageData struct { + Site string + Base string // the base url, for the clone lines on the page + Now string // for the footer, in the format cgit's own footer uses + Desc string + Logo string + // MailOff stops pages offering what only mail can deliver. + MailOff bool + User string // empty when signed out + // MustEnrol is the flag waiting on an authenticator; the admin link is + // where enrolling starts. + Admin bool + MustEnrol bool + CSRF string + Error string + Notice string + Repo *repoBar // set on a repository page, rendered or injected + Data any +} + +// repoBar is the bar every repository page carries. +type repoBar struct { + Path string + Desc string + Issues bool + Wiki bool + Settings bool + Active string // the forge's own tab this page is, empty on cgit's pages +} + +func (f *Forge) repoBar(r *http.Request, path, kind, active string, repo Repo) *repoBar { + desc := repo.Desc + switch kind { + case "issues": + desc = "the issue tracker of " + path + case "wiki": + desc = "the wiki of " + path + } + v := f.viewer(r) + return &repoBar{ + Path: path, + Desc: desc, + Active: active, + Issues: repo.Issues != featDisabled, + Wiki: repo.Wiki != featDisabled, + Settings: v.CanAdmin(repo), + } +} + +type errorPage struct { + Status int + Text string +} + +func (f *Forge) page(r *http.Request) pageData { + d := f.chrome(f.csrf(r)) + if name, u, ok := f.user(r); ok { + d.signedInAs(name, u) + } + return d +} + +// pageFor builds the page for a reissued cookie, which the request does not +// carry yet. +func (f *Forge) pageFor(name string, u User, session string) pageData { + d := f.chrome(f.mac("csrf", session)) + d.signedInAs(name, u) + return d +} + +// chrome is everything on a page that does not depend on who is asking. +func (f *Forge) chrome(csrf string) pageData { + site := f.site.Load() + logo := "/cgit.png" + if site.Logo.Name != "" { + logo = "/logo" + } + return pageData{ + Site: site.Name, Base: f.cfg.BaseURL, Desc: site.Desc, Logo: logo, + Now: time.Now().Format("2006-01-02 15:04:05 -0700"), + MailOff: f.cfg.Mail.Driver == "none", CSRF: csrf, + } +} + +func (d *pageData) signedInAs(name string, u User) { + d.User = name + d.Admin, d.MustEnrol = u.adminInEffect(), u.Admin && u.TOTP == nil +} + +// requireUser answers the request itself when there is no account. +func (f *Forge) requireUser(w http.ResponseWriter, r *http.Request) (string, User, bool) { + name, u, ok := f.user(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + } + return name, u, ok +} + +// render buffers, so a template failing halfway leaves no half page behind a +// 200. +func (f *Forge) render(w http.ResponseWriter, page string, status int, d pageData) { + t, ok := f.pages[page] + if !ok { + log.Printf("template %s: not embedded", page) + http.Error(w, "template missing", http.StatusInternalServerError) + return + } + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "base.html", d); err != nil { + log.Printf("template %s: %v", page, err) + http.Error(w, "template failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + w.Write(buf.Bytes()) +} + +func (f *Forge) fail(w http.ResponseWriter, r *http.Request, status int, msg string) { + d := f.page(r) + d.Error = msg + d.Data = errorPage{Status: status, Text: http.StatusText(status)} + f.render(w, "error.html", status, d) +} + +// Fixed before the config is read, so the config cannot move it. +func resolveDatadir(flagValue string) (string, error) { + dir := flagValue + if dir == "" { + dir = os.Getenv("GIT_FORGE_DATADIR") + } + if dir == "" { + dir = defaultDatadir + } + return filepath.Abs(dir) +} + +func serve(cfg *Config, h http.Handler) error { + srv := &http.Server{ + Handler: h, + // No read or write deadline: a push sends an entire packfile in one + // request and a clone streams one back. + ReadHeaderTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + } + + ln, err := net.Listen("tcp", cfg.Listen) + if err != nil { + return err + } + log.Printf("listening on %s", ln.Addr()) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ln) }() + + select { + case err := <-errc: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + case <-ctx.Done(): + stop() // a second signal now kills a drain that will not finish + log.Printf("draining") + sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return srv.Shutdown(sctx) + } +} + +func healthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + io.WriteString(w, "ok\n") +} + +// logRequests writes one line per request to stdout. +func (f *Forge) logRequests(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lw := &logWriter{ResponseWriter: w} + h.ServeHTTP(lw, r) + if lw.status == 0 { + lw.status = http.StatusOK + } + // No query string: reset links and OIDC codes arrive in one. Quoted, + // so a newline in the path cannot write the next line. + log.Printf("%s %s %q %d %d %dms", f.peer(r), r.Method, r.URL.Path, + lw.status, lw.bytes, time.Since(start).Milliseconds()) + }) +} + +// peer is the address rate limits count against. The header is read only when +// named, and only as one parsed address: anything else would be a key anybody +// could invent. +func (f *Forge) peer(r *http.Request) string { + if name := f.cfg.ClientIPHeader; name != "" { + if ip := net.ParseIP(strings.TrimSpace(r.Header.Get(name))); ip != nil { + return ip.String() + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +type logWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (w *logWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *logWriter) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + n, err := w.ResponseWriter.Write(b) + w.bytes += n + return n, err +} @@ -0,0 +1,598 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// OIDC is generic: discovery, authorization code with PKCE, and JWKS +// verification. Identity is iss plus sub; the name is derived once, since +// preferred_username promises neither uniqueness nor stability. + +const ( + oidcCookie = "oidc" + oidcLife = 10 * time.Minute // how long a login may take + oidcRefresh = time.Hour // how long discovery and keys are kept + oidcTimeout = 15 * time.Second +) + +// discovery is the part of a provider's metadata that gets used. +type discovery struct { + Issuer string `json:"issuer"` + Authorization string `json:"authorization_endpoint"` + Token string `json:"token_endpoint"` + JWKS string `json:"jwks_uri"` +} + +type providerCache struct { + meta discovery + keys []jwk + metaAt time.Time + keysAt time.Time +} + +// OIDC is a cache; no session state lives here. +type OIDC struct { + client *http.Client + mu sync.Mutex + cache map[string]*providerCache +} + +func newOIDC() *OIDC { + return &OIDC{client: &http.Client{Timeout: oidcTimeout}, cache: map[string]*providerCache{}} +} + +func (o *OIDC) entry(issuer string) *providerCache { + o.mu.Lock() + defer o.mu.Unlock() + if o.cache[issuer] == nil { + o.cache[issuer] = &providerCache{} + } + return o.cache[issuer] +} + +// meta refuses a document that does not name its own issuer. +func (o *OIDC) meta(issuer string) (discovery, error) { + entry := o.entry(issuer) + o.mu.Lock() + if time.Since(entry.metaAt) < oidcRefresh && entry.meta.Issuer != "" { + meta := entry.meta + o.mu.Unlock() + return meta, nil + } + o.mu.Unlock() + + var meta discovery + if err := o.fetch(issuer+"/.well-known/openid-configuration", &meta); err != nil { + return meta, err + } + switch { + case meta.Issuer != issuer: + return meta, fmt.Errorf("oidc: %s says it is %q", issuer, meta.Issuer) + case meta.Authorization == "" || meta.Token == "" || meta.JWKS == "": + return meta, fmt.Errorf("oidc: %s is missing an endpoint", issuer) + } + + o.mu.Lock() + entry.meta, entry.metaAt = meta, time.Now() + o.mu.Unlock() + return meta, nil +} + +// signingKeys fetches the key set, again when a token names a key not held: a +// rotation. +func (o *OIDC) signingKeys(issuer, jwksURL string, force bool) ([]jwk, error) { + entry := o.entry(issuer) + o.mu.Lock() + fresh := time.Since(entry.keysAt) < oidcRefresh && len(entry.keys) > 0 + keys := entry.keys + o.mu.Unlock() + if fresh && !force { + return keys, nil + } + + var set struct { + Keys []jwk `json:"keys"` + } + if err := o.fetch(jwksURL, &set); err != nil { + return nil, err + } + o.mu.Lock() + entry.keys, entry.keysAt = set.Keys, time.Now() + o.mu.Unlock() + return set.Keys, nil +} + +func (o *OIDC) fetch(url string, into any) error { + resp, err := o.client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s: %s", url, resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + return json.Unmarshal(body, into) +} + +// The state and PKCE verifier go into a short lived signed cookie, which +// survives a restart. +func (f *Forge) handleOIDCStart(w http.ResponseWriter, r *http.Request) { + provider, ok := f.provider(r.PathValue("key")) + if !ok { + f.fail(w, r, http.StatusNotFound, "no such provider") + return + } + meta, err := f.oidc.meta(provider.Issuer) + if err != nil { + log.Printf("oidc: %v", err) + f.fail(w, r, http.StatusBadGateway, "that provider is not answering") + return + } + + state, verifier := randomToken(16), randomToken(32) + f.setOIDCCookie(w, provider.Key, state, verifier) + + sum := sha256.Sum256([]byte(verifier)) + q := url.Values{ + "response_type": {"code"}, + "client_id": {provider.ClientID}, + "redirect_uri": {f.oidcRedirect(provider.Key)}, + "scope": {"openid email profile"}, + "state": {state}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}, + "code_challenge_method": {"S256"}, + } + http.Redirect(w, r, meta.Authorization+"?"+q.Encode(), http.StatusSeeOther) +} + +func (f *Forge) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + provider, ok := f.provider(r.PathValue("key")) + if !ok { + f.fail(w, r, http.StatusNotFound, "no such provider") + return + } + key, state, verifier, ok := f.oidcCookie(r) + f.clearOIDCCookie(w) + if !ok || key != provider.Key { + f.fail(w, r, http.StatusForbidden, "that login took too long, or did not start here. try again") + return + } + q := r.URL.Query() + if !hmac.Equal([]byte(q.Get("state")), []byte(state)) { + f.fail(w, r, http.StatusForbidden, "that login did not start here. try again") + return + } + if reason := q.Get("error"); reason != "" { + f.fail(w, r, http.StatusForbidden, "the provider refused: "+reason) + return + } + + claims, err := f.oidcExchange(provider, q.Get("code"), verifier) + if err != nil { + log.Printf("oidc: %v", err) + f.fail(w, r, http.StatusForbidden, "that login could not be verified") + return + } + // Without a list, every address the provider will vouch for mints an + // account. + if !provider.allows(claims.Email) { + log.Printf("oidc: %s refused %q: not in the allow list", provider.Key, claims.Email) + f.fail(w, r, http.StatusForbidden, "that address may not sign in here") + return + } + + name, u, err := f.oidcAccount(provider, claims) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + // The provider is the first factor, not the second. + if u.TOTP != nil { + f.askForCode(w, r, name, u, "") + return + } + f.setSession(w, name, u.Epoch) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// oidcExchange trades the code for an id token and verifies it. +func (f *Forge) oidcExchange(provider OIDCProvider, code, verifier string) (*claims, error) { + if code == "" { + return nil, errors.New("no code") + } + meta, err := f.oidc.meta(provider.Issuer) + if err != nil { + return nil, err + } + + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {f.oidcRedirect(provider.Key)}, + "client_id": {provider.ClientID}, + "code_verifier": {verifier}, + } + req, err := http.NewRequest(http.MethodPost, meta.Token, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if provider.ClientSecret != "" { + req.SetBasicAuth(url.QueryEscape(provider.ClientID), url.QueryEscape(provider.ClientSecret)) + } + resp, err := f.oidc.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var token struct { + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &token); err != nil { + return nil, err + } + if token.IDToken == "" { + return nil, errors.New("no id_token in the token response") + } + return f.verifyIDToken(provider, token.IDToken) +} + +func (f *Forge) verifyIDToken(provider OIDCProvider, token string) (*claims, error) { + signed, signature, kid, alg, payload, err := splitJWT(token) + if err != nil { + return nil, err + } + meta, err := f.oidc.meta(provider.Issuer) + if err != nil { + return nil, err + } + keys, err := f.oidc.signingKeys(provider.Issuer, meta.JWKS, false) + if err != nil { + return nil, err + } + key, ok := findKey(keys, kid) + if !ok { + if keys, err = f.oidc.signingKeys(provider.Issuer, meta.JWKS, true); err != nil { + return nil, err + } + if key, ok = findKey(keys, kid); !ok { + return nil, fmt.Errorf("id token signed by unknown key %q", kid) + } + } + if err := verifySignature(key, alg, signed, signature); err != nil { + return nil, err + } + + var c claims + if err := json.Unmarshal(payload, &c); err != nil { + return nil, err + } + now := time.Now() + switch { + case c.Iss != provider.Issuer: + return nil, fmt.Errorf("id token issued by %q, not %q", c.Iss, provider.Issuer) + case c.Sub == "": + return nil, errors.New("id token has no subject") + case !c.Aud.has(provider.ClientID): + return nil, fmt.Errorf("id token is for %v, not for %s", c.Aud, provider.ClientID) + case c.Exp == 0 || now.After(time.Unix(c.Exp, 0).Add(time.Minute)): + return nil, errors.New("id token has expired") + case c.Iat != 0 && time.Unix(c.Iat, 0).After(now.Add(5*time.Minute)): + return nil, errors.New("id token was issued in the future") + case c.EmailVerified == nil || !*c.EmailVerified: + return nil, errors.New("the provider did not say the address is verified") + case c.Email == "" || !validEmail(c.Email): + // This reaches git's author field and a mail envelope. + return nil, fmt.Errorf("id token carries %q, which is not an address", c.Email) + } + return &c, nil +} + +// oidcAccount finds the account this identity belongs to, or provisions one. +func (f *Forge) oidcAccount(provider OIDCProvider, c *claims) (string, User, error) { + for name, u := range f.store.Users() { + if u.Origin == originOIDC && u.Iss == c.Iss && u.Sub == c.Sub { + // The provider owns the address; the name is what grants point at + // and never changes. + if u.Email != c.Email || !u.EmailConfirmed { + err := f.store.UpdateUser(name, "users: refresh the address of "+name, Author{Name: name, Email: c.Email}, + func(acct *User) error { + acct.Email, acct.EmailConfirmed = c.Email, true + return nil + }) + if err != nil { + return "", User{}, err + } + u, _ = f.store.User(name) + } + return name, u, nil + } + } + + name := f.deriveName(provider.Key, c) + acct := User{ + Origin: originOIDC, Iss: c.Iss, Sub: c.Sub, + Email: c.Email, EmailConfirmed: true, Notify: true, + } + err := f.store.UpdateUsers("users: provision "+name+" from "+provider.Label, Author{Name: name, Email: c.Email}, + func(m map[string]User) error { + if _, taken := m[name]; taken { + return fmt.Errorf("%s was taken while provisioning it", name) + } + m[name] = acct + return nil + }) + if err != nil { + return "", User{}, err + } + log.Printf("oidc: provisioned %s from %s with no grants", name, provider.Issuer) + f.mailAdmins("", "new account "+name+" is waiting for a grant", + name+" signed in through "+provider.Label+" for the first time and holds no grants yet.\n\n"+ + f.url("/admin")+"\n") + return name, acct, nil +} + +// deriveName is run once, at first login. +func (f *Forge) deriveName(key string, c *claims) string { + base := sanitizeName(c.Username) + if base == "" { + local, _, _ := strings.Cut(c.Email, "@") + base = sanitizeName(local) + } + if base == "" { + base = sanitizeName(c.Sub) + } + if base == "" { + base = "user" + } + if len(base) > 24 { + base = base[:24] + } + + users := f.store.Users() + name := key + ":" + base + for n := 2; ; n++ { + if _, taken := users[name]; !taken { + return name + } + name = key + ":" + base + strconv.Itoa(n) + } +} + +var unsafeName = regexp.MustCompile(`[^a-z0-9._-]+`) + +// sanitizeName is blunt: the name becomes a key in repos.json and part of a +// URL, out of a claim from elsewhere. +func sanitizeName(s string) string { + s = unsafeName.ReplaceAllString(strings.ToLower(s), "-") + s = strings.Trim(s, "-._") + if s == "" || !localNameRE.MatchString(s) { + return "" + } + return s +} + +func (f *Forge) provider(key string) (OIDCProvider, bool) { + for _, p := range f.cfg.OIDC { + if p.Key == key { + return p, true + } + } + return OIDCProvider{}, false +} + +func (f *Forge) oidcRedirect(key string) string { return f.cfg.BaseURL + "/oidc/" + key + "/callback" } + +// The login cookie: the provider, the state, the verifier, and an expiry. +func (f *Forge) setOIDCCookie(w http.ResponseWriter, key, state, verifier string) { + expires := strconv.FormatInt(time.Now().Add(oidcLife).Unix(), 10) + value := strings.Join([]string{key, state, verifier, expires}, ".") + http.SetCookie(w, &http.Cookie{ + Name: oidcCookie, + Value: value + "." + f.mac("oidc", key, state, verifier, expires), + Path: "/oidc/", + MaxAge: int(oidcLife / time.Second), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, // the callback arrives from the provider + Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"), + }) +} + +func (f *Forge) oidcCookie(r *http.Request) (key, state, verifier string, ok bool) { + c, err := r.Cookie(oidcCookie) + if err != nil { + return "", "", "", false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 5 { + return "", "", "", false + } + key, state, verifier, expires, mac := parts[0], parts[1], parts[2], parts[3], parts[4] + unix, err := strconv.ParseInt(expires, 10, 64) + if err != nil || time.Now().After(time.Unix(unix, 0)) { + return "", "", "", false + } + if !hmac.Equal([]byte(mac), []byte(f.mac("oidc", key, state, verifier, expires))) { + return "", "", "", false + } + return key, state, verifier, true +} + +func (f *Forge) clearOIDCCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: oidcCookie, Path: "/oidc/", MaxAge: -1, HttpOnly: true}) +} + +// claims is the part of an id token that gets used. +type claims struct { + Iss string `json:"iss"` + Sub string `json:"sub"` + Aud audience `json:"aud"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + Email string `json:"email"` + EmailVerified *bool `json:"email_verified"` + Username string `json:"preferred_username"` +} + +// audience is one string or several, as providers send it. +type audience []string + +func (a *audience) UnmarshalJSON(data []byte) error { + var one string + if err := json.Unmarshal(data, &one); err == nil { + *a = audience{one} + return nil + } + var many []string + if err := json.Unmarshal(data, &many); err != nil { + return err + } + *a = many + return nil +} + +func (a audience) has(want string) bool { + for _, s := range a { + if s == want { + return true + } + } + return false +} + +// jwk is one signing key from the provider's key set. +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// An exact kid wins over a key naming none, or a rotation would hide behind a +// failed verification. +func findKey(keys []jwk, kid string) (jwk, bool) { + for _, k := range keys { + if signingKey(k) && kid != "" && k.Kid == kid { + return k, true + } + } + for _, k := range keys { + if signingKey(k) && (kid == "" || k.Kid == "") { + return k, true + } + } + return jwk{}, false +} + +// An absent use is unrestricted. +func signingKey(k jwk) bool { return k.Use == "" || k.Use == "sig" } + +// splitJWT takes a token apart without trusting any of it. +func splitJWT(token string) (signed, signature []byte, kid, alg string, payload []byte, err error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, nil, "", "", nil, errors.New("id token is not a jwt") + } + head, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, nil, "", "", nil, err + } + var header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if err := json.Unmarshal(head, &header); err != nil { + return nil, nil, "", "", nil, err + } + payload, err = base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, nil, "", "", nil, err + } + signature, err = base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, nil, "", "", nil, err + } + return []byte(parts[0] + "." + parts[1]), signature, header.Kid, header.Alg, payload, nil +} + +// RS256 and ES256 only. +func verifySignature(key jwk, alg string, signed, signature []byte) error { + sum := sha256.Sum256(signed) + switch { + case alg == "RS256" && key.Kty == "RSA": + n, err := bigFromJWK(key.N) + if err != nil { + return err + } + e, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + return err + } + pub := &rsa.PublicKey{N: n, E: int(new(big.Int).SetBytes(e).Int64())} + return rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], signature) + + case alg == "ES256" && key.Kty == "EC" && key.Crv == "P-256": + x, err := bigFromJWK(key.X) + if err != nil { + return err + } + y, err := bigFromJWK(key.Y) + if err != nil { + return err + } + if len(signature) != 64 { + return errors.New("es256 signature is the wrong size") + } + pub := &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y} + r := new(big.Int).SetBytes(signature[:32]) + s := new(big.Int).SetBytes(signature[32:]) + if !ecdsa.Verify(pub, sum[:], r, s) { + return errors.New("id token signature does not verify") + } + return nil + } + return fmt.Errorf("id token algorithm %q with key type %q is not supported", alg, key.Kty) +} + +func bigFromJWK(field string) (*big.Int, error) { + raw, err := base64.RawURLEncoding.DecodeString(field) + if err != nil { + return nil, err + } + return new(big.Int).SetBytes(raw), nil +} diff --git a/oidc_test.go b/oidc_test.go new file mode 100644 index 0000000..a065db3 --- /dev/null +++ b/oidc_test.go @@ -0,0 +1,474 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// fakeProvider is discovery, a key set, and a token endpoint. A real provider +// is checked by hand. +type fakeProvider struct { + *httptest.Server + key *rsa.PrivateKey + signer *rsa.PrivateKey // what it signs with, which is key unless a test says otherwise + claims map[string]any + verifier string // the code_verifier the token endpoint was given +} + +func newFakeProvider(t *testing.T) *fakeProvider { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p := &fakeProvider{key: key, signer: key} + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{ + "issuer": p.URL, + "authorization_endpoint": p.URL + "/auth", + "token_endpoint": p.URL + "/token", + "jwks_uri": p.URL + "/jwks", + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + pub := key.Public().(*rsa.PublicKey) + json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ + "kty": "RSA", "kid": "k1", "alg": "RS256", "use": "sig", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}}) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + p.verifier = r.PostFormValue("code_verifier") + json.NewEncoder(w).Encode(map[string]string{"id_token": p.sign(t)}) + }) + p.Server = httptest.NewServer(mux) + p.claims = map[string]any{ + "iss": p.URL, "sub": "3719", "aud": "git-forge", + "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), + "email": "carol@example.com", "email_verified": true, + "preferred_username": "Carol", + } + return p +} + +func (p *fakeProvider) sign(t *testing.T) string { + t.Helper() + b64 := base64.RawURLEncoding.EncodeToString + header, err := json.Marshal(map[string]string{"alg": "RS256", "kid": "k1", "typ": "JWT"}) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(p.claims) + if err != nil { + t.Fatal(err) + } + signed := b64(header) + "." + b64(payload) + sum := sha256.Sum256([]byte(signed)) + sig, err := rsa.SignPKCS1v15(rand.Reader, p.signer, crypto.SHA256, sum[:]) + if err != nil { + t.Fatal(err) + } + return signed + "." + b64(sig) +} + +// login drives one whole flow and returns the callback's response. +func login(t *testing.T, f *Forge, h http.Handler, p *fakeProvider, mangle func(url.Values)) *httptest.ResponseRecorder { + t.Helper() + start := get(h, "/oidc/sso", nil) + if start.Code != http.StatusSeeOther { + t.Fatalf("starting a login: status %d", start.Code) + } + to, err := url.Parse(start.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + sent := to.Query() + if sent.Get("code_challenge_method") != "S256" || sent.Get("code_challenge") == "" { + t.Errorf("no pkce challenge in %v", sent) + } + + back := url.Values{"code": {"the-code"}, "state": {sent.Get("state")}} + if mangle != nil { + mangle(back) + } + w := get(h, "/oidc/sso/callback?"+back.Encode(), start.Result().Cookies()) + + // The verifier the token endpoint saw has to hash to the challenge sent. + if p.verifier != "" { + sum := sha256.Sum256([]byte(p.verifier)) + if got := base64.RawURLEncoding.EncodeToString(sum[:]); got != sent.Get("code_challenge") { + t.Errorf("the verifier does not match the challenge") + } + } + return w +} + +func oidcForge(t *testing.T, p *fakeProvider) *Forge { + t.Helper() + f := testForge(t) + // Assigned rather than loaded: the config refuses an issuer that is not + // https, and this one is a test server. + f.cfg.OIDC = []OIDCProvider{{ + Key: "sso", Label: "Example SSO", Issuer: p.URL, + ClientID: "git-forge", ClientSecret: "shh", + }} + return f +} + +func TestOIDCLoginProvisions(t *testing.T) { + p := newFakeProvider(t) + defer p.Close() + f := oidcForge(t, p) + h := f.routes() + + w := login(t, f, h, p, nil) + if w.Code != http.StatusSeeOther { + t.Fatalf("callback: status %d, %s", w.Code, w.Body.String()) + } + u, ok := f.store.User("sso:carol") + if !ok { + t.Fatalf("no account was provisioned: %v", f.store.Users()) + } + if u.Origin != originOIDC || u.Iss != p.URL || u.Sub != "3719" { + t.Errorf("identity is %+v", u) + } + if u.Email != "carol@example.com" || !u.EmailConfirmed { + t.Errorf("address is %q, confirmed %v", u.Email, u.EmailConfirmed) + } + if u.Admin || u.Password != "" { + t.Error("a provisioned account should hold no grants and no password") + } + if body := get(h, "/", w.Result().Cookies()).Body.String(); !strings.Contains(body, "sso:carol") { + t.Error("the session from an external login does not work") + } + + // A second login is the same account: identity is iss plus sub, and the + // name is never matched on. + p.claims["preferred_username"] = "Carol Renamed" + if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther { + t.Fatalf("second login: status %d", w.Code) + } + if len(f.store.Users()) != 1 { + t.Errorf("a second login made a second account: %v", f.store.Users()) + } + + // A different subject with the same username gets a suffix rather than + // the other account. + p.claims["sub"] = "9999" + p.claims["preferred_username"] = "Carol" + p.claims["email"] = "carol2@example.com" + if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther { + t.Fatalf("third login: status %d", w.Code) + } + if _, ok := f.store.User("sso:carol2"); !ok { + t.Errorf("a colliding name was not suffixed: %v", f.store.Users()) + } +} + +// The provider is the first factor and not the second, and the global admin +// flag rests on there being a second one. +func TestOIDCLoginAsksForTheSecondFactor(t *testing.T) { + p := newFakeProvider(t) + defer p.Close() + f := oidcForge(t, p) + h := f.routes() + + if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther { + t.Fatalf("the login that provisions the account: status %d", w.Code) + } + addTOTP(t, f, "sso:carol") + + first := login(t, f, h, p, nil) + if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), "Authenticator code") { + t.Fatalf("the callback did not ask for a code: status %d", first.Code) + } + for _, c := range first.Result().Cookies() { + if c.Name == sessionCookie && c.Value != "" { + t.Fatal("the provider alone issued a session") + } + } + + second := post(h, "/login/totp", url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, + "name": {between(first.Body.String(), `name="name" value="`, `"`)}, + "expires": {between(first.Body.String(), `name="expires" value="`, `"`)}, + "t": {between(first.Body.String(), `name="t" value="`, `"`)}, + "code": {totpCode(knownSecret, time.Now())}, + }, nil) + if second.Code != http.StatusSeeOther { + t.Fatalf("the second step refused a good code: status %d", second.Code) + } + if body := get(h, "/", second.Result().Cookies()).Body.String(); !strings.Contains(body, "sso:carol") { + t.Error("the session the second step issued does not work") + } +} + +// The note carrying a half-finished login is bound to the epoch, so signing the +// account out everywhere while it is in flight kills it. +func TestPendingLoginDiesWithTheEpoch(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addTOTP(t, f, "alice") + h := f.routes() + first := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil) + pending := url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, + "name": {between(first.Body.String(), `name="name" value="`, `"`)}, + "expires": {between(first.Body.String(), `name="expires" value="`, `"`)}, + "t": {between(first.Body.String(), `name="t" value="`, `"`)}, + "code": {totpCode(knownSecret, time.Now())}, + } + err := f.store.UpdateUsers("users: sign out", f.store.forgeAuthor(), func(m map[string]User) error { + u := m["alice"] + u.Epoch++ + m["alice"] = u + return nil + }) + if err != nil { + t.Fatal(err) + } + if w := post(h, "/login/totp", pending, nil); w.Code != http.StatusForbidden { + t.Errorf("a login in flight survived the account being signed out: status %d", w.Code) + } +} + +func TestOIDCRefusals(t *testing.T) { + cases := []struct { + what string + claims map[string]any + mangle func(url.Values) + }{ + {what: "an unverified address", claims: map[string]any{"email_verified": false}}, + {what: "a missing email_verified", claims: map[string]any{"email_verified": nil}}, + {what: "an id token for somebody else", claims: map[string]any{"aud": "another-client"}}, + {what: "an expired id token", claims: map[string]any{"exp": time.Now().Add(-2 * time.Hour).Unix()}}, + {what: "an id token from another issuer", claims: map[string]any{"iss": "https://evil.example"}}, + {what: "an address with no domain", claims: map[string]any{"email": "carol"}}, + {what: "an address with a line break in it", claims: map[string]any{"email": "carol@example.com\nx@evil.example"}}, + {what: "a state that did not come from us", mangle: func(v url.Values) { v.Set("state", "made-up") }}, + } + for _, c := range cases { + p := newFakeProvider(t) + f := oidcForge(t, p) + for k, v := range c.claims { + if v == nil { + delete(p.claims, k) + } else { + p.claims[k] = v + } + } + w := login(t, f, f.routes(), p, c.mangle) + if w.Code != http.StatusForbidden { + t.Errorf("%s: status %d", c.what, w.Code) + } + if len(f.store.Users()) != 0 { + t.Errorf("%s: an account was provisioned anyway", c.what) + } + p.Close() + } +} + +// Every claim above is read out of a payload that has already been trusted, so +// a token the provider's own key set cannot vouch for has to be refused before +// any of them is looked at. The key set still publishes k1 and the token still +// names it: only the signature is somebody else's. +func TestAnIdTokenTheKeySetCannotVouchForIsRefused(t *testing.T) { + p := newFakeProvider(t) + defer p.Close() + other, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p.signer = other + + f := oidcForge(t, p) + if w := login(t, f, f.routes(), p, nil); w.Code != http.StatusForbidden { + t.Errorf("a token signed by a key the provider does not publish: status %d", w.Code) + } + if len(f.store.Users()) != 0 { + t.Errorf("an account was provisioned from a token nothing vouches for: %v", f.store.Users()) + } +} + +// An exact kid wins: taking a key that merely names none ahead of the match +// fails verification and hides a rotation from the refetch. +func TestFindKey(t *testing.T) { + mixed := []jwk{{Kid: ""}, {Kid: "k2"}} + if k, ok := findKey(mixed, "k2"); !ok || k.Kid != "k2" { + t.Errorf("an exact kid was passed over for %+v, %v", k, ok) + } + if k, ok := findKey(mixed, ""); !ok || k.Kid != "" { + t.Errorf("a token naming no kid got %+v, %v", k, ok) + } + if k, ok := findKey([]jwk{{Kid: "sole", Use: "sig"}}, ""); !ok || k.Kid != "sole" { + t.Errorf("the one signing key was refused: %+v, %v", k, ok) + } + // A rotation: every key names a kid and none of them is this one, so the + // answer has to be no, or the key set is never fetched again. + if _, ok := findKey([]jwk{{Kid: "k1"}, {Kid: "k2"}}, "k9"); ok { + t.Error("an unknown kid found a key anyway") + } + if _, ok := findKey([]jwk{{Kid: "k1", Use: "enc"}}, ""); ok { + t.Error("an encryption key was offered for a signature") + } +} + +func TestSanitizeName(t *testing.T) { + cases := map[string]string{ + "Carol": "carol", + "carol.smith": "carol.smith", + "../../config": "config", + "Ünïcodé": "n-cod", // no transliteration: unsafe runs become a dash + "": "", + "...": "", + "a b": "a-b", + "sso:carol": "sso-carol", + strings.Repeat("x", 40): "", + } + for in, want := range cases { + if got := sanitizeName(in); got != want { + t.Errorf("sanitizeName(%q) = %q, wanted %q", in, got, want) + } + } +} + +func TestOIDCAllows(t *testing.T) { + cases := []struct { + allow []string + email string + want bool + }{ + {nil, "anyone@example.com", true}, + {[]string{}, "anyone@example.com", true}, + {[]string{"carol@example.com"}, "carol@example.com", true}, + {[]string{"carol@example.com"}, "dave@example.com", false}, + {[]string{"@example.com"}, "anyone@example.com", true}, + {[]string{"@example.com"}, "anyone@evil.example", false}, + // A domain entry is a whole label, not a suffix. + {[]string{"@example.com"}, "anyone@sub.example.com", false}, + {[]string{"@example.com"}, "anyone@example.com.attacker.test", false}, + {[]string{"@example.com"}, "anyone@notexample.com", false}, + // The provider decides the case of what it sends. + {[]string{"Carol@Example.COM"}, "carol@example.com", true}, + {[]string{"@Example.COM"}, "CAROL@example.com", true}, + {[]string{"@example.com", "dave@other.example"}, "dave@other.example", true}, + {[]string{"@example.com"}, "no-at-sign-at-all", false}, + } + for _, c := range cases { + p := OIDCProvider{Allow: c.allow} + if got := p.allows(c.email); got != c.want { + t.Errorf("allow %v, address %q: got %v", c.allow, c.email, got) + } + } +} + +// An address outside the list is refused before an account is made. +func TestOIDCLoginRefusesUnlistedAddress(t *testing.T) { + p := newFakeProvider(t) + defer p.Close() + f := oidcForge(t, p) + f.cfg.OIDC[0].Allow = []string{"@allowed.example", "specific@other.example"} + + if w := login(t, f, f.routes(), p, nil); w.Code != http.StatusForbidden { + t.Fatalf("an unlisted address got status %d", w.Code) + } + if n := len(f.store.Users()); n != 0 { + t.Fatalf("%d accounts were provisioned anyway: %v", n, f.store.Users()) + } + + // And the listed one gets in, so the refusal is the list and not the flow. + p.claims["email"] = "carol@allowed.example" + if w := login(t, f, f.routes(), p, nil); w.Code != http.StatusSeeOther { + t.Fatalf("a listed address got status %d", w.Code) + } + if _, ok := f.store.User("sso:carol"); !ok { + t.Fatalf("no account for a listed address: %v", f.store.Users()) + } +} + +// Removing an entry revokes, rather than only refusing new accounts. +func TestOIDCAllowListRevokes(t *testing.T) { + p := newFakeProvider(t) + defer p.Close() + f := oidcForge(t, p) + h := f.routes() + + if w := login(t, f, h, p, nil); w.Code != http.StatusSeeOther { + t.Fatalf("first login: %d", w.Code) + } + if _, ok := f.store.User("sso:carol"); !ok { + t.Fatal("the first login provisioned nothing") + } + + f.cfg.OIDC[0].Allow = []string{"@somewhere-else.example"} + if w := login(t, f, h, p, nil); w.Code != http.StatusForbidden { + t.Errorf("an account that exists still signed in after being removed: %d", w.Code) + } + if _, ok := f.store.User("sso:carol"); !ok { + t.Error("the account was deleted rather than refused") + } +} + +// The other algorithm. A provider that signs with ES256 is verified by code +// that nothing else here runs, so it is exercised directly rather than through +// a second fake provider. +func TestES256IdTokens(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + b64 := base64.RawURLEncoding.EncodeToString + pub := key.Public().(*ecdsa.PublicKey) + jwk := jwk{Kty: "EC", Kid: "e1", Use: "sig", Crv: "P-256", + X: b64(pub.X.Bytes()), Y: b64(pub.Y.Bytes())} + + signed := []byte("the.token") + sum := sha256.Sum256(signed) + r, s, err := ecdsa.Sign(rand.Reader, key, sum[:]) + if err != nil { + t.Fatal(err) + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + + if err := verifySignature(jwk, "ES256", signed, sig); err != nil { + t.Errorf("a good signature was refused: %v", err) + } + if err := verifySignature(jwk, "ES256", []byte("another.token"), sig); err == nil { + t.Error("the signature verified against a different token") + } + // An algorithm the key cannot carry is refused rather than guessed at. + if err := verifySignature(jwk, "RS256", signed, sig); err == nil { + t.Error("an ec key verified an rs256 signature") + } + if err := verifySignature(jwk, "none", signed, nil); err == nil { + t.Error("an unsigned token verified") + } + // A signature of the wrong size, and coordinates that are not on the + // curve, both of which a hostile key set could hold. + if err := verifySignature(jwk, "ES256", signed, sig[:63]); err == nil { + t.Error("a short signature verified") + } + off := jwk + off.X = b64([]byte{1, 2, 3}) + if err := verifySignature(off, "ES256", signed, sig); err == nil { + t.Error("a point that is not on the curve verified") + } +} @@ -0,0 +1,202 @@ +package main + +import ( + "fmt" + "regexp" + "strings" +) + +// Repo is one entry in repos.json, keyed by {section}/{repo} or {repo}. +type Repo struct { + Desc string `json:"desc"` + Visibility string `json:"visibility"` + Issues string `json:"issues"` + Wiki string `json:"wiki"` + Labels []string `json:"labels"` + Grants map[string]string `json:"grants"` +} + +// visibility governs reading everything; issues and wiki each govern writing +// to that feature. +const ( + visPublic = "public" + visPrivate = "private" + + featDisabled = "disabled" + featMembers = "members" + featUsers = "users" + + grantRead = "read" + grantWrite = "write" + grantAdmin = "admin" +) + +// orphanRepo is how a directory with no entry is served: private, both +// features off. +func orphanRepo() Repo { + return Repo{ + Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, + Labels: []string{}, Grants: map[string]string{}, + } +} + +// clone deep copies, so a writer rebuilding grants is never seen half done. +func (r Repo) clone() Repo { + r.Labels = append([]string(nil), r.Labels...) + grants := make(map[string]string, len(r.Grants)) + for who, level := range r.Grants { + grants[who] = level + } + r.Grants = grants + return r +} + +// A section or repository name. The first character rules out . and .., so a +// path built from these cannot traverse. +var repoNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + +// The forge's own route names, refused in both components: /infra/settings is +// the settings of infra. +var reservedNames = map[string]bool{ + "login": true, "logout": true, "settings": true, "admin": true, + "setup": true, "healthz": true, "set-password": true, + "cgit.css": true, "cgit.png": true, "favicon.ico": true, + "logo": true, "avatar": true, "reset": true, "confirm-email": true, "oidc": true, + "issues": true, "wiki": true, +} + +// cgit's routes, refused in the second component: /infra/log is the log of +// infra. cgit's command table, less info and objects, which it refuses itself. +var cgitNames = map[string]bool{ + "about": true, "atom": true, "blame": true, "blob": true, "commit": true, + "diff": true, "log": true, "ls_cache": true, "patch": true, "plain": true, + "rawdiff": true, "refs": true, "repolist": true, "snapshot": true, + "stats": true, "summary": true, "tag": true, "tree": true, +} + +// Suffixes the tree gives meaning to. +var reservedSuffixes = []string{".git", ".issues", ".wiki"} + +// validRepoPath checks a path from a URL or repos.json: one or two components. +func validRepoPath(path string) bool { + parts := strings.Split(path, "/") + if len(parts) > 2 { + return false + } + for i, part := range parts { + if !repoNameRE.MatchString(part) || reservedNames[part] { + return false + } + if i == 1 && cgitNames[part] { + return false + } + for _, suffix := range reservedSuffixes { + if strings.HasSuffix(part, suffix) { + return false + } + } + } + return true +} + +// Viewer is whoever is asking. The zero value is an anonymous visitor. +type Viewer struct { + Name string // empty when signed out + Admin bool // the global admin flag +} + +func (v Viewer) SignedIn() bool { return v.Name != "" } + +// Grant is the viewer's level on the repository, or "" for none. A global +// admin holds admin everywhere. +func (v Viewer) Grant(repo Repo) string { + if v.Admin { + return grantAdmin + } + if v.Name == "" { + return "" + } + return repo.Grants[v.Name] +} + +// CanRead covers code, browsing, cloning, issues, and wiki alike. +func (v Viewer) CanRead(repo Repo) bool { + return repo.Visibility == visPublic || v.Grant(repo) != "" +} + +// CanWrite is push to {repo}.git alone; issues and wiki have their own knobs. +func (v Viewer) CanWrite(repo Repo) bool { + g := v.Grant(repo) + return g == grantWrite || g == grantAdmin +} + +// CanAdmin is this repository's settings and grants. +func (v Viewer) CanAdmin(repo Repo) bool { return v.Grant(repo) == grantAdmin } + +// CanIssues and CanWiki are writing; reading is visibility's alone. +func (v Viewer) CanIssues(repo Repo) bool { return v.canFeature(repo, repo.Issues) } +func (v Viewer) CanWiki(repo Repo) bool { return v.canFeature(repo, repo.Wiki) } + +func (v Viewer) canFeature(repo Repo, knob string) bool { + if !v.CanRead(repo) { + return false + } + switch knob { + case featMembers: + return v.Grant(repo) != "" + case featUsers: + return v.SignedIn() + } + return false +} + +// validate checks one repos.json record. Refused rather than coerced: a +// coerced permission is one nobody knows they have. +func (r Repo) validate(path string) []error { + var errs []error + fail := func(format string, a ...any) { + errs = append(errs, fmt.Errorf(format, a...)) + } + + if !validRepoPath(path) { + fail("%q is not a usable repository path", path) + } + if r.Visibility != visPublic && r.Visibility != visPrivate { + fail("%s: visibility %q is neither public nor private", path, r.Visibility) + } + for _, knob := range []struct{ name, value string }{{"issues", r.Issues}, {"wiki", r.Wiki}} { + switch knob.value { + case featDisabled, featMembers: + case featUsers: + if r.Visibility == visPrivate { + fail("%s: %s is users on a private repository, so an account that cannot read it could write to it", path, knob.name) + } + default: + fail("%s: %s %q is not disabled, members, or users", path, knob.name, knob.value) + } + } + for name, level := range r.Grants { + if !accountNameRE.MatchString(name) { + fail("%s: %q is not a usable account name", path, name) + } + if level != grantRead && level != grantWrite && level != grantAdmin { + fail("%s: grant %q for %s is not read, write, or admin", path, level, name) + } + } + // Every label is a checkbox on every issue form. + if len(r.Labels) > maxLabels { + fail("%s: %d labels, and %d is all a repository may have", path, len(r.Labels), maxLabels) + } + for _, label := range r.Labels { + if !validLabel(label) { + fail("%s: label %q is empty, too long, or has a line break in it", path, label) + } + } + return errs +} + +const maxLabels = 64 + +func validLabel(label string) bool { + return label != "" && len(label) <= 32 && !strings.ContainsAny(label, "\r\n") +} diff --git a/perm_test.go b/perm_test.go new file mode 100644 index 0000000..dc874fe --- /dev/null +++ b/perm_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "net/http" + "testing" +) + +func TestValidRepoPath(t *testing.T) { + ok := []string{ + "dns", "infra/dns", "a", "x-1.2_3", "infra/x.y", "static", + // cgit's routes are only taken one component deep, because that is the + // only place they can be reached. + "log", "tree", "about", "summary", + } + bad := []string{ + "", ".", "..", "../etc", "infra/../etc", "a/b/c", "/dns", "dns/", + "Infra/dns", "in fra/dns", "dns.git", "dns.issues", "dns.wiki", + "admin", "settings", "login", "-dns", + // The forge's own names are taken in both components: /infra/settings + // has to be the settings of the repository infra, so a repository called + // settings inside the section infra would make that page unreachable. + "infra/settings", "infra/issues", "infra/wiki", "issues", "wiki", + // And cgit's, in the second component, for the same reason. + "infra/log", "infra/tree", "infra/about", "infra/summary", + "infra/atom", "infra/plain", "infra/snapshot", + } + for _, p := range ok { + if !validRepoPath(p) { + t.Errorf("%q was refused", p) + } + } + for _, p := range bad { + if validRepoPath(p) { + t.Errorf("%q was allowed", p) + } + } +} + +func TestPermissions(t *testing.T) { + anon := Viewer{} + stranger := Viewer{Name: "eve"} + reader := Viewer{Name: "carol"} + writer := Viewer{Name: "bob"} + owner := Viewer{Name: "dave"} + root := Viewer{Name: "alice", Admin: true} + + grants := map[string]string{"carol": grantRead, "bob": grantWrite, "dave": grantAdmin} + + private := Repo{Visibility: visPrivate, Issues: featMembers, Wiki: featDisabled, Grants: grants} + public := Repo{Visibility: visPublic, Issues: featUsers, Wiki: featMembers, Grants: grants} + + cases := []struct { + what string + v Viewer + repo Repo + read, write, admin, issue, wiki bool + }{ + {"anon on private", anon, private, false, false, false, false, false}, + {"stranger on private", stranger, private, false, false, false, false, false}, + {"reader on private", reader, private, true, false, false, true, false}, + {"writer on private", writer, private, true, true, false, true, false}, + {"owner on private", owner, private, true, true, true, true, false}, + {"global admin on private", root, private, true, true, true, true, false}, + + {"anon on public", anon, public, true, false, false, false, false}, + {"stranger on public", stranger, public, true, false, false, true, false}, + {"reader on public", reader, public, true, false, false, true, true}, + {"writer on public", writer, public, true, true, false, true, true}, + {"global admin on public", root, public, true, true, true, true, true}, + } + for _, c := range cases { + if got := c.v.CanRead(c.repo); got != c.read { + t.Errorf("%s: read %v", c.what, got) + } + if got := c.v.CanWrite(c.repo); got != c.write { + t.Errorf("%s: write %v", c.what, got) + } + if got := c.v.CanAdmin(c.repo); got != c.admin { + t.Errorf("%s: admin %v", c.what, got) + } + if got := c.v.CanIssues(c.repo); got != c.issue { + t.Errorf("%s: issues %v", c.what, got) + } + if got := c.v.CanWiki(c.repo); got != c.wiki { + t.Errorf("%s: wiki %v", c.what, got) + } + } + + off := Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled, Grants: grants} + for _, v := range []Viewer{anon, reader, writer, owner, root} { + if v.CanIssues(off) || v.CanWiki(off) { + t.Errorf("%s writes to a disabled feature", v.Name) + } + } +} + +func TestUsersOnPrivateIsAnError(t *testing.T) { + repo := Repo{Visibility: visPrivate, Issues: featUsers, Wiki: featDisabled} + if errs := repo.validate("infra/dns"); len(errs) != 1 { + t.Fatalf("expected one error, got %v", errs) + } + repo.Visibility = visPublic + if errs := repo.validate("infra/dns"); len(errs) != 0 { + t.Fatalf("expected none, got %v", errs) + } +} + +// Turning a feature off leaves its repository on disk, and the answer has to +// be the same at both doors. +func TestDisabledFeatureIsDisabledEverywhere(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + err := f.store.UpdateRepos("off", f.store.forgeAuthor(), func(m map[string]Repo) error { + repo := m["notes"] + repo.Issues, repo.Wiki = featDisabled, featDisabled + m["notes"] = repo + return nil + }) + if err != nil { + t.Fatal(err) + } + h := f.routes() + for _, path := range []string{ + "/notes.issues/", "/notes.wiki/", + "/notes.issues.git/info/refs?service=git-upload-pack", + "/notes.wiki.git/info/refs?service=git-upload-pack", + } { + if code := get(h, path, nil).Code; code != http.StatusNotFound { + t.Errorf("%s answered %d for a disabled feature", path, code) + } + } +} diff --git a/static/forge.css b/static/forge.css new file mode 100644 index 0000000..f03fa5a --- /dev/null +++ b/static/forge.css @@ -0,0 +1,135 @@ +/* + * cgit's own stylesheet does the work; this is the rest. The forge's pages + * wrap themselves in div#cgit and use cgit's markup, so both kinds of page are + * the same page. Nothing here may carry meaning of its own: every page still + * has to work with all of it missing. + */ + +/* Readability. cgit's 10pt suits a dense table; prose wants more. */ + +div#cgit { color: #222; } +div#cgit .dim { color: #666; } +div#cgit article { font-size: 11pt; line-height: 1.5; max-width: 46em; } + +/* A row of alternating shade, which is what makes a long list scannable. */ +div#cgit table.list tr:nth-child(even) { background: #f6f6f6; } +div#cgit table.list tr.nohover { background: #fff; } + +/* cgit sizes the header cell and not the image in it, so a large upload drags + * the header, and the page under it, out to its own width. Both axes, or a + * tall one pushes the page down instead. */ +div#cgit table#header td.logo img { max-width: 96px; max-height: 96px; } + +/* Forms. cgit only styles the two in its own header. */ + +div#cgit label { display: block; color: #666; margin-bottom: 0.2em; } +div#cgit label.inline, div#cgit label.choice { display: inline-block; color: #222; } +div#cgit label.choice { display: block; margin: 0.3em 0; } +div#cgit label.inline { margin-right: 1em; } +div#cgit fieldset { border: solid 1px #ccc; margin: 1em 0; padding: 0.5em 1em; } +div#cgit legend { color: #666; padding: 0 0.3em; } +div#cgit form p { margin: 0.9em 0; } + +div#cgit input, div#cgit textarea, div#cgit select, div#cgit button { + font: inherit; + color: #222; + border: solid 1px #bbb; + padding: 0.3em; +} + +div#cgit input[type=checkbox], div#cgit input[type=radio] { width: auto; } +/* cgit's own header and tab controls size themselves, and the forge's must not + * stretch them across the row. */ +div#cgit table#header td.form input, div#cgit table#header td.form select, +div#cgit table.tabs td.form input, div#cgit table.tabs td.form select { + width: auto; + max-width: none; + font-size: 90%; +} +/* A control inside a list row sizes itself, or it stretches the column. */ +div#cgit table.list td input { width: auto; } +div#cgit input, div#cgit textarea { width: 100%; max-width: 34em; } +div#cgit textarea { max-width: none; min-height: 12em; font-family: monospace; } +div#cgit button { background: #eee; padding: 0.3em 1em; cursor: pointer; } +div#cgit button:hover { background: #ddd; } + +/* Sign out is a form because it changes something. Inline, or the button lands + * on its own line away from the name it belongs to. */ +div#cgit table#header td.form form { display: inline; } +div#cgit table#header td.form a { padding: 0 0.2em; } + +div#cgit button.link { + background: none; + border: none; + padding: 0; + color: blue; + cursor: pointer; +} +div#cgit button.link:hover { text-decoration: underline; } + +/* A row of controls that belong together. */ +div#cgit .bar { + display: flex; + flex-wrap: wrap; + gap: 1em; + align-items: center; + margin: 1em 0; + padding: 0.5em 0; + border-top: solid 1px #eee; + border-bottom: solid 1px #eee; +} +div#cgit .bar form { display: flex; gap: 0.5em; align-items: center; margin: 0; } +div#cgit .bar input { width: auto; } +div#cgit .bar span { display: flex; gap: 0.8em; } + +/* One person's say in a thread. */ +div#cgit .comment { border: solid 1px #ddd; margin: 1em 0; } +div#cgit .comment .byline { background: #f6f6f6; border-bottom: solid 1px #ddd; padding: 0.3em 0.6em; } +div#cgit .comment article { margin: 0; padding: 0.2em 0.8em 0.8em 0.8em; } +div#cgit img.avatar { vertical-align: middle; border: solid 1px #ccc; } + +div#cgit .label { + background: #eee; + border: solid 1px #ccc; + padding: 0 0.4em; + margin-right: 0.3em; + font-size: 90%; +} + +div#cgit .state-open { color: #007700; font-weight: bold; } +div#cgit .state-closed { color: #770000; font-weight: bold; } +div#cgit .notice { color: #007700; font-weight: bold; margin: 1em 0; } + +/* Markdown, so that a comment reads as one. */ +div#cgit article h1, div#cgit article h2, div#cgit article h3 { font-size: 120%; margin: 1em 0 0.4em 0; } +div#cgit article p, div#cgit article ul, div#cgit article ol { margin: 0.6em 0; } +div#cgit article code { background: #f0f0f0; padding: 0 0.2em; font-size: 95%; } +div#cgit article pre { background: #f6f6f6; border: solid 1px #e0e0e0; padding: 0.6em; overflow-x: auto; } +div#cgit article pre code { background: none; padding: 0; } +div#cgit article blockquote { border-left: solid 3px #ccc; margin: 0.6em 0; padding-left: 0.8em; color: #555; } + +/* What cannot be undone is marked, so that it is never a slip. */ + +div#cgit .danger { + border: solid 1px #770000; + border-left-width: 4px; + background: #fff6f6; + padding: 0.5em 1em; + margin: 2em 0; +} + +div#cgit .danger h2, div#cgit .danger h3, div#cgit .danger h4 { color: #770000; } +div#cgit .danger button { background: #ffdddd; border-color: #770000; } + +/* highlight's classes, which cgit's stylesheet has nothing to say about. */ + +.hl.com, .hl.slc { color: #767676; font-style: italic; } +.hl.sng, .hl.str { color: #a11; } +.hl.num, .hl.esc { color: #7a5600; } +.hl.ppc, .hl.pps, .hl.ipl { color: #616100; } +.hl.kwa { color: #000; font-weight: bold; } +.hl.kwb { color: #0057ae; } +.hl.kwc { color: #000; } +.hl.kwd, .hl.kwe, .hl.kwf { color: #010181; } +.hl.lin { color: #555; } +.hl.err, .hl.erm { color: #c00; font-weight: bold; } diff --git a/store.go b/store.go new file mode 100644 index 0000000..7ba9ac7 --- /dev/null +++ b/store.go @@ -0,0 +1,761 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "syscall" +) + +// Store is the data directory, which holds four entries and nothing else: +// +// config/ git repo with a working tree: forge.json, users.json, repos.json +// cookie.key generated secret, deliberately outside the config repo +// mailq/ spooled outbound mail, transient +// repos/ bare git repos, holding nothing the forge wrote +type Store struct { + cfg *Config + dir string + configDir string + reposDir string + mailqDir string + cookieKey []byte + + // mu guards the tables, the git commands, and the directories: one lock, + // so there is no order to get wrong. Tables are replaced whole and handed + // out as copies. + mu sync.Mutex + users map[string]User + repos map[string]Repo // repos.json + dirs map[string]bool // what the tree actually holds, from the startup scan +} + +// RepoEntry is one repository as the tree has it. Registered is false for a +// directory with no entry in repos.json. +type RepoEntry struct { + Path string + Repo Repo + Registered bool +} + +// Author is a git identity in the config repo's audit log. +type Author struct{ Name, Email string } + +// User is one record in users.json, for either origin. +type User struct { + Origin string `json:"origin"` + Iss string `json:"iss,omitempty"` // external accounts: identity is + Sub string `json:"sub,omitempty"` // iss plus sub, and nothing else + Admin bool `json:"admin"` + Email string `json:"email,omitempty"` + EmailConfirmed bool `json:"email_confirmed"` + Notify bool `json:"notify"` + Epoch int `json:"epoch"` + Password string `json:"password,omitempty"` // argon2id, local accounts + TOTP *TOTP `json:"totp,omitempty"` + Tokens []Token `json:"tokens,omitempty"` +} + +// TOTP is the second factor. Recovery codes are kept as digests, like push +// tokens. Used is the last time step a code was accepted at, so a code cannot +// be replayed within its window. +type TOTP struct { + Secret string `json:"secret"` + Recovery []string `json:"recovery"` + Used int64 `json:"used,omitempty"` +} + +// Token is one scoped push token. Dates are days: an instant would mean a +// write per request. +type Token struct { + ID string `json:"id"` + Repo string `json:"repo"` + Hash string `json:"hash"` + Created string `json:"created,omitempty"` + Used string `json:"used,omitempty"` +} + +// The admin flag does nothing until the account holds a second factor. +func (u User) adminInEffect() bool { return u.Admin && u.TOTP != nil } + +func (u User) clone() User { + u.Tokens = append([]Token(nil), u.Tokens...) + if u.TOTP != nil { + totp := *u.TOTP + totp.Recovery = append([]string(nil), totp.Recovery...) + u.TOTP = &totp + } + return u +} + +const ( + originLocal = "local" + originOIDC = "oidc" +) + +// An account name is what grants point at, and is immutable. An external one +// is prefixed by its provider's key, as in sso:carol. +var accountNameRE = regexp.MustCompile(`^([a-z][a-z0-9]{0,15}:)?[a-z0-9][a-z0-9._-]{0,31}$`) + +// A local name has no provider prefix, so it cannot collide with a minted one. +var localNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) + +// accountName forgives the shift key: names are lowercase by construction. +func accountName(typed string) string { return strings.ToLower(strings.TrimSpace(typed)) } + +// lockDatadir refuses a second process, which would lose writes. On the +// directory itself, so the kernel drops it however the process exits. +func lockDatadir(dir string) (func(), error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("datadir %s: %v", dir, err) + } + f, err := os.Open(dir) + if err != nil { + return nil, fmt.Errorf("datadir %s: %v", dir, err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, fmt.Errorf("datadir %s is already being served by another git-forge: %v", dir, err) + } + return func() { f.Close() }, nil +} + +// openStore creates whatever is missing, idempotently, and reads the config. +func openStore(dir string) (*Store, error) { + s := &Store{ + dir: dir, + configDir: filepath.Join(dir, "config"), + reposDir: filepath.Join(dir, "repos"), + mailqDir: filepath.Join(dir, "mailq"), + } + // 0700 throughout: the whole datadir is credential material. + for _, d := range []string{s.dir, s.configDir, s.reposDir, s.mailqDir} { + if err := os.MkdirAll(d, 0700); err != nil { + return nil, err + } + } + + // git is only the audit log; one that will not initialise must not stop + // the forge. + if err := s.ensureConfigRepo(); err != nil { + log.Printf("config: no audit log: %v", err) + } + + forge, err := marshalJSON(defaultConfig()) + if err != nil { + return nil, err + } + defaults := []struct { + name string + data []byte + }{ + {"forge.json", forge}, + {"users.json", []byte("{}\n")}, + {"repos.json", []byte("{}\n")}, + } + var made []string + for _, d := range defaults { + created, err := ensureFile(s.File(d.name), d.data) + if err != nil { + return nil, err + } + if created { + log.Printf("config: created %s", d.name) + made = append(made, d.name) + } + } + + // Before the first commit: the committer's address needs the host. + cfg, err := loadConfig(s.File("forge.json")) + if err != nil { + return nil, fmt.Errorf("%s: %v", s.File("forge.json"), err) + } + s.cfg = cfg + if len(made) > 0 { + s.Commit("config: add "+strings.Join(made, ", "), s.forgeAuthor()) + } + + if err := s.loadUsers(); err != nil { + return nil, err + } + if err := s.loadRepos(); err != nil { + return nil, err + } + if err := s.scanRepos(); err != nil { + return nil, err + } + + key, err := cookieKey(filepath.Join(dir, "cookie.key")) + if err != nil { + return nil, err + } + s.cookieKey = key + return s, nil +} + +func (s *Store) loadUsers() error { + data, err := os.ReadFile(s.File("users.json")) + if err != nil { + return err + } + users := map[string]User{} + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&users); err != nil { + return fmt.Errorf("users.json: %v", err) + } + for name, u := range users { + if !accountNameRE.MatchString(name) { + return fmt.Errorf("users.json: %q is not a usable account name", name) + } + if u.Origin != originLocal && u.Origin != originOIDC { + return fmt.Errorf("users.json: %s: origin %q is neither local nor oidc", name, u.Origin) + } + } + s.users = users + return nil +} + +func (s *Store) User(name string) (User, bool) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[name] + return u.clone(), ok +} + +func (s *Store) Users() map[string]User { + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]User, len(s.users)) + for name, u := range s.users { + out[name] = u.clone() + } + return out +} + +// UpdateUsers edits under the lock, writes, and commits. fn is handed a copy; +// returning an error changes nothing. +func (s *Store) UpdateUsers(subject string, author Author, fn func(map[string]User) error) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.writeUsersLocked(fn); err != nil { + return err + } + s.Commit(subject, author) + return nil +} + +// UpdateUser edits one account. +func (s *Store) UpdateUser(name, subject string, author Author, fn func(*User) error) error { + return s.UpdateUsers(subject, author, editUser(name, fn)) +} + +// editUser leaves an account that is gone alone: writing it back would +// recreate it with an origin the next start refuses. +func editUser(name string, fn func(*User) error) func(map[string]User) error { + return func(m map[string]User) error { + acct, ok := m[name] + if !ok { + return nil + } + if err := fn(&acct); err != nil { + return err + } + m[name] = acct + return nil + } +} + +// WriteConfig rewrites forge.json. Read without the environment on top, so an +// override never becomes the file's own value; the name is the only edited +// field with a rule. +func (s *Store) WriteConfig(author Author, apply func(*Config)) error { + s.mu.Lock() + defer s.mu.Unlock() + + cfg, err := readConfig(s.File("forge.json")) + if err != nil { + return err + } + apply(cfg) + if !validSiteName(cfg.SiteName) { + return errSiteName + } + data, err := marshalJSON(cfg) + if err != nil { + return err + } + if err := writeFile(s.File("forge.json"), data, 0600); err != nil { + return err + } + s.Commit("config: update the site", author) + return nil +} + +// touchUser writes without committing: telemetry would bury the audit log. The +// next commit sweeps it in. +func (s *Store) touchUser(name string, fn func(*User) error) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.writeUsersLocked(editUser(name, fn)) +} + +func (s *Store) writeUsersLocked(fn func(map[string]User) error) error { + next := make(map[string]User, len(s.users)) + for name, u := range s.users { + next[name] = u.clone() + } + if err := fn(next); err != nil { + return err + } + data, err := marshalJSON(next) + if err != nil { + return err + } + if err := writeFile(s.File("users.json"), data, 0600); err != nil { + return err + } + s.users = next + return nil +} + +func (s *Store) loadRepos() error { + data, err := os.ReadFile(s.File("repos.json")) + if err != nil { + return err + } + repos := map[string]Repo{} + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&repos); err != nil { + return fmt.Errorf("repos.json: %v", err) + } + var errs []error + for path, repo := range repos { + errs = append(errs, repo.validate(path)...) + } + if err := errors.Join(errs...); err != nil { + return fmt.Errorf("repos.json:\n%v", err) + } + s.repos = repos + return nil +} + +// scanRepos reconciles repos.json against the tree: a directory ending in .git +// is a repository, anything else is a section one level deep. Disagreements +// are logged, never fatal. Every name is quoted: this is the one place a name +// has been through no rule, and a newline in one would write the next log +// line. +func (s *Store) scanRepos() error { + dirs := map[string]bool{} + top, err := os.ReadDir(s.reposDir) + if err != nil { + return err + } + for _, entry := range top { + if !entry.IsDir() { + log.Printf("repos: %q is not a directory, ignoring it", entry.Name()) + continue + } + if siblingDir(entry.Name()) { + continue + } + if path, ok := strings.CutSuffix(entry.Name(), ".git"); ok { + dirs[path] = true + continue + } + section := entry.Name() + inner, err := os.ReadDir(filepath.Join(s.reposDir, section)) + if err != nil { + log.Printf("repos: %q cannot be read, ignoring it: %v", section, err) + continue + } + for _, entry := range inner { + if siblingDir(entry.Name()) { + continue + } + path, ok := strings.CutSuffix(entry.Name(), ".git") + if !entry.IsDir() || !ok { + log.Printf("repos: %q is neither a repository nor a section, ignoring it", section+"/"+entry.Name()) + continue + } + dirs[section+"/"+path] = true + } + } + + s.mu.Lock() + defer s.mu.Unlock() + for path := range dirs { + if !validRepoPath(path) { + log.Printf("repos: %q is not a usable repository path, so it is not served at all", path+".git") + delete(dirs, path) + continue + } + if _, ok := s.repos[path]; !ok { + log.Printf("repos: %q has no entry in repos.json, serving it private with issues and wiki off until an admin adopts or removes it", path) + } + } + for path := range s.repos { + if !dirs[path] { + log.Printf("repos: %q has an entry in repos.json but no directory, ignoring it", path) + } + } + s.dirs = dirs + return nil +} + +// siblingDir is a repository's issues or wiki directory, not a section. +func siblingDir(name string) bool { + path, ok := strings.CutSuffix(name, ".git") + return ok && (strings.HasSuffix(path, ".issues") || strings.HasSuffix(path, ".wiki")) +} + +// The tree decides existence; a directory with no entry is served as an +// orphan. +func (s *Store) Repo(path string) (Repo, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.dirs[path] { + return Repo{}, false + } + if repo, ok := s.repos[path]; ok { + return repo.clone(), true + } + return orphanRepo(), true +} + +// Repos is sorted by path. +func (s *Store) Repos() []RepoEntry { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]RepoEntry, 0, len(s.dirs)) + for path := range s.dirs { + repo, ok := s.repos[path] + if !ok { + repo = orphanRepo() + } + out = append(out, RepoEntry{Path: path, Repo: repo.clone(), Registered: ok}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// splitRepo resolves the repository a URL names, which of the three it is, and +// the rest. Two components are tried before one, so a section wins over a root +// repository of the same name. +func (s *Store) splitRepo(p string) (repo, kind, rest string, ok bool) { + p = strings.TrimPrefix(p, "/") + parts := strings.SplitN(p, "/", 3) + for n := min(2, len(parts)); n >= 1; n-- { + head := strings.Join(parts[:n], "/") + base, kind := head, "" + if cut, found := strings.CutSuffix(head, ".issues"); found { + base, kind = cut, "issues" + } else if cut, found := strings.CutSuffix(head, ".wiki"); found { + base, kind = cut, "wiki" + } + if _, exists := s.Repo(base); !exists { + continue + } + return base, kind, strings.TrimPrefix(strings.TrimPrefix(p, head), "/"), true + } + return "", "", "", false +} + +// splitGit resolves a /{repo}.git/... path without asking whether the +// repository exists: that is decided where the answer can be a challenge. +// kind is "" for the repository, or issues or wiki for a sibling. +func splitGit(p string) (repo, kind, rest string, ok bool) { + p = strings.TrimPrefix(p, "/") + parts := strings.SplitN(p, "/", 3) + for n := min(2, len(parts)); n >= 1; n-- { + dir := strings.Join(parts[:n], "/") + base, isGit := strings.CutSuffix(dir, ".git") + if !isGit { + continue + } + if cut, ok := strings.CutSuffix(base, ".issues"); ok { + base, kind = cut, "issues" + } else if cut, ok := strings.CutSuffix(base, ".wiki"); ok { + base, kind = cut, "wiki" + } + return base, kind, strings.TrimPrefix(strings.TrimPrefix(p, dir), "/"), true + } + return "", "", "", false +} + +// UpdateRepos edits repos.json under the same rules as UpdateUsers. +func (s *Store) UpdateRepos(subject string, author Author, fn func(map[string]Repo) error) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.updateReposLocked(subject, author, fn) +} + +func (s *Store) updateReposLocked(subject string, author Author, fn func(map[string]Repo) error) error { + next := make(map[string]Repo, len(s.repos)) + for path, repo := range s.repos { + next[path] = repo.clone() + } + if err := fn(next); err != nil { + return err + } + data, err := marshalJSON(next) + if err != nil { + return err + } + if err := writeFile(s.File("repos.json"), data, 0600); err != nil { + return err + } + s.repos = next + s.Commit(subject, author) + return nil +} + +// CreateRepo initialises the repository and a sibling per enabled feature, and +// records it. +func (s *Store) CreateRepo(path string, repo Repo, author Author) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.dirs[path] { + return userError(path + " already exists") + } + if errs := repo.validate(path); len(errs) > 0 { + return userError(errors.Join(errs...).Error()) + } + for _, dir := range s.repoDirs(path, repo) { + if err := gitInit(dir, true); err != nil { + return err + } + } + err := s.updateReposLocked("repos: create "+path, author, func(m map[string]Repo) error { + m[path] = repo + return nil + }) + if err != nil { + return err + } + s.dirs[path] = true + return nil +} + +// EnsureSiblings creates what a knob turned on. One turned off keeps its +// repository; the knob governs access. +func (s *Store) EnsureSiblings(path string, repo Repo) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, dir := range s.repoDirs(path, repo) { + if _, err := os.Stat(dir); err == nil { + continue + } + if err := gitInit(dir, true); err != nil { + return err + } + } + return nil +} + +// DeleteRepo removes all three directories, and an emptied section. +func (s *Store) DeleteRepo(path string, author Author) error { + s.mu.Lock() + defer s.mu.Unlock() + + for _, suffix := range []string{".git", ".issues.git", ".wiki.git"} { + if err := os.RemoveAll(filepath.Join(s.reposDir, path+suffix)); err != nil { + return err + } + } + if section, _, ok := strings.Cut(path, "/"); ok { + dir := filepath.Join(s.reposDir, section) + if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 { + if err := os.Remove(dir); err != nil { + return err + } + } + } + delete(s.dirs, path) + return s.updateReposLocked("repos: delete "+path, author, func(m map[string]Repo) error { + delete(m, path) + return nil + }) +} + +// repoDirs is what a repository has on disk, given its knobs. +func (s *Store) repoDirs(path string, repo Repo) []string { + dirs := []string{filepath.Join(s.reposDir, path+".git")} + if repo.Issues != featDisabled { + dirs = append(dirs, filepath.Join(s.reposDir, path+".issues.git")) + } + if repo.Wiki != featDisabled { + dirs = append(dirs, filepath.Join(s.reposDir, path+".wiki.git")) + } + return dirs +} + +// author is the identity a change is recorded under; git wants an address that +// parses. +func (s *Store) author(name string, u User) Author { + if u.Email != "" { + return Author{Name: name, Email: u.Email} + } + return Author{Name: name, Email: name + "@" + s.cfg.host} +} + +func (s *Store) File(name string) string { return filepath.Join(s.configDir, name) } + +func (s *Store) ensureConfigRepo() error { + _, err := os.Stat(filepath.Join(s.configDir, ".git")) + if err == nil { + return nil + } + if !errors.Is(err, fs.ErrNotExist) { + return err + } + return gitInit(s.configDir, false) +} + +// Commit records a change in the config repo. Failure is logged, never fatal: +// history must not take the forge down. add -A, so a manual edit is picked up +// rather than clobbered. +func (s *Store) Commit(subject string, author Author) { + if err := s.commit(subject, author); err != nil { + log.Printf("config: %q not committed, the working tree is still authoritative: %v", subject, err) + } +} + +func (s *Store) commit(subject string, author Author) error { + if _, err := git(s.configDir, nil, "add", "-A"); err != nil { + return err + } + out, err := git(s.configDir, nil, "status", "--porcelain") + if err != nil { + return err + } + if len(bytes.TrimSpace(out)) == 0 { + return nil + } + committer := s.forgeAuthor() + env := []string{ + "GIT_AUTHOR_NAME=" + author.Name, + "GIT_AUTHOR_EMAIL=" + author.Email, + "GIT_COMMITTER_NAME=" + committer.Name, + "GIT_COMMITTER_EMAIL=" + committer.Email, + } + _, err = git(s.configDir, env, "commit", "--quiet", "-m", subject) + return err +} + +// forgeAuthor is the committer, and the author when no account is acting. +func (s *Store) forgeAuthor() Author { + return Author{Name: "git-forge", Email: "git-forge@" + s.cfg.host} +} + +// marshalJSON is encoding/json without HTML escaping: these files are read +// with cat. +func marshalJSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { // Encode ends the file with a newline + return nil, err + } + return buf.Bytes(), nil +} + +// ensureFile reports whether it wrote, so only a first start commits. +func ensureFile(path string, data []byte) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return false, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return false, err + } + return true, writeFile(path, data, 0600) +} + +// writeFile writes beside the target and renames, so nothing sees a partial +// file. +func writeFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp") + if err != nil { + return err + } + tmp := f.Name() + defer os.Remove(tmp) + + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Chmod(perm); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + return err + } + return syncDir(dir) // the rename itself has to be durable +} + +func syncDir(dir string) error { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +// cookieKey signs every session cookie: GIT_FORGE_COOKIE_KEY, else generated +// into {datadir}/cookie.key, outside the config repo. +func cookieKey(path string) ([]byte, error) { + if v, ok := os.LookupEnv("GIT_FORGE_COOKIE_KEY"); ok { + if len(v) < 32 { + return nil, errors.New("GIT_FORGE_COOKIE_KEY: at least 32 characters required") + } + return []byte(v), nil + } + + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + if err := writeFile(path, []byte(hex.EncodeToString(key)+"\n"), 0600); err != nil { + return nil, err + } + log.Printf("generated %s", path) + return key, nil + } + if err != nil { + return nil, err + } + + key, err := hex.DecodeString(strings.TrimSpace(string(data))) + if err != nil || len(key) < 32 { + return nil, fmt.Errorf("%s: expected 64 hex characters; deleting it generates a new key, which signs everybody out", path) + } + return key, nil +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..9ae8c58 --- /dev/null +++ b/store_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "bytes" + "log" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSplitRepo(t *testing.T) { + f := testForge(t) + author := f.store.forgeAuthor() + for _, path := range []string{"notes", "infra", "infra/dns"} { + repo := Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled, Labels: []string{}, Grants: map[string]string{}} + if err := f.store.CreateRepo(path, repo, author); err != nil { + t.Fatal(err) + } + } + cases := []struct{ url, repo, kind, rest string }{ + {"/notes", "notes", "", ""}, + {"/notes/", "notes", "", ""}, + {"/notes/settings", "notes", "", "settings"}, + {"/infra/dns", "infra/dns", "", ""}, // the section wins over the repo + {"/infra/dns/log/", "infra/dns", "", "log/"}, // and keeps winning deeper + {"/infra/other", "infra", "", "other"}, + {"/infra/dns.issues/log/", "infra/dns", "issues", "log/"}, + {"/notes.wiki/tree/", "notes", "wiki", "tree/"}, + {"/nothing", "", "", ""}, + } + for _, c := range cases { + repo, kind, rest, ok := f.store.splitRepo(c.url) + if repo != c.repo || kind != c.kind || rest != c.rest || ok != (c.repo != "") { + t.Errorf("%s resolved to %q %q %q %v", c.url, repo, kind, rest, ok) + } + } +} + +// One process per datadir is a correctness rule: two lose updates to +// users.json with nothing in any log to say so. +func TestDatadirLockIsExclusive(t *testing.T) { + dir := t.TempDir() + unlock, err := lockDatadir(dir) + if err != nil { + t.Fatalf("the first lock was refused: %v", err) + } + if _, err := lockDatadir(dir); err == nil { + t.Error("a second process took a datadir that was already being served") + } + if _, err := lockDatadir(t.TempDir()); err != nil { + t.Errorf("a different datadir was refused: %v", err) + } + unlock() + if unlock, err = lockDatadir(dir); err != nil { + t.Errorf("the lock outlived the process that held it: %v", err) + } + unlock() +} + +// A directory whose name could never be a repository path must not be +// servable, and must not stop the forge starting either. +func TestStrayDirectoryIsSkipped(t *testing.T) { + dir := t.TempDir() + s, err := openStore(dir) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Uppercase.git", "with space.git", "settings.git"} { + if err := os.MkdirAll(filepath.Join(s.reposDir, name), 0700); err != nil { + t.Fatal(err) + } + } + s, err = openStore(dir) + if err != nil { + t.Fatalf("a stray directory stopped the forge starting: %v", err) + } + if repos := s.Repos(); len(repos) != 0 { + t.Errorf("a directory that is not a usable path is being served: %v", repos) + } +} + +// The scan exists to report what arrived by hand, and a report buried in noise +// is not a report: a repository owns two sibling directories, and reading +// either as a section warns about every file git keeps inside it. +func TestScanIsQuietOnAHealthyDatadir(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + addRepo(t, f, "infra/dns", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + + var buf bytes.Buffer + scan := func() string { + t.Helper() + buf.Reset() + old := log.Writer() + log.SetOutput(&buf) + err := f.store.scanRepos() + log.SetOutput(old) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(buf.String()) + } + + if out := scan(); out != "" { + t.Errorf("a healthy datadir warned about itself:\n%s", out) + } + for _, path := range []string{"notes", "infra/dns"} { + if _, ok := f.store.Repo(path); !ok { + t.Errorf("%s did not survive the scan", path) + } + } + // And the one thing it is for is still said. + if err := os.MkdirAll(filepath.Join(f.store.reposDir, "Uppercase.git"), 0700); err != nil { + t.Fatal(err) + } + if out := scan(); !strings.Contains(out, "Uppercase") { + t.Errorf("a stray directory was not reported:\n%s", out) + } +} + +// The scan is the one place that reports a name nothing has validated, so a +// directory can be named to look like the next line of the log. +func TestTheScanCannotBeMadeToForgeALogLine(t *testing.T) { + f := testForge(t) + // No slash: this has to be one name on disk, not a path. + forged := "innocent\n2026-08-22 00:00:00 203.0.113.9 GET x 200 0 1ms" + repos := f.store.reposDir + // One of each thing the scan reports: a stray file, a directory whose name + // is not a usable path, and a stray file inside a section. + if err := os.WriteFile(filepath.Join(repos, forged), nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repos, forged+".git"), 0700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repos, "sect"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repos, "sect", forged), nil, 0600); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + err := f.store.scanRepos() + log.SetOutput(old) + if err != nil { + t.Fatal(err) + } + + out := strings.TrimRight(buf.String(), "\n") + lines := strings.Split(out, "\n") + if len(lines) != 3 { + t.Fatalf("three strays wrote %d lines:\n%s", len(lines), out) + } + for _, line := range lines { + if !strings.Contains(line, "repos: ") || !strings.Contains(line, `\n`) { + t.Errorf("the line break was not escaped: %q", line) + } + } +} + +// Every single-account edit found the account before the write. Writing an +// absent key back would put a record in users.json under a name nothing else +// knows about, with an origin the next start refuses to load. +func TestEditingAnAccountThatIsGoneWritesNothing(t *testing.T) { + dir := t.TempDir() + store, err := openStore(dir) + if err != nil { + t.Fatal(err) + } + + ran := false + err = store.UpdateUser("ghost", "users: sign out every session for ghost", store.forgeAuthor(), + func(acct *User) error { + ran = true + acct.Epoch++ + return nil + }) + if err != nil { + t.Fatalf("editing an account that is gone: %v", err) + } + if ran { + t.Error("the edit ran against an account that is not there") + } + if _, ok := store.User("ghost"); ok { + t.Fatal("the account was recreated") + } + // The consequence that matters: the datadir still starts. + if _, err := openStore(dir); err != nil { + t.Errorf("a fresh start refuses the datadir: %v", err) + } +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..871b061 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,236 @@ +{{define "title"}}Admin - {{.Site}}{{end}} +{{define "main"}} +<h2>This instance</h2> +<form method="post" action="/admin/site" enctype="multipart/form-data"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="site_name">Name, shown in the header and in mail subjects</label> +<input id="site_name" name="site_name" value="{{.Data.Site.Name}}" required> +</p> +<p> +<label for="site_desc">Subtitle, under the name</label> +<input id="site_desc" name="site_desc" value="{{.Data.Site.Desc}}"> +</p> +<fieldset> +<legend>Logo</legend> +<p> +{{if .Data.Site.Logo.Name}} +Currently <code>{{.Data.Site.Logo.Name}}</code>. +{{else}} +Currently cgit's own. +{{end}} +</p> +<p> +<label for="logo_file">Upload one</label> +<input id="logo_file" name="logo_file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon"> +<span class="dim"> +Drawn in the header at 96 pixels at most, and left at its own size below that. +</span> +</p> +<p> +<label class="inline" for="logo-cgit"> +<input id="logo-cgit" name="logo" type="checkbox" value="cgit"> +Use cgit's own logo instead +</label> +</p> +</fieldset> + +<fieldset> +<legend>Favicon</legend> +<p> +{{if .Data.Site.Icon.Name}} +Currently <code>{{.Data.Site.Icon.Name}}</code>. +{{else}} +Currently cgit's own. +{{end}} +</p> +<p> +<label for="icon_file">Upload one</label> +<input id="icon_file" name="icon_file" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon"> +<span class="dim"> +Served at <code>/favicon.ico</code>, whatever type it is. +</span> +</p> +<p> +<label class="inline" for="icon-cgit"> +<input id="icon-cgit" name="icon" type="checkbox" value="cgit"> +Use cgit's own favicon instead +</label> +</p> +</fieldset> +<p class="dim"> +Both take a png, jpeg, gif, webp, or an icon, up to 512 KiB, and are kept in +the config repository, so they are in the backup and in the history with +everything else. SVG is refused: it is XML that can carry script, and this is +served back to browsers. +</p> +<p><button type="submit">Save</button></p> +</form> +<p class="dim"> +Everything else in forge.json decides how the process starts, and is left to +the file: a listener or a base url typed into a browser is a way to lock +everybody out. +</p> + +<h2>Repositories</h2> +<table summary="repositories" class="list nowrap"> +<tr class="nohover"> +<th class="left">Name</th><th class="left">Visibility</th> +<th class="left">Issues</th><th class="left">Wiki</th><th class="left"></th> +</tr> +{{range .Data.Repos}} +<tr> +<td><a href="/{{.Path}}/settings">{{.Path}}</a></td> +<td>{{.Repo.Visibility}}</td> +<td>{{.Repo.Issues}}</td> +<td>{{.Repo.Wiki}}</td> +<td> +{{if not .Registered}} +<form method="post" action="/admin/repo/adopt"> +<input type="hidden" name="csrf" value="{{$.CSRF}}"> +<input type="hidden" name="path" value="{{.Path}}"> +<button type="submit">adopt</button> +</form> +{{end}} +</td> +</tr> +{{else}} +<tr><td colspan="5" class="dim">None yet.</td></tr> +{{end}} +</table> +<p class="dim"> +A repository with an adopt button has a directory but no entry in repos.json. +It is served private with issues and wiki off until it is adopted or deleted. +</p> + +<h3>Create a repository</h3> +<form method="post" action="/admin/repo/create"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="section">Section, optional</label> +<input id="section" name="section" autocapitalize="none"> +</p> +<p> +<label for="name">Name</label> +<input id="name" name="name" autocapitalize="none" required> +</p> +<p> +<label for="desc">Description</label> +<input id="desc" name="desc"> +</p> +{{template "knobs" .Data.New}} +<p><button type="submit">Create</button></p> +</form> + +<h2>Accounts</h2> +{{with .Data.Link}} +<div class="notice"> +One time link for {{$.Data.For}}, valid for two days. It both sets the password +and confirms the address, and it dies the moment it is used. +</div> +<pre>{{.}}</pre> +{{end}} + +<table summary="accounts" class="list nowrap"> +<tr class="nohover"> +<th class="left">Account</th><th class="left">Origin</th><th class="left">Email</th> +<th class="left">Admin</th><th class="left">Second factor</th> +</tr> +{{range $name, $u := .Data.Users}} +<tr> +<td>{{$name}}</td> +<td>{{$u.Origin}}</td> +<td>{{with $u.Email}}{{.}}{{if not $u.EmailConfirmed}} <span class="dim">unconfirmed</span>{{end}}{{end}}</td> +<td>{{if $u.Admin}}yes{{end}}</td> +<td>{{if $u.TOTP}}yes{{else}}<span class="dim">none</span>{{end}}</td> +</tr> +{{end}} +</table> + +<h3>Create an account</h3> +<p> +Creating an account issues a one time set-password link rather than a password, +so an admin never handles one. That link is a way in that outlives this session, +so this asks for your own authenticator code. +</p> +<form method="post" action="/admin/user/create"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="account">Account name</label> +<input id="account" name="name" autocapitalize="none" required> +</p> +<p> +<label for="email">Email address, optional</label> +<input id="email" name="email" type="email"> +</p> +<p> +<label for="create-code">Your authenticator code</label> +<input id="create-code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +<p class="dim"> +The admin flag is not set here. Handing it out is its own act, below. +</p> +<p><button type="submit">Create</button></p> +</form> + +<div class="danger"> +<h3>Changes that hand out power, or cannot be undone</h3> +<p> +Each of these asks for your own authenticator code as well, so that a session +somebody else is sitting in front of cannot use them. +</p> + +<h4>Admin flag</h4> +<form method="post" action="/admin/user/admin"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="flag-name">Account</label> +<input id="flag-name" name="name" autocapitalize="none" required> +</p> +<p> +<label for="flag-admin"> +<input id="flag-admin" name="admin" type="checkbox" value="yes"> +Hold the global admin flag. Leave this unticked to take it away. +</label> +</p> +<p> +<label for="flag-code">Your authenticator code</label> +<input id="flag-code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +<p><button type="submit">Set the flag</button></p> +</form> + +<h4>Clear an authenticator</h4> +<p> +The way back in for somebody who has lost both their authenticator and their +recovery codes. It signs every session for that account out. +</p> +<form method="post" action="/admin/user/totp"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="totp-name">Account</label> +<input id="totp-name" name="name" autocapitalize="none" required> +</p> +<p> +<label for="totp-code">Your authenticator code</label> +<input id="totp-code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +<p><button type="submit">Clear it</button></p> +</form> + +<h4>Delete an account</h4> +<p>This also drops every grant that pointed at it.</p> +<form method="post" action="/admin/user/delete"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="delete-name">Account name</label> +<input id="delete-name" name="name" autocapitalize="none" required> +</p> +<p> +<label for="delete-code">Your authenticator code</label> +<input id="delete-code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +<p><button type="submit">Delete</button></p> +</form> +</div> +{{end}} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..0f1751f --- /dev/null +++ b/templates/base.html @@ -0,0 +1,23 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{block "title" .}}{{.Site}}{{end}}</title> +<link rel="stylesheet" href="/cgit.css"> +<link rel="shortcut icon" href="/favicon.ico"> +</head> +<body> +<div id="cgit"> +{{template "header" .}} +{{template "tabrow" .}} +<div class="content"> +{{if .Error}}<div class="error">{{.Error}}</div>{{end}} +{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}} +{{block "main" .}}{{end}} +</div> +{{/* cgit's footer names cgit, so this one names the forge. */}} +<div class="footer">generated by {{.Site}} at {{.Now}}</div> +</div> +</body> +</html> diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..9c39077 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,5 @@ +{{define "title"}}{{.Data.Status}} {{.Data.Text}} - {{.Site}}{{end}} +{{define "main"}} +<h2>{{.Data.Status}} {{.Data.Text}}</h2> +<p><a href="/">Back to the index</a></p> +{{end}} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..a7460f8 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,16 @@ +{{define "title"}}{{.Site}}{{end}} +{{define "main"}} +<table summary="repository list" class="list nowrap"> +<tr class="nohover"><th class="left">Name</th><th class="left">Description</th></tr> +{{range .Data.Repos}} +<tr> +<td><a href="/{{.Path}}/">{{.Path}}</a>{{if eq .Repo.Visibility "private"}} <span class="dim">private</span>{{end}}</td> +<td>{{.Repo.Desc}}</td> +</tr> +{{else}} +<tr><td colspan="2" class="dim"> +{{if $.User}}Nothing you can read yet.{{else}}Nothing public here. <a href="/login">Sign in</a> to see the rest.{{end}} +</td></tr> +{{end}} +</table> +{{end}} diff --git a/templates/issue-new.html b/templates/issue-new.html new file mode 100644 index 0000000..ed102aa --- /dev/null +++ b/templates/issue-new.html @@ -0,0 +1,27 @@ +{{define "title"}}New issue, {{.Data.Path}} - {{.Site}}{{end}} +{{define "main"}} +<h2>Open an issue</h2> +<form method="post" action="/{{.Data.Path}}/issues"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="title">Title</label> +<input id="title" name="title" required maxlength="200"> +</p> +<p> +<label for="body">Description. Markdown, and raw HTML is not rendered.</label> +<textarea id="body" name="body" rows="12"></textarea> +</p> +{{with .Data.Labels}} +<fieldset> +<legend>Labels</legend> +{{range .}} +<label class="inline" for="label-{{.}}"> +<input id="label-{{.}}" type="checkbox" name="label" value="{{.}}"> +{{.}} +</label> +{{end}} +</fieldset> +{{end}} +<p><button type="submit">Open</button></p> +</form> +{{end}} diff --git a/templates/issue.html b/templates/issue.html new file mode 100644 index 0000000..e5135a0 --- /dev/null +++ b/templates/issue.html @@ -0,0 +1,62 @@ +{{define "title"}}{{.Data.Issue.Title}} - {{.Data.Path}} - {{.Site}}{{end}} +{{define "main"}} +{{$issue := .Data.Issue}} +<h2>{{$issue.Title}} <span class="dim">#{{$issue.ID}}</span></h2> +<p> +<span class="state-{{$issue.State}}">{{$issue.State}}</span> +<span class="dim">opened by</span> {{template "who" $issue.Author}} +<span class="dim">on {{$issue.Created.Format "2006-01-02 15:04"}} UTC</span> +{{range $issue.Labels}}<span class="label">{{.}}</span>{{end}} +</p> + +{{if .Data.Triage}} +<div class="bar"> +<form method="post" action="/{{.Data.Path}}/issues/{{$issue.ID}}/state"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<button type="submit">{{if eq $issue.State "open"}}close this issue{{else}}reopen this issue{{end}}</button> +</form> +{{with .Data.Labels}} +<form method="post" action="/{{$.Data.Path}}/issues/{{$issue.ID}}/labels"> +<input type="hidden" name="csrf" value="{{$.CSRF}}"> +{{range .}} +<label class="inline" for="label-{{.Name}}"> +<input id="label-{{.Name}}" type="checkbox" name="label" value="{{.Name}}" {{if .On}}checked{{end}}> +{{.Name}} +</label> +{{end}} +<button type="submit">set labels</button> +</form> +{{end}} +</div> +{{end}} + +<div class="comment"> +<div class="byline">{{template "who" $issue.Author}} <span class="dim">opened this</span></div> +<article>{{.Data.Rendered}}</article> +</div> + +{{range .Data.Comments}} +<div class="comment"> +<div class="byline"> +{{template "who" .Comment.Author}} +<span class="dim">commented on {{.Comment.Created.Format "2006-01-02 15:04"}} UTC</span> +</div> +<article>{{.Rendered}}</article> +</div> +{{end}} + +{{if .Data.Can}} +<h3>Comment</h3> +<form method="post" action="/{{.Data.Path}}/issues/{{$issue.ID}}/comment"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="body"> +Markdown: **bold**, `code`, a list, or four spaces for a code block. Raw HTML +is not rendered. +</label> +<textarea id="body" name="body" rows="8" required></textarea> +</p> +<p><button type="submit">Comment</button></p> +</form> +{{end}} +{{end}} diff --git a/templates/issues.html b/templates/issues.html new file mode 100644 index 0000000..e0907ce --- /dev/null +++ b/templates/issues.html @@ -0,0 +1,34 @@ +{{define "title"}}Issues, {{.Data.Path}} - {{.Site}}{{end}} +{{define "main"}} +<div class="bar"> +<form method="get" action="/{{.Data.Path}}/issues"> +<input id="q" name="q" value="{{.Data.Query}}" placeholder="search the tracker" size="30"> +<input type="hidden" name="state" value="{{.Data.State}}"> +<button type="submit">search</button> +</form> +<span> +{{if eq .Data.State "open"}}<strong>open</strong>{{else}}<a href="/{{.Data.Path}}/issues?state=open&q={{.Data.Query}}">open</a>{{end}} +{{if eq .Data.State "closed"}}<strong>closed</strong>{{else}}<a href="/{{.Data.Path}}/issues?state=closed&q={{.Data.Query}}">closed</a>{{end}} +<a href="/{{.Data.Path}}.issues/">history</a> +{{if .Data.Can}}<a href="/{{.Data.Path}}/issues/new">open an issue</a>{{end}} +</span> +</div> + +<table summary="issues" class="list nowrap"> +<tr class="nohover"> +<th class="left">#</th><th class="left">Title</th> +<th class="left">Labels</th><th class="left">Author</th><th class="left">Updated</th> +</tr> +{{range .Data.Issues}} +<tr> +<td>{{.ID}}</td> +<td><a href="/{{$.Data.Path}}/issues/{{.ID}}">{{.Title}}</a></td> +<td class="dim">{{range .Labels}}<span class="label">{{.}}</span> {{end}}</td> +<td>{{template "who" .Author}}</td> +<td class="dim">{{.Updated.Format "2006-01-02"}}</td> +</tr> +{{else}} +<tr><td colspan="5" class="dim">Nothing here.</td></tr> +{{end}} +</table> +{{end}} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..eb9dcd9 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,33 @@ +{{define "title"}}Sign in - {{.Site}}{{end}} +{{define "main"}} +<h2>Sign in</h2> +<form method="post" action="/login"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="name">Account</label> +<input id="name" name="name" autocomplete="username" autocapitalize="none" required> +</p> +<p> +<label for="password">Password</label> +<input id="password" name="password" type="password" autocomplete="current-password" required> +</p> +<p><button type="submit">Sign in</button></p> +</form> +{{if .MailOff}} +<p class="dim"> +Mail is turned off on this instance, so there is no password reset. An admin +can issue you a one time link instead. +</p> +{{else}} +<p><a href="/reset">Forgotten your password?</a></p> +{{end}} + +{{with .Data.Providers}} +<h3>Or sign in through</h3> +<ul> +{{range .}} +<li><a href="/oidc/{{.Key}}">{{.Label}}</a></li> +{{end}} +</ul> +{{end}} +{{end}} diff --git a/templates/nav.html b/templates/nav.html new file mode 100644 index 0000000..82f0714 --- /dev/null +++ b/templates/nav.html @@ -0,0 +1,112 @@ +{{/* + cgit's own header markup, rendered in place of the one noheader=1 turns off, + so that both kinds of page carry the same header and it can name the account. +*/}} +{{define "header"}} +<table id="header"> +<tr> +<td class="logo" rowspan="2"><a href="/"><img src="{{.Logo}}" alt="home"></a></td> +<td class="main"> +{{if .Repo}}<a href="/">{{.Site}}</a> : <a href="/{{.Repo.Path}}/">{{.Repo.Path}}</a> +{{else}}<a href="/">{{.Site}}</a>{{end}} +</td> +<td class="form"> +{{if .User}} +{{if or .Admin .MustEnrol}}<a href="/admin">admin</a>{{end}} +<a href="/settings">{{.User}}</a> +<form method="post" action="/logout"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<button type="submit" class="link">sign out</button> +</form> +{{else}} +<a href="/login">sign in</a> +{{end}} +</td> +</tr> +<tr><td class="sub">{{if .Repo}}{{.Repo.Desc}}{{else}}{{.Desc}}{{end}}</td></tr> +</table> +{{end}} + +{{/* The forge's sections, in cgit's tab markup. */}} +{{define "tabs"}}{{with .Repo}}{{if .Issues}}<a href="/{{.Path}}/issues"{{if eq $.Repo.Active "issues"}} class="active"{{end}}>issues</a>{{end}}{{if .Wiki}}<a href="/{{.Path}}/wiki"{{if eq $.Repo.Active "wiki"}} class="active"{{end}}>wiki</a>{{end}}{{if .Settings}}<a href="/{{.Path}}/settings"{{if eq $.Repo.Active "settings"}} class="active"{{end}}>settings</a>{{end}}{{end}}{{end}} + +{{/* + cgit's row copied, for the forge's own pages where cgit renders nothing: the + same tabs in the same order, and the same search cell. +*/}} +{{define "tabrow"}}{{with .Repo}} +<table class="tabs"><tr><td> +<a href="/{{.Path}}/about/">about</a><a href="/{{.Path}}/">summary</a><a href="/{{.Path}}/refs/">refs</a><a href="/{{.Path}}/log/">log</a><a href="/{{.Path}}/tree/">tree</a><a href="/{{.Path}}/commit/">commit</a><a href="/{{.Path}}/diff/">diff</a>{{template "tabs" $}}</td> +<td class="form"><form class="right" method="get" action="/{{.Path}}/log/"> +<select name="qt"> +<option value="grep">log msg</option> +<option value="author">author</option> +<option value="committer">committer</option> +<option value="range">range</option> +</select> +<input class="txt" type="search" size="10" name="q" value=""> +<input type="submit" value="search"> +</form> +</td></tr></table> +{{end}}{{end}} + +{{/* + In the layout because every page is parsed as its own set, and a partial + defined in one page is invisible to the next. +*/}} +{{define "who"}}{{if .}}<img class="avatar" src="/avatar/{{.}}" alt=""> {{.}}{{else}}<span class="dim">nobody</span>{{end}}{{end}} + +{{/* + One fragment for both the create form and the settings form, so that the two + cannot drift apart on what members and users mean. +*/}} +{{define "knobs"}} +<fieldset> +<legend>Who may read it</legend> +<label class="choice" for="v-private"> +<input id="v-private" type="radio" name="visibility" value="private" {{if eq .Visibility "private"}}checked{{end}}> +<strong>private</strong> <span class="dim">accounts holding a grant on it, and global admins</span> +</label> +<label class="choice" for="v-public"> +<input id="v-public" type="radio" name="visibility" value="public" {{if eq .Visibility "public"}}checked{{end}}> +<strong>public</strong> <span class="dim">anybody, including visitors who are not signed in</span> +</label> +</fieldset> + +<fieldset> +<legend>Who may file issues</legend> +<label class="choice" for="i-disabled"> +<input id="i-disabled" type="radio" name="issues" value="disabled" {{if eq .Issues "disabled"}}checked{{end}}> +<strong>disabled</strong> <span class="dim">nobody, and the tab is hidden</span> +</label> +<label class="choice" for="i-members"> +<input id="i-members" type="radio" name="issues" value="members" {{if eq .Issues "members"}}checked{{end}}> +<strong>members</strong> <span class="dim">anybody holding a grant here, even a read grant</span> +</label> +<label class="choice" for="i-users"> +<input id="i-users" type="radio" name="issues" value="users" {{if eq .Issues "users"}}checked{{end}}> +<strong>users</strong> <span class="dim">any signed in account. Public repositories only</span> +</label> +</fieldset> + +<fieldset> +<legend>Who may edit the wiki</legend> +<label class="choice" for="w-disabled"> +<input id="w-disabled" type="radio" name="wiki" value="disabled" {{if eq .Wiki "disabled"}}checked{{end}}> +<strong>disabled</strong> <span class="dim">nobody, and the tab is hidden</span> +</label> +<label class="choice" for="w-members"> +<input id="w-members" type="radio" name="wiki" value="members" {{if eq .Wiki "members"}}checked{{end}}> +<strong>members</strong> <span class="dim">anybody holding a grant here</span> +</label> +<label class="choice" for="w-users"> +<input id="w-users" type="radio" name="wiki" value="users" {{if eq .Wiki "users"}}checked{{end}}> +<strong>users</strong> <span class="dim">any signed in account. Public repositories only</span> +</label> +</fieldset> +<p class="dim"> +Reading is visibility's job alone. The other two say who may write, so a +repository can take bug reports and documentation fixes from people who will +never push code. users on a private repository is refused rather than coerced. +</p> +{{end}} diff --git a/templates/repo-settings.html b/templates/repo-settings.html new file mode 100644 index 0000000..d90f744 --- /dev/null +++ b/templates/repo-settings.html @@ -0,0 +1,122 @@ +{{define "title"}}{{.Data.Path}} settings - {{.Site}}{{end}} +{{define "main"}} +{{$repo := .Data.Repo}} +<h2>Settings</h2> + +{{if not .Data.Enrolled}} +<div class="error"> +This account has no authenticator, so it cannot change who may read or write +this repository, and cannot change grants. +<a href="/settings/totp">Enrol one</a> and come back. +</div> +{{end}} + +<form method="post" action="/{{.Data.Path}}/settings"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="desc">Description, shown in the header and in the index</label> +<input id="desc" name="desc" value="{{$repo.Desc}}"> +</p> +{{template "knobs" $repo}} +<p> +<label for="labels">Labels an issue may carry, one per line</label> +<textarea id="labels" name="labels" rows="5">{{.Data.Labels}}</textarea> +</p> +<p> +<label for="settings-code">Your authenticator code</label> +<input id="settings-code" name="code" inputmode="numeric" autocomplete="one-time-code"> +<span class="dim"> +Needed only to move one of the three above: who may read or write outlives this +session, the way a grant does. A description or a label does not. +</span> +</p> +<p><button type="submit">Save</button></p> +</form> + +<h3>Grants</h3> +<p class="dim"> +read clones and browses a private repository. write adds pushing to it. admin +adds this page. A global admin holds admin everywhere without appearing here. +A push token can be minted for any repository an account can reach, and carries +the access that account already has there and nothing more. +</p> +<p class="dim"> +Access handed out here outlives the session that hands it out, so changing a +grant asks for your own authenticator code as well. +</p> +<table summary="grants" class="list nowrap"> +<tr class="nohover"><th class="left">Account</th><th class="left">Level</th><th class="left"></th></tr> +{{range $account, $level := $repo.Grants}} +<tr> +<td>{{template "who" $account}}</td> +<td>{{$level}}</td> +<td> +{{if $.Data.Enrolled}} +<form method="post" action="/{{$.Data.Path}}/settings/grant"> +<input type="hidden" name="csrf" value="{{$.CSRF}}"> +<input type="hidden" name="account" value="{{$account}}"> +<input name="code" size="6" inputmode="numeric" autocomplete="one-time-code" aria-label="Your authenticator code" placeholder="code" required> +<button type="submit">revoke</button> +</form> +{{end}} +</td> +</tr> +{{else}} +<tr><td colspan="3" class="dim">Nobody, so only global admins can reach it.</td></tr> +{{end}} +</table> + +{{if .Data.Enrolled}} +<form method="post" action="/{{.Data.Path}}/settings/grant"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="account">Account</label> +<input id="account" name="account" autocapitalize="none" required> +</p> +<fieldset> +<legend>Level</legend> +<label class="choice" for="g-read"> +<input id="g-read" type="radio" name="level" value="read" checked> +<strong>read</strong> <span class="dim">clone and browse it</span> +</label> +<label class="choice" for="g-write"> +<input id="g-write" type="radio" name="level" value="write"> +<strong>write</strong> <span class="dim">and push to it</span> +</label> +<label class="choice" for="g-admin"> +<input id="g-admin" type="radio" name="level" value="admin"> +<strong>admin</strong> <span class="dim">and change this page</span> +</label> +</fieldset> +<p> +<label for="grant-code">Your authenticator code</label> +<input id="grant-code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +<p><button type="submit">Grant</button></p> +</form> +{{end}} + +{{if .Data.Delete}} +<div class="danger"> +<h3>Delete this repository</h3> +<p> +This removes the repository, its issues, and its wiki together. There is no +undo and the history goes with it. Type <code>{{.Data.Path}}</code> to confirm, +and your own authenticator code, so that a session somebody else is sitting in +front of cannot do it. +</p> +<form method="post" action="/{{.Data.Path}}/settings/delete"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="confirm">Repository path</label> +<input id="confirm" name="confirm" autocapitalize="none" required> +</p> +<p> +<label for="code">Your authenticator code</label> +<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +<p><button type="submit">Delete it</button></p> +</form> +</div> +{{end}} +{{end}} diff --git a/templates/reset.html b/templates/reset.html new file mode 100644 index 0000000..9025f2e --- /dev/null +++ b/templates/reset.html @@ -0,0 +1,23 @@ +{{define "title"}}Forgotten password - {{.Site}}{{end}} +{{define "main"}} +<h2>Forgotten password</h2> +{{if .MailOff}} +<div class="error"> +Mail is turned off on this instance, so no link can be sent. Ask an admin for +a one time link. +</div> +{{end}} +<p> +This mails a one time link to the address on file for the account. The answer +below is the same whether or not the account exists, so that this page cannot +be used to find out which names are taken. +</p> +<form method="post" action="/reset"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="name">Account name</label> +<input id="name" name="name" autocomplete="username" autocapitalize="none" required> +</p> +<p><button type="submit">Send the link</button></p> +</form> +{{end}} diff --git a/templates/set-password.html b/templates/set-password.html new file mode 100644 index 0000000..6a38413 --- /dev/null +++ b/templates/set-password.html @@ -0,0 +1,20 @@ +{{define "title"}}Set a password - {{.Site}}{{end}} +{{define "main"}} +<h2>Set a password for {{.Data.Name}}</h2> +<p>This link works once, and setting the password is what spends it.</p> +<form method="post" action="/set-password"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<input type="hidden" name="name" value="{{.Data.Name}}"> +<input type="hidden" name="expires" value="{{.Data.Expires}}"> +<input type="hidden" name="t" value="{{.Data.Token}}"> +<p> +<label for="new">Password</label> +<input id="new" name="new" type="password" autocomplete="new-password" required> +</p> +<p> +<label for="again">Password again</label> +<input id="again" name="again" type="password" autocomplete="new-password" required> +</p> +<p><button type="submit">Set the password</button></p> +</form> +{{end}} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..8b2f0f1 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,126 @@ +{{define "title"}}Settings - {{.Site}}{{end}} +{{define "main"}} +<h2>Account</h2> +<p> +Signed in as {{template "who" .User}}{{if .Admin}}, a global admin{{end}}. +</p> + +<h3>Email and notifications</h3> +{{if .MailOff}} +<p class="dim"> +Mail is turned off on this instance, so nothing is sent. The address below is +still what commits made through the web are attributed to. +</p> +{{else}} +<p class="dim"> +An address receives nothing until a link sent to it has been followed. Notices +about your own account ignore the toggle below, because they are security facts +rather than activity. +</p> +{{end}} +<form method="post" action="/settings/email"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="email">Email address</label> +<input id="email" name="email" type="email" value="{{.Data.Email}}"> +{{if .Data.Email}}{{if not .Data.EmailConfirmed}}<span class="dim">unconfirmed</span>{{end}}{{end}} +</p> +<p> +<label for="notify"> +<input id="notify" name="notify" type="checkbox" value="yes" {{if .Data.Notify}}checked{{end}}> +Send me issue and repository notifications +</label> +</p> +<p><button type="submit">Save</button></p> +</form> + +{{if .Data.Local}} +<h3>Change password</h3> +<form method="post" action="/settings/password"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="current">Current password</label> +<input id="current" name="current" type="password" autocomplete="current-password" required> +</p> +<p> +<label for="new">New password</label> +<input id="new" name="new" type="password" autocomplete="new-password" required> +</p> +<p> +<label for="again">New password again</label> +<input id="again" name="again" type="password" autocomplete="new-password" required> +</p> +<p><button type="submit">Change password</button></p> +</form> +{{else}} +<h3>Password</h3> +<p>This account signs in through an external provider, so there is no password here.</p> +{{end}} + +<h3>Push tokens</h3> +<p class="dim"> +Git over HTTP takes a token, never your password. A token is scoped to one +repository and carries the access you already have there: clone if you can read +it, push if you can write it, the wiki if you may edit it. It carries no second +factor, and it can be revoked on its own without touching anything else. +</p> +<pre>git clone {{.Base}}/section/repo.git +Username: {{.User}} +Password: the token</pre> +{{with .Data.Secret}} +<div class="notice">Token for {{$.User}}, shown once:</div> +<pre id="token">{{.}}</pre> +{{end}} +<table summary="push tokens" class="list nowrap"> +<tr class="nohover"> +<th class="left">Id</th><th class="left">Repository</th> +<th class="left">Made</th><th class="left">Last used</th><th class="left"></th> +</tr> +{{range .Data.Tokens}} +<tr> +<td>{{.ID}}</td> +<td><a href="/{{.Repo}}/">{{.Repo}}</a></td> +<td class="dim">{{if .Created}}{{.Created}}{{else}}unknown{{end}}</td> +<td class="dim">{{if .Used}}{{.Used}}{{else}}never{{end}}</td> +<td> +<form method="post" action="/settings/tokens/revoke"> +<input type="hidden" name="csrf" value="{{$.CSRF}}"> +<input type="hidden" name="id" value="{{.ID}}"> +<button type="submit">revoke</button> +</form> +</td> +</tr> +{{else}} +<tr><td colspan="5" class="dim">None. Mint one below to push over HTTP.</td></tr> +{{end}} +</table> +<p class="dim"> +Dates are days, not times: a write on every request git makes would be a write +on every request git makes. A token that has never been used, or was last used +a long time ago, is one worth revoking. +</p> +<form method="post" action="/settings/tokens"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="repo">Repository you can reach</label> +<input id="repo" name="repo" placeholder="section/repo" autocapitalize="none" required> +</p> +<p><button type="submit">Mint a token</button></p> +</form> + +<h3>Authenticator</h3> +<p> +<a href="/settings/totp">Second factor</a>. An account with the global admin +flag has to have one before it can administer anything. +</p> + +<h3>Sessions</h3> +<p> +Signing out everywhere invalidates every session cookie ever issued for this +account, including this browser's. +</p> +<form method="post" action="/settings/sessions"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p><button type="submit">Sign out everywhere</button></p> +</form> +{{end}} diff --git a/templates/setup.html b/templates/setup.html new file mode 100644 index 0000000..9089bd3 --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,33 @@ +{{define "title"}}Set up - {{.Site}}{{end}} +{{define "main"}} +<h2>Set up {{.Site}}</h2> +<p> +This creates the first account and gives it the global admin flag. The setup +token was printed to the log when git-forge started, and this page is gone once +an account exists. +</p> +<form method="post" action="/setup"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<p> +<label for="token">Setup token</label> +<input id="token" name="token" required> +</p> +<p> +<label for="name">Account name</label> +<input id="name" name="name" autocomplete="username" autocapitalize="none" required> +</p> +<p> +<label for="email">Email address, optional</label> +<input id="email" name="email" type="email" autocomplete="email"> +</p> +<p> +<label for="password">Password</label> +<input id="password" name="password" type="password" autocomplete="new-password" required> +</p> +<p> +<label for="again">Password again</label> +<input id="again" name="again" type="password" autocomplete="new-password" required> +</p> +<p><button type="submit">Create the first admin</button></p> +</form> +{{end}} diff --git a/templates/totp-enroll.html b/templates/totp-enroll.html new file mode 100644 index 0000000..da3d301 --- /dev/null +++ b/templates/totp-enroll.html @@ -0,0 +1,96 @@ +{{define "title"}}Authenticator - {{.Site}}{{end}} +{{define "main"}} +<h2>Authenticator</h2> + +{{with .Data.Codes}} +<h3>Recovery codes</h3> +<p>Each of these works once, in place of a code. They are not shown again.</p> +<pre>{{range .}}{{.}} +{{end}}</pre> +{{end}} + +{{if .Data.Enrolled}} +<p> +This account has an authenticator, and {{.Data.Left}} recovery codes left. +</p> + +<h3>New recovery codes</h3> +<p>Making new ones stops the old ones working.</p> +<form method="post" action="/settings/totp/codes"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +{{if .Data.Local}} +<p> +<label for="codes-current">Current password</label> +<input id="codes-current" name="current" type="password" autocomplete="current-password" required> +</p> +{{else}} +<p> +<label for="codes-code">Code from the authenticator</label> +<input id="codes-code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +{{end}} +<p><button type="submit">Make new recovery codes</button></p> +</form> + +<h3>Remove</h3> +{{if .Data.Required}} +<p> +This account holds the global admin flag, so it must keep an authenticator. +Another admin has to take the flag away first. +</p> +{{else}} +<p>Removing it signs out every session for this account.</p> +<form method="post" action="/settings/totp/remove"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +{{if .Data.Local}} +<p> +<label for="current">Current password</label> +<input id="current" name="current" type="password" autocomplete="current-password" required> +</p> +{{else}} +<p> +<label for="remove-code">Code from the authenticator</label> +<input id="remove-code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +{{end}} +<p><button type="submit">Remove the authenticator</button></p> +</form> +{{end}} + +{{else}} +<p> +Type this secret into an authenticator, or give it the URI. There is no QR +code: it would be useless in a terminal browser. +</p> +<pre>{{.Data.Secret}} + +{{.Data.URI}}</pre> +<p> +Then prove it took, which is what enrols it. Enrolling signs out every other +session. +{{if .Data.Local}} +It takes the password as well: a stolen cookie must not be able to put a second +factor on this account and lock its owner out. +{{else}} +This account signs in through an external provider and has no password here, so +enrolling rests on this session alone. The address on file is told, and an admin +can clear an authenticator you did not enrol. +{{end}} +</p> +<form method="post" action="/settings/totp"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<input type="hidden" name="secret" value="{{.Data.Secret}}"> +<p> +<label for="code">Code from the authenticator</label> +<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required> +</p> +{{if .Data.Local}} +<p> +<label for="enrol-current">Current password</label> +<input id="enrol-current" name="current" type="password" autocomplete="current-password" required> +</p> +{{end}} +<p><button type="submit">Enroll</button></p> +</form> +{{end}} +{{end}} diff --git a/templates/totp.html b/templates/totp.html new file mode 100644 index 0000000..329d6cc --- /dev/null +++ b/templates/totp.html @@ -0,0 +1,16 @@ +{{define "title"}}Authenticator code - {{.Site}}{{end}} +{{define "main"}} +<h2>Authenticator code</h2> +<p>The password was right. Now the six digit code, or one recovery code.</p> +<form method="post" action="/login/totp"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<input type="hidden" name="name" value="{{.Data.Name}}"> +<input type="hidden" name="expires" value="{{.Data.Expires}}"> +<input type="hidden" name="t" value="{{.Data.Token}}"> +<p> +<label for="code">Code</label> +<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus required> +</p> +<p><button type="submit">Sign in</button></p> +</form> +{{end}} diff --git a/templates/wiki-edit.html b/templates/wiki-edit.html new file mode 100644 index 0000000..ed896d2 --- /dev/null +++ b/templates/wiki-edit.html @@ -0,0 +1,27 @@ +{{define "title"}}Edit {{.Data.Page}} - {{.Data.Path}} - {{.Site}}{{end}} +{{define "main"}} +<h2>Edit wiki{{with .Data.Page}} <span class="dim">{{.}}</span>{{end}}</h2> + +{{with .Data.Preview}} +<h3>Preview</h3> +<article>{{.}}</article> +<hr> +{{end}} + +<form method="post" action="/{{.Data.Path}}/wiki/{{.Data.Page}}"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<input type="hidden" name="base" value="{{.Data.Base}}"> +<p> +<label for="body">{{.Data.File}}, markdown</label> +<textarea id="body" name="body" rows="24">{{.Data.Body}}</textarea> +</p> +<p> +<button type="submit" name="action" value="save">Save</button> +<button type="submit" name="action" value="preview">Preview</button> +</p> +</form> +<p class="dim"> +Links between pages are plain relative markdown links, and a subdirectory's +index.md is served at the subdirectory itself. +</p> +{{end}} diff --git a/templates/wiki.html b/templates/wiki.html new file mode 100644 index 0000000..36305c6 --- /dev/null +++ b/templates/wiki.html @@ -0,0 +1,37 @@ +{{define "title"}}Wiki {{.Data.Page}} - {{.Data.Path}} - {{.Site}}{{end}} +{{define "main"}} +<h2>Wiki{{with .Data.Page}} <span class="dim">{{.}}</span>{{end}}</h2> + +{{with .Data.Pages}} +<p> +Pages: +{{range .}}<a href="/{{$.Data.Path}}/wiki/{{.}}">{{if .}}{{.}}{{else}}index{{end}}</a>  +{{end}} +</p> +{{end}} + +{{if .Data.Can}} +<form method="get" action="/{{.Data.Path}}/wiki/"> +<p> +<label for="page">New page, or an existing one to edit</label> +<input id="page" name="page" placeholder="setup, or runbooks/dns" autocapitalize="none" required> +<button type="submit">Write it</button> +</p> +</form> +{{end}} + +{{if .Data.Empty}} +<p>This wiki has no pages yet.</p> +{{if .Data.Can}}<p><a href="/{{.Data.Path}}/wiki?edit">Write the first one</a>.</p>{{end}} +{{else if .Data.Missing}} +<p>There is no page here yet.</p> +{{if .Data.Can}}<p><a href="/{{.Data.Path}}/wiki/{{.Data.Page}}?edit">Write it</a>.</p>{{end}} +{{else}} +<article>{{.Data.Rendered}}</article> +<p> +{{if .Data.Can}}<a href="/{{.Data.Path}}/wiki/{{.Data.Page}}?edit">Edit</a>{{end}} +<a href="/{{.Data.Path}}.wiki/log/{{.Data.File}}">History</a> +</p> +{{end}} + +{{end}} @@ -0,0 +1,320 @@ +#!/bin/sh +# smoke: build the image, run it against a temporary volume, and check what +# only breaks in the real thing. +# +# Needs docker, git, curl, lynx, openssl, python3, and timeout here. +set -eu + +IMAGE=${IMAGE:-git-forge:smoke} +PORT=${PORT:-18080} +BASE=http://127.0.0.1:$PORT +NAME=git-forge-smoke + +WORK=$(mktemp -d) +VOLUME="" +cleanup() { + docker rm -f "$NAME" >/dev/null 2>&1 || true + if [ -n "$VOLUME" ]; then + docker volume rm "$VOLUME" >/dev/null 2>&1 || true + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +ok() { printf 'ok %s\n' "$1"; } +no() { printf 'FAIL %s\n' "$1" >&2; exit 1; } + +# want <description> <pattern> <file> +want() { grep -q -- "$2" "$3" || no "$1"; ok "$1"; } +# lack <description> <pattern> <file> +lack() { grep -q -- "$2" "$3" && no "$1"; ok "$1"; } +# code <description> <expected> <curl args...> +code() { + description=$1 expected=$2 + shift 2 + got=$(curl -s -o "$WORK/body" -w '%{http_code}' "$@") + [ "$got" = "$expected" ] || no "$description: got $got, wanted $expected" + ok "$description" +} +# csrf <file> is the form token on a fetched page +csrf() { grep -o 'name="csrf" value="[^"]*"' "$1" | head -1 | cut -d'"' -f4; } +# field <file> <name> is a hidden field on a fetched page +field() { grep -o "name=\"$2\" value=\"[^\"]*\"" "$1" | head -1 | cut -d'"' -f4; } + +# The authenticator secret is the base32 of the ASCII "12345678901234567890", +# so a code can be computed with openssl alone and no base32 decoder. The +# counter goes in as eight bytes, spelled in octal because POSIX printf has +# \ooo and not \xhh. A code works once, so a second call waits for the next +# step; the step is kept in a file because $(totp) runs in a subshell. +SECRET=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ +KEYHEX=3132333435363738393031323334353637383930 +totp() { + last=$(cat "$WORK/step" 2>/dev/null || echo 0) + step=$(( $(date -u +%s) / 30 )) + while [ "$step" -le "$last" ]; do + sleep 1 + step=$(( $(date -u +%s) / 30 )) + done + echo "$step" > "$WORK/step" + msg=$(printf '\\%03o' \ + $(( (step >> 56) & 255 )) $(( (step >> 48) & 255 )) \ + $(( (step >> 40) & 255 )) $(( (step >> 32) & 255 )) \ + $(( (step >> 24) & 255 )) $(( (step >> 16) & 255 )) \ + $(( (step >> 8) & 255 )) $(( step & 255 ))) + digest=$(printf "$msg" | openssl dgst -sha1 -mac HMAC -macopt hexkey:$KEYHEX | sed 's/.*= *//') + offset=$(( 0x$(printf %s "$digest" | cut -c40) * 2 + 1 )) + chunk=$(printf %s "$digest" | cut -c${offset}-$((offset + 7))) + printf '%06d' $(( (0x$chunk & 0x7fffffff) % 1000000 )) +} + +export GIT_TERMINAL_PROMPT=0 GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null +export GIT_AUTHOR_NAME=Smoke GIT_AUTHOR_EMAIL=smoke@example.com +export GIT_COMMITTER_NAME=Smoke GIT_COMMITTER_EMAIL=smoke@example.com + +alice="-c $WORK/alice -b $WORK/alice" +bob="-c $WORK/bob -b $WORK/bob" + +echo "== build and start" +docker build -q -f Containerfile -t "$IMAGE" . >/dev/null +VOLUME=$(docker volume create) +docker run -d --name "$NAME" -p "127.0.0.1:$PORT:8080" \ + -v "$VOLUME:/var/lib/git-forge" -e GIT_FORGE_BASE_URL="$BASE" "$IMAGE" >/dev/null + +for _ in 1 2 3 4 5 6 7 8 9 10; do + sleep 1 + curl -sf "$BASE/healthz" >/dev/null 2>&1 && break +done +curl -sf "$BASE/healthz" >/dev/null || no "the container never answered /healthz" +ok "/healthz answers" + +echo "== the first admin" +docker logs "$NAME" > "$WORK/log" 2>&1 +TOKEN=$(grep -o 'token: .*' "$WORK/log" | cut -d' ' -f2) +[ -n "$TOKEN" ] || no "no setup token in the log" +curl -s $alice "$BASE/setup" > "$WORK/p" +want "the setup page is served" "Setup token" "$WORK/p" +code "the setup form makes the first admin" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")&token=$TOKEN&name=alice&password=hunter2hunter2&again=hunter2hunter2" "$BASE/setup" +code "and then /setup is gone" 404 "$BASE/setup" + +echo "== the admin has to hold a second factor" +code "an admin with no authenticator is sent to enrol" 303 $alice -o /dev/null "$BASE/admin" +curl -s $alice "$BASE/settings/totp" > "$WORK/p" +# Enrolling costs the password too. +curl -s $alice -o /dev/null -w '%{http_code}' \ + -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)" "$BASE/settings/totp" > "$WORK/nopass" +grep -qx 401 "$WORK/nopass" || no "enrolling a second factor did not ask for the password" +ok "enrolling a second factor asks for the password" +curl -s $alice -d "csrf=$(csrf "$WORK/p")&secret=$SECRET&code=$(totp)¤t=hunter2hunter2" \ + "$BASE/settings/totp" > "$WORK/enrolled" +want "enrolling shows recovery codes once" "recovery codes" "$WORK/enrolled" +code "and now the admin pages open" 200 $alice "$BASE/admin" + +echo "== repositories" +curl -s $alice "$BASE/admin" > "$WORK/p" +code "a repository in a section" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")§ion=infra&name=dns&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members" \ + "$BASE/admin/repo/create" +code "a public repository" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")&name=notes&desc=Notes&visibility=public&issues=disabled&wiki=disabled" \ + "$BASE/admin/repo/create" + +# Not an exact list: gc changes the shape over a repository's life. What must +# never appear is a file the forge wrote. +docker exec "$NAME" ls -A /var/lib/git-forge/repos/infra/dns.git | sort > "$WORK/bare" +grep -qvE '^(HEAD|ORIG_HEAD|branches|config|description|hooks|info|logs|objects|packed-refs|refs|shallow|worktrees)$' "$WORK/bare" && + no "the repository holds something that is not git's: $(tr '\n' ' ' < "$WORK/bare")" +ok "the repository holds nothing but git's own files" +docker exec "$NAME" cat /var/lib/git-forge/repos/infra/dns.git/config > "$WORK/repoconfig" +lack "and nothing of the forge's in its config" "unpackLimit" "$WORK/repoconfig" + +docker exec "$NAME" git -C /var/lib/git-forge/config log --format='%an %s' > "$WORK/audit" +want "the change is in the audit log, authored by the admin" "alice repos: create infra/dns" "$WORK/audit" + +echo "== a second account, through the one time link" +curl -s $alice "$BASE/admin" > "$WORK/p" +curl -s $alice -d "csrf=$(csrf "$WORK/p")&name=bob&email=bob@example.com&code=$(totp)" "$BASE/admin/user/create" > "$WORK/made" +# Off the page as the template wrote it, so the separators are escaped. +LINK=$(sed -n 's|.*<pre>\('"$BASE"'/set-password?[^<]*\)</pre>.*|\1|p' "$WORK/made" | sed 's/&/\&/g') +[ -n "$LINK" ] || no "no set-password link on the admin page" +ok "creating an account shows a set-password link" +sleep 1 +docker logs "$NAME" > "$WORK/log" 2>&1 +want "mail is off, so the log names the message it dropped" 'dropped "\[git-forge\] set a password for bob"' "$WORK/log" +lack "and never carries the link" "set-password?" "$WORK/log" +curl -s $bob "$LINK" > "$WORK/p" +code "following it sets the password and confirms the address" 303 $bob -o /dev/null \ + --data-urlencode "csrf=$(csrf "$WORK/p")" \ + --data-urlencode "name=$(field "$WORK/p" name)" \ + --data-urlencode "expires=$(field "$WORK/p" expires)" \ + --data-urlencode "t=$(field "$WORK/p" t)" \ + --data-urlencode "new=bobbobbob1" --data-urlencode "again=bobbobbob1" "$BASE/set-password" +code "and the link does not work twice" 404 "$LINK" + +echo "== grants and push tokens" +curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p" +code "bob is granted write" 303 $alice -o /dev/null \ + -d "csrf=$(csrf "$WORK/p")&account=bob&level=write&code=$(totp)" "$BASE/infra/dns/settings/grant" +sleep 1 +docker logs "$NAME" > "$WORK/log" 2>&1 +want "the grant change is a message, dropped by name" "access to infra/dns changed" "$WORK/log" + +curl -s $bob "$BASE/settings" > "$WORK/p" +curl -s $bob -d "csrf=$(csrf "$WORK/p")&repo=infra/dns" "$BASE/settings/tokens" > "$WORK/minted" +PUSH=$(sed -n 's/.*<pre id="token">\(.*\)<\/pre>.*/\1/p' "$WORK/minted") +[ -n "$PUSH" ] || no "no push token was minted" +ok "bob mints a push token" + +echo "== git over http" +cd "$WORK" +# 2>/dev/null only silences git's note about the repository being empty; a +# clone that actually fails still stops the script. +git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.git" dns 2>/dev/null +printf 'example.com. IN A 192.0.2.1\n' > dns/zone.txt +printf 'package main\n\n// the zone loader\nfunc main() { n := 42; _ = n }\n' > dns/load.go +printf '# infra/dns\n\nThe *zones*.\n' > dns/README.md +# A page that would like to run on this origin must come back as text. +printf '<html><body><script>alert(document.domain)</script></body></html>\n' > dns/page.html +git -C dns add -A +git -C dns commit -qm "dns: add a zone" +git -C dns push -q origin master +ok "push over http with the token" + +git clone -q "$BASE/notes.git" notes 2>/dev/null +ok "a public repository clones anonymously" +git clone -q "$BASE/infra/dns.git" nope 2>"$WORK/refused" && no "a private repository cloned anonymously" +code "a private repository refuses anonymous access with 401" 401 "$BASE/infra/dns.git/info/refs?service=git-upload-pack" + +echo "== issues" +curl -s $alice "$BASE/infra/dns/settings" > "$WORK/p" +curl -s $alice -o /dev/null -d "csrf=$(csrf "$WORK/p")&desc=Authoritative DNS zones&visibility=private&issues=members&wiki=members&labels=bug +docs" "$BASE/infra/dns/settings" +curl -s $alice "$BASE/infra/dns/issues/new" > "$WORK/p" +code "alice opens an issue" 303 $alice -o /dev/null \ + --data-urlencode "csrf=$(csrf "$WORK/p")" \ + --data-urlencode "title=Zone transfer fails on the secondary" \ + --data-urlencode "body=The **secondary** does not answer AXFR." \ + --data-urlencode "label=bug" "$BASE/infra/dns/issues" +curl -s $alice "$BASE/infra/dns/issues/1" > "$WORK/issue" +want "the issue renders its markdown" "<strong>secondary</strong>" "$WORK/issue" +want "and its label" "bug" "$WORK/issue" +code "alice comments" 303 $alice -o /dev/null \ + --data-urlencode "csrf=$(csrf "$WORK/issue")" \ + --data-urlencode "body=Confirmed on ns2 as well." "$BASE/infra/dns/issues/1/comment" +curl -s $alice "$BASE/infra/dns/issues?q=ns2" > "$WORK/search" +want "search finds it through the comment" "Zone transfer fails" "$WORK/search" + +sleep 1 +docker logs "$NAME" > "$WORK/log" 2>&1 +want "the new issue is a message to the grantee" "infra/dns#1 opened" "$WORK/log" + +git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.issues.git" issues +git -C issues show master:issues/1.json | python3 -m json.tool > /dev/null || no "the tracker's json does not parse" +ok "the tracker clones and its json parses" +git -C issues commit -q --allow-empty -m "issues: hand written" +git -C issues push origin master > "$WORK/pushfail" 2>&1 && no "the tracker accepted a push" +want "the tracker refuses a push, with a reason" "written by the forge" "$WORK/pushfail" + +echo "== wiki" +curl -s $alice "$BASE/infra/dns/wiki?edit" > "$WORK/p" +curl -s $alice -o /dev/null --data-urlencode "csrf=$(csrf "$WORK/p")" --data-urlencode "base=" \ + --data-urlencode "body=# Zones + +Written in the browser." --data-urlencode "action=save" "$BASE/infra/dns/wiki/" +curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p" +want "a wiki page written in the browser renders" "Written in the browser." "$WORK/p" + +git clone -q "http://bob:$PUSH@127.0.0.1:$PORT/infra/dns.wiki.git" wiki +printf '# Zones\n\nEdited by push.\n' > wiki/index.md +git -C wiki add -A +git -C wiki commit -qm "wiki: edit by push" +git -C wiki push -q origin master +curl -s $alice "$BASE/infra/dns/wiki" > "$WORK/p" +want "and a push to the wiki changes the rendered page" "Edited by push." "$WORK/p" + +echo "== browsing" +curl -s "$BASE/cgit.css" > "$WORK/style" +curl -s $alice "$BASE/infra/dns/" > "$WORK/summary" +want "the forge's header is injected into a cgit page" 'id="header"' "$WORK/summary" +want "with the description from repos.json" "Authoritative DNS zones" "$WORK/summary" +want "and cgit's own tabs under it" "class='tabs'" "$WORK/summary" +curl -s $alice "$BASE/infra/dns/issues" > "$WORK/forgepage" +want "the same header is on the forge's own pages" 'id="header"' "$WORK/forgepage" + +# Without cgit's own stylesheet, at the path cgit links to, every page is +# unstyled. +code "the one stylesheet is served where cgit looks for it" 200 "$BASE/cgit.css" +want "and carries the forge's as well as cgit's" "highlight's classes" "$WORK/style" +want "and cgit's pages ask for it rather than for a second one" "/cgit.css" "$WORK/summary" +lack "which is the only stylesheet there is" "static/forge.css" "$WORK/summary" +# cgit has no tab called issues, so finding one means the forge's were injected +# into cgit's own row rather than sitting in a second menu. +want "the forge's sections are inside cgit's own tab row" ">issues</a>" "$WORK/summary" +lack "and there is only one tab row" "<table class=\"tabs\"" "$WORK/summary" +lack "and one element with cgit's id on the page" "id=\"cgit\"" "$WORK/summary" + +# The forge's pages copy cgit's tab list, and a copy that is not checked +# drifts. +tablist() { + sed -n 's/.*<table class=.tabs.><tr><td>//p' "$1" | + grep -o '>[a-z]*</a>' | sed 's/>\(.*\)<\/a>/\1/' | tr '\n' ' ' +} +if [ "$(tablist "$WORK/summary")" != "$(tablist "$WORK/forgepage")" ]; then + printf 'FAIL the forge'"'"'s tab row has drifted from cgit'"'"'s\n cgit: %s\n forge: %s\n' \ + "$(tablist "$WORK/summary")" "$(tablist "$WORK/forgepage")" >&2 + exit 1 +fi +ok "the forge's copy of cgit's tab row still matches cgit's" +want "with the decorations that make a log line readable" "a.deco" "$WORK/style" + +curl -s $alice "$BASE/infra/dns/tree/load.go" > "$WORK/blob" +want "a source blob comes back with highlight's markup" 'class="hl ' "$WORK/blob" +curl -s $alice "$BASE/infra/dns/about/" > "$WORK/about" +want "and the about page through the markdown filter" "<em>zones</em>" "$WORK/about" +curl -s -D "$WORK/head" -o "$WORK/blobbody" $alice "$BASE/infra/dns/plain/page.html" +want "a blob comes back as text, never as something a browser runs" "text/plain" "$WORK/head" + +curl -s "$BASE/" > "$WORK/index" +want "the index shows a public repository to anonymous visitors" "notes" "$WORK/index" +want "with the same header on the index" 'id="header"' "$WORK/index" +want "and the columns it has" "Description" "$WORK/index" +lack "and hides a private one" "infra/dns" "$WORK/index" + +echo "== no javascript, and readable in lynx" +ROUTES="/ /login /reset /settings /admin /infra/dns/ /infra/dns/log/ \ +/infra/dns/tree/ /infra/dns/tree/load.go /infra/dns/refs/ /infra/dns/issues \ +/infra/dns/issues/1 /infra/dns/issues/new /infra/dns/wiki /infra/dns/wiki?edit \ +/infra/dns/settings /infra/dns.wiki/ /notes/ /nothing-here" +for route in $ROUTES; do + curl -s $alice "$BASE$route" > "$WORK/page" + grep -q '<script' "$WORK/page" && no "a script tag on $route" + lynx -dump -stdin -nolist < "$WORK/page" > "$WORK/text" 2>/dev/null || no "lynx could not render $route" + [ -s "$WORK/text" ] || no "$route renders empty in lynx" +done +ok "every route is script free and renders in lynx" + +lynx -dump -stdin -nolist < "$WORK/issue" | grep -q "Zone transfer fails on the secondary" || + no "the issue title is not readable in lynx" +ok "the issue reads correctly through lynx -dump" + +# The header says so to the browser too, on cgit's pages as much as the +# forge's. +curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/" +want "cgit's pages carry the script policy" "script-src 'none'" "$WORK/head" +curl -s -D "$WORK/head" -o /dev/null $alice "$BASE/infra/dns/issues" +want "and so do the forge's" "script-src 'none'" "$WORK/head" + +echo "== one process to a datadir" +# timeout, so that a lock that is not taken fails this check rather than +# hanging it: without the lock the second process serves happily for ever. +timeout 30 docker run --rm -v "$VOLUME:/var/lib/git-forge" "$IMAGE" > "$WORK/second" 2>&1 && + no "a second process served a datadir that was already being served" +want "a second process refuses to start, and says why" "already being served" "$WORK/second" + +echo "== shutdown" +docker stop -t 10 "$NAME" >/dev/null +docker inspect -f '{{.State.ExitCode}}' "$NAME" | grep -qx 0 || no "SIGTERM did not exit zero" +ok "SIGTERM drains and exits zero" + +echo +echo "smoke: all checks passed" diff --git a/totp_test.go b/totp_test.go new file mode 100644 index 0000000..1c5b240 --- /dev/null +++ b/totp_test.go @@ -0,0 +1,517 @@ +package main + +import ( + "maps" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// The RFC's own vectors, which is the only way to know an authenticator will +// agree. +func TestTOTPAgainstRFC6238(t *testing.T) { + // The RFC's SHA-1 key is the ASCII "12345678901234567890". + secret := knownSecret + cases := map[int64]string{ + 59: "287082", + 1111111109: "081804", + 1111111111: "050471", + 1234567890: "005924", + 2000000000: "279037", + } + for at, want := range cases { + if got := totpCode(secret, time.Unix(at, 0)); got != want { + t.Errorf("at %d: %s, wanted %s", at, got, want) + } + } + if totpCode("not base32 !!", time.Now()) != "" { + t.Error("an unreadable secret produced a code") + } +} + +func TestTOTPSkew(t *testing.T) { + secret := totpSecret() + now := time.Now() + if totpStep(secret, totpCode(secret, now), now) < 0 { + t.Error("the current code was refused") + } + if totpStep(secret, totpCode(secret, now.Add(-totpPeriod)), now) < 0 { + t.Error("the previous code was refused") + } + if totpStep(secret, totpCode(secret, now.Add(-5*totpPeriod)), now) >= 0 { + t.Error("a code from two minutes ago was accepted") + } + if totpStep(secret, "000000 ", now) >= 0 && totpCode(secret, now) != "000000" { + t.Error("a wrong code was accepted") + } + // The step reported is the one the code was made for, which is what stops + // the previous code once the current one has been used. + if got, want := totpStep(secret, totpCode(secret, now.Add(-totpPeriod)), now), now.Unix()/30-1; got != want { + t.Errorf("the previous code reports step %d, wanted %d", got, want) + } +} + +func TestTOTPLoginAndRecovery(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + + // Enrolling needs a code that proves the authenticator has the secret. + page := get(h, "/settings/totp", session).Body.String() + secret := between(page, `name="secret" value="`, `"`) + if secret == "" { + t.Fatal("no proposed secret on the enrolment page") + } + // Enrolling costs the password as well as a code. + bare := url.Values{"csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}} + if w := post(h, "/settings/totp", bare, session); w.Code != http.StatusUnauthorized { + t.Errorf("enrolling with no password: status %d", w.Code) + } + wrong := url.Values{"csrf": {token}, "secret": {secret}, "code": {"000000"}, "current": {"hunter2hunter2"}} + retry := post(h, "/settings/totp", wrong, session) + if retry.Code != http.StatusBadRequest { + t.Errorf("enrolling with a wrong code: status %d", retry.Code) + } + // The page says to try the next code, which is only possible if the secret + // is still the one already typed into the authenticator. + if got := between(retry.Body.String(), `name="secret" value="`, `"`); got != secret { + t.Errorf("a wrong code rotated the secret to %q", got) + } + w := post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}, + "current": {"hunter2hunter2"}}, session) + if w.Code != http.StatusOK { + t.Fatalf("enrolling: status %d", w.Code) + } + codes := recoveryFrom(w.Body.String()) + if len(codes) != recoveryCodes { + t.Fatalf("expected %d recovery codes, got %v", recoveryCodes, codes) + } + if u, _ := f.store.User("alice"); u.TOTP == nil || u.TOTP.Secret != secret || u.Epoch == 0 { + t.Errorf("enrolment did not stick, or did not bump the epoch: %+v", u.TOTP) + } + + // The password alone now stops at the second step. + first := post(h, "/login", url.Values{"name": {"alice"}, "password": {"hunter2hunter2"}}, nil) + if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), "Authenticator code") { + t.Fatalf("password alone: status %d", first.Code) + } + if len(first.Result().Cookies()) != 0 { + t.Error("the password alone issued a session") + } + pending := url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, + "name": {between(first.Body.String(), `name="name" value="`, `"`)}, + "expires": {between(first.Body.String(), `name="expires" value="`, `"`)}, + "t": {between(first.Body.String(), `name="t" value="`, `"`)}, + } + + badCode := maps.Clone(pending) + badCode.Set("code", "000000") + if w := post(h, "/login/totp", badCode, nil); w.Code != http.StatusUnauthorized { + t.Errorf("a wrong code: status %d", w.Code) + } + right := maps.Clone(pending) + right.Set("code", freshCode(t, f, "alice", secret)) + if w := post(h, "/login/totp", right, nil); w.Code != http.StatusSeeOther || len(w.Result().Cookies()) == 0 { + t.Errorf("the right code: status %d", w.Code) + } + // A code works once, however long its window has left. + if w := post(h, "/login/totp", right, nil); w.Code != http.StatusUnauthorized || len(w.Result().Cookies()) != 0 { + t.Errorf("the same code again: status %d", w.Code) + } + + // A recovery code works once. + recovery := maps.Clone(pending) + recovery.Set("code", codes[0]) + if w := post(h, "/login/totp", recovery, nil); w.Code != http.StatusSeeOther { + t.Errorf("a recovery code: status %d", w.Code) + } + if u, _ := f.store.User("alice"); len(u.TOTP.Recovery) != recoveryCodes-1 { + t.Errorf("the recovery code was not spent: %d left", len(u.TOTP.Recovery)) + } + if w := post(h, "/login/totp", recovery, nil); w.Code != http.StatusUnauthorized { + t.Errorf("the same recovery code twice: status %d", w.Code) + } +} + +// The flag is not enough on its own. +func TestAdminNeedsTOTP(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + err := f.store.UpdateUsers("users: admin", f.store.forgeAuthor(), func(m map[string]User) error { + u := m["alice"] + u.Admin = true + m["alice"] = u + return nil + }) + if err != nil { + t.Fatal(err) + } + h := f.routes() + session := signIn(t, f, h, "alice") + + w := get(h, "/admin", session) + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/settings/totp" { + t.Fatalf("an admin with no authenticator reached /admin: %d %s", w.Code, w.Header().Get("Location")) + } + + token := f.csrf(withCookies("/", session)) + secret := between(get(h, "/settings/totp", session).Body.String(), `name="secret" value="`, `"`) + enrolled := post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}, + "current": {"hunter2hunter2"}}, session) + session = enrolled.Result().Cookies() + if get(h, "/admin", session).Code != http.StatusOK { + t.Error("an enrolled admin still cannot reach /admin") + } + + // And an admin may not take the second factor off again. + token = f.csrf(withCookies("/", session)) + form := url.Values{"csrf": {token}, "current": {"hunter2hunter2"}} + if w := post(h, "/settings/totp/remove", form, session); w.Code != http.StatusForbidden { + t.Errorf("an admin removed their authenticator: status %d", w.Code) + } +} + +// Enrolling and reissuing recovery codes both outlive the session they were +// done from, so both cost the password. +func TestSecondFactorNeedsPassword(t *testing.T) { + f := testForge(t) + addUser(t, f, "victim", "hunter2hunter2") + h := f.routes() + stolen := signIn(t, f, h, "victim") + token := f.csrf(withCookies("/", stolen)) + secret := knownSecret + + w := post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}}, stolen) + if w.Code != http.StatusUnauthorized { + t.Fatalf("a cookie alone enrolled a second factor: status %d", w.Code) + } + if u, _ := f.store.User("victim"); u.TOTP != nil { + t.Fatal("the account was taken with a cookie alone") + } + // The retry has to offer the same secret, or the authenticator that was + // just set up by hand no longer matches. + if got := between(w.Body.String(), `name="secret" value="`, `"`); got != secret { + t.Errorf("the retry offered a different secret: %q", got) + } + + w = post(h, "/settings/totp", url.Values{ + "csrf": {token}, "secret": {secret}, "code": {totpCode(secret, time.Now())}, + "current": {"hunter2hunter2"}}, stolen) + if w.Code != http.StatusOK { + t.Fatalf("enrolling with the password: status %d", w.Code) + } + session := w.Result().Cookies() + token = f.csrf(withCookies("/", session)) + + if w := post(h, "/settings/totp/codes", url.Values{"csrf": {token}}, session); w.Code != http.StatusUnauthorized { + t.Errorf("recovery codes regenerated without a password: status %d", w.Code) + } + w = post(h, "/settings/totp/codes", url.Values{"csrf": {token}, "current": {"hunter2hunter2"}}, session) + if w.Code != http.StatusOK || len(recoveryFrom(w.Body.String())) != recoveryCodes { + t.Errorf("regenerating with the password: status %d", w.Code) + } +} + +// An external account has no password, so the enrolled authenticator is what +// stands between a stolen cookie and losing the second factor. +func TestExternalSecondFactorNeedsACode(t *testing.T) { + f := testForge(t) + err := f.store.UpdateUsers("users: add sso:carol", f.store.forgeAuthor(), func(m map[string]User) error { + m["sso:carol"] = User{Origin: originOIDC, Iss: "https://idp.example", Sub: "carol", Notify: true} + return nil + }) + if err != nil { + t.Fatal(err) + } + addTOTP(t, f, "sso:carol") + h := f.routes() + + // No password and no provider round trip: the cookie is all the thief has. + stolen := httptest.NewRecorder() + f.setSession(stolen, "sso:carol", 0) + session := stolen.Result().Cookies() + token := f.csrf(withCookies("/", session)) + + for _, path := range []string{"/settings/totp/remove", "/settings/totp/codes"} { + if w := post(h, path, url.Values{"csrf": {token}}, session); w.Code != http.StatusForbidden { + t.Errorf("%s with the cookie alone: status %d", path, w.Code) + } + if u, _ := f.store.User("sso:carol"); u.TOTP == nil { + t.Fatalf("%s took the second factor off with the cookie alone", path) + } + } + + code := url.Values{"csrf": {token}, "code": {totpCode(knownSecret, time.Now())}} + if w := post(h, "/settings/totp/codes", code, session); w.Code != http.StatusOK { + t.Errorf("regenerating with the code: status %d", w.Code) + } + code.Set("code", freshCode(t, f, "sso:carol", knownSecret)) + if w := post(h, "/settings/totp/remove", code, session); w.Code != http.StatusSeeOther { + t.Errorf("removing with the code: status %d", w.Code) + } + if u, _ := f.store.User("sso:carol"); u.TOTP != nil { + t.Error("the code was right and the authenticator is still there") + } +} + +// The way back in when both the authenticator and the recovery codes are gone. +func TestAdminClearsLostAuthenticator(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addAdmin(t, f, "alice") + addTOTP(t, f, "bob") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + token := f.csrf(withCookies("/", session)) + + // Clearing somebody's second factor hands out access, so it asks for a code. + bare := url.Values{"csrf": {token}, "name": {"bob"}} + if w := post(h, "/admin/user/totp", bare, session); w.Code != http.StatusForbidden { + t.Errorf("cleared bob's factor with no code of our own: status %d", w.Code) + } + if u, _ := f.store.User("bob"); u.TOTP == nil { + t.Fatal("it was cleared anyway") + } + withCode := func(name string) url.Values { + return url.Values{"csrf": {token}, "name": {name}, "code": {freshCode(t, f, "alice", knownSecret)}} + } + if w := post(h, "/admin/user/totp", withCode("alice"), session); w.Code != http.StatusForbidden { + t.Errorf("an admin cleared their own factor from the admin page: status %d", w.Code) + } + if w := post(h, "/admin/user/totp", withCode("bob"), session); w.Code != http.StatusSeeOther { + t.Fatalf("clearing bob: status %d", w.Code) + } + if u, _ := f.store.User("bob"); u.TOTP != nil || u.Epoch == 0 { + t.Error("bob's factor was not cleared, or his sessions were left alive") + } + if w := post(h, "/login", url.Values{"name": {"bob"}, "password": {"hunter2hunter2"}}, nil); w.Code != http.StatusSeeOther { + t.Errorf("bob cannot get back in: status %d", w.Code) + } +} + +// The budget is for guessing, and it is the same one the login form spends. An +// admin charged for every code that was right would lock themselves out. +func TestConfirmCodeCountsWrongCodesOnly(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addUser(t, f, "bob", "hunter2hunter2") + addAdmin(t, f, "alice") + h := f.routes() + session := codeSession(t, f, h, "alice", "hunter2hunter2") + token := f.csrf(withCookies("/", session)) + // Signing in spent one of these, which is its own rule. Count from here. + f.totpLimit = newLimiter() + + right := url.Values{"csrf": {token}, "name": {"bob"}, "admin": {"yes"}} + for i := range 5 { + right.Set("code", freshCode(t, f, "alice", knownSecret)) + if w := post(h, "/admin/user/admin", right, session); w.Code != http.StatusSeeOther { + t.Fatalf("admin action %d: status %d", i, w.Code) + } + } + if f.totpLimit.over(accountKey("alice"), 0) { + t.Error("codes that were right spent the budget for guessing") + } + + wrong := url.Values{"csrf": {token}, "name": {"bob"}, "admin": {"yes"}, "code": {"000000"}} + for range 3 { + if w := post(h, "/admin/user/admin", wrong, session); w.Code != http.StatusForbidden { + t.Fatalf("a wrong code was accepted: status %d", w.Code) + } + } + // The code that was just accepted is refused a second time, and counted. + if w := post(h, "/admin/user/admin", right, session); w.Code != http.StatusForbidden { + t.Errorf("a code used already was accepted again: status %d", w.Code) + } + if !f.totpLimit.over(accountKey("alice"), 3) { + t.Error("wrong codes were not counted") + } + if f.totpLimit.over(accountKey("alice"), 4) { + t.Error("more was counted than was tried") + } +} + +// The secret arrives in a form, so what is enrolled is only as strong as this +// check makes it. +func TestEnrolledSecretMustBeTheMintedSize(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + h := f.routes() + session := signIn(t, f, h, "alice") + for _, secret := range []string{"AA", "", "!!!", knownSecret + "GE"} { + w := post(h, "/settings/totp", url.Values{ + "csrf": {f.csrf(withCookies("/", session))}, "secret": {secret}, + "code": {totpCode(secret, time.Now())}, "current": {"hunter2hunter2"}, + }, session) + if w.Code != http.StatusBadRequest { + t.Errorf("a secret of %d characters: status %d", len(secret), w.Code) + } + } + if u, _ := f.store.User("alice"); u.TOTP != nil { + t.Error("a secret this forge would not mint was enrolled") + } +} + +// Two logins with one recovery code, from the same read of the account: the +// write decides, not the read. +func TestRecoveryCodeIsSpentInTheWrite(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + show, keep := newRecoveryCodes() + err := f.store.UpdateUser("alice", "users: enrol", f.store.forgeAuthor(), func(acct *User) error { + acct.TOTP = &TOTP{Secret: knownSecret, Recovery: keep} + return nil + }) + if err != nil { + t.Fatal(err) + } + stale, _ := f.store.User("alice") + if !f.spendRecoveryCode("alice", stale, show[0]) { + t.Fatal("a fresh recovery code was refused") + } + if f.spendRecoveryCode("alice", stale, show[0]) { + t.Error("the same recovery code was accepted twice") + } + if f.spendRecoveryCode("alice", stale, "not-a-code") { + t.Error("a made up recovery code was accepted") + } + if u, _ := f.store.User("alice"); len(u.TOTP.Recovery) != recoveryCodes-1 { + t.Errorf("%d recovery codes left, wanted %d", len(u.TOTP.Recovery), recoveryCodes-1) + } +} + +// knownSecret is the base32 of the ASCII "12345678901234567890", the key the +// RFC's own vectors use. +const knownSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + +// addTOTP enrols the known authenticator on an account, so that totpCode can +// answer for it. +func addTOTP(t *testing.T, f *Forge, name string) { + t.Helper() + err := f.store.UpdateUsers("users: enrol "+name, f.store.forgeAuthor(), func(m map[string]User) error { + u := m[name] + u.TOTP = &TOTP{Secret: knownSecret, Recovery: []string{}} + m[name] = u + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// freshCode clears the step the account last accepted and returns the current +// code. A code works once, and a test cannot wait thirty seconds for the next. +func freshCode(t *testing.T, f *Forge, name, secret string) string { + t.Helper() + err := f.store.touchUser(name, func(acct *User) error { + if acct.TOTP != nil { + acct.TOTP.Used = 0 + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return totpCode(secret, time.Now()) +} + +// addAdmin hands out the global admin flag, with the authenticator the flag +// does nothing without. +func addAdmin(t *testing.T, f *Forge, name string) { + t.Helper() + addTOTP(t, f, name) + err := f.store.UpdateUsers("users: admin "+name, f.store.forgeAuthor(), func(m map[string]User) error { + u := m[name] + u.Admin = true + m[name] = u + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// codeSession signs an account holding the known authenticator all the way +// in, through both steps. +func codeSession(t *testing.T, f *Forge, h http.Handler, name, password string) []*http.Cookie { + t.Helper() + first := post(h, "/login", url.Values{"name": {name}, "password": {password}}, nil) + if !strings.Contains(first.Body.String(), "Authenticator code") { + t.Fatal("expected a second step") + } + w := post(h, "/login/totp", url.Values{ + "csrf": {f.csrf(withCookies("/", nil))}, + "name": {between(first.Body.String(), `name="name" value="`, `"`)}, + "expires": {between(first.Body.String(), `name="expires" value="`, `"`)}, + "t": {between(first.Body.String(), `name="t" value="`, `"`)}, + "code": {totpCode(knownSecret, time.Now())}, + }, nil) + if w.Code != http.StatusSeeOther { + t.Fatalf("second step: %d", w.Code) + } + return w.Result().Cookies() +} + +func between(s, start, end string) string { + _, rest, ok := strings.Cut(s, start) + if !ok { + return "" + } + out, _, _ := strings.Cut(rest, end) + return out +} + +func recoveryFrom(page string) []string { + block := between(page, "<pre>", "</pre>") + var codes []string + for _, line := range strings.Split(block, "\n") { + if line = strings.TrimSpace(line); line != "" { + codes = append(codes, line) + } + } + return codes +} + +// Removing it is the other half of enrolling: it costs the password too, and +// it takes every session with it. +func TestSecondFactorIsRemoved(t *testing.T) { + f := testForge(t) + addUser(t, f, "bob", "hunter2hunter2") + addTOTP(t, f, "bob") + h := f.routes() + session := codeSession(t, f, h, "bob", "hunter2hunter2") + token := f.csrf(withCookies("/", session)) + + if w := post(h, "/settings/totp/remove", url.Values{"csrf": {token}}, session); w.Code != http.StatusUnauthorized { + t.Fatalf("removing with no password: status %d", w.Code) + } + if u, _ := f.store.User("bob"); u.TOTP == nil { + t.Fatal("it was removed anyway") + } + form := url.Values{"csrf": {token}, "current": {"hunter2hunter2"}} + if w := post(h, "/settings/totp/remove", form, session); w.Code != http.StatusSeeOther { + t.Fatalf("removing with the password: status %d", w.Code) + } + u, _ := f.store.User("bob") + if u.TOTP != nil { + t.Error("the authenticator is still enrolled") + } + if u.Epoch == 0 { + t.Error("removing it left every other session alive") + } + // And the password alone gets back in. + if w := post(h, "/login", url.Values{"name": {"bob"}, "password": {"hunter2hunter2"}}, nil); w.Code != http.StatusSeeOther { + t.Errorf("signing in after the removal: status %d", w.Code) + } +} @@ -0,0 +1,303 @@ +package main + +import ( + "errors" + "fmt" + "html/template" + "log" + "net/http" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" +) + +// Wiki pages are markdown in {repo}.wiki.git, paths in the git tree. Rendered +// here rather than by cgit: clean URLs, an index from ls-tree, and a history +// link out to cgit. + +// A wiki path component, for reading and writing alike. +var wikiNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +const maxWikiDepth = 8 + +func (f *Forge) wikiDir(path string) string { + return filepath.Join(f.store.reposDir, path+".wiki.git") +} + +// wikiFiles maps a URL to the tree paths it could mean: the page itself, then +// that name as a directory. index.md is served at the root of the wiki and of +// any subdirectory. +func wikiFiles(page string) ([]string, bool) { + page = strings.TrimPrefix(page, "/") + page = strings.TrimSuffix(page, ".md") + if page == "" || strings.HasSuffix(page, "/") { + page += "index" + if !validWikiPage(page) { + return nil, false + } + return []string{page + ".md"}, true + } + if !validWikiPage(page) { + return nil, false + } + return []string{page + ".md", page + "/index.md"}, true +} + +func validWikiPage(page string) bool { + parts := strings.Split(page, "/") + if len(parts) > maxWikiDepth { + return false + } + for _, part := range parts { + if !wikiNameRE.MatchString(part) { + return false + } + } + return true +} + +// wikiPage is the reverse: a tree path as it appears in a URL. Only a whole +// index component is dropped: myindex.md is a page called myindex. +func wikiPage(file string) string { + page := strings.TrimSuffix(file, ".md") + if page == "index" { + return "" + } + if rest, ok := strings.CutSuffix(page, "/index"); ok { + return rest + "/" + } + return page +} + +type wikiView struct { + Path string // the repository + Page string // the page, "" for the root + File string // the path in the tree + Rendered template.HTML + Pages []string + Missing bool // no such page yet + Empty bool // no pages at all yet + Can bool // may edit +} + +type wikiEdit struct { + Path string + Page string + File string + Body string + Base string // the blob this edit started from, for the compare and swap + Preview template.HTML +} + +// handleWiki routes everything under {repo}/wiki. +func (f *Forge) handleWiki(w http.ResponseWriter, r *http.Request, path string, repo Repo, rest string) { + if repo.Wiki == featDisabled { + f.fail(w, r, http.StatusNotFound, "this repository has its wiki turned off") + return + } + page := strings.TrimPrefix(strings.TrimPrefix(rest, "wiki"), "/") + files, ok := wikiFiles(page) + if !ok { + f.fail(w, r, http.StatusNotFound, "that is not a usable page name") + return + } + + // Without javascript a form cannot put a name into a path, so it arrives + // as a query. + if isGet(r) && r.URL.Query().Get("page") != "" { + wanted, ok := wikiFiles(r.URL.Query().Get("page")) + if !ok { + f.fail(w, r, http.StatusBadRequest, + "a page name is letters, digits, dot, dash, and underscore, with / for a subdirectory") + return + } + http.Redirect(w, r, "/"+path+"/wiki/"+wikiPage(wanted[0])+"?edit", http.StatusSeeOther) + return + } + + switch { + case isGet(r) && r.URL.Query().Has("edit"): + // Editing is always the page itself, never the directory behind it. + f.wikiForm(w, r, path, repo, files[0], "", "") + case isGet(r): + f.wikiShow(w, r, path, repo, files) + case r.Method == http.MethodPost: + if f.checkPost(w, r) { + f.wikiSave(w, r, path, repo, files[0]) + } + default: + f.fail(w, r, http.StatusNotFound, "no such page") + } +} + +func (f *Forge) wikiShow(w http.ResponseWriter, r *http.Request, path string, repo Repo, files []string) { + dir := f.wikiDir(path) + view := wikiView{ + Path: path, Page: wikiPage(files[0]), File: files[0], + Can: f.viewer(r).CanWiki(repo), + } + + head, err := resolveRef(dir, branch) + if err != nil { + // An empty wiki and a missing repository must not read the same. + f.problem(w, r, http.StatusInternalServerError, err) + return + } + if head == "" { + view.Empty, view.Missing = true, true + } else { + view.Pages = f.wikiPages(dir, head) + view.Missing = true + for _, file := range files { + if body, err := catFile(dir, head+":"+file); err == nil { + view.Page, view.File = wikiPage(file), file + view.Rendered = renderMarkdown(string(body)) + view.Missing = false + break + } + } + } + + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "wiki", repo) + d.Data = view + // A wiki with no pages at all is a state, not a 404. + status := http.StatusOK + if view.Missing && !view.Empty { + status = http.StatusNotFound + } + f.render(w, "wiki.html", status, d) +} + +// wikiPages is the index, straight out of the tree. +func (f *Forge) wikiPages(dir, head string) []string { + paths, err := listTreeRecursive(dir, head) + if err != nil { + log.Printf("wiki: %v", err) + return nil + } + pages := []string{} + for _, p := range paths { + if !strings.HasSuffix(p, ".md") { + continue + } + // A push can put a name here that reading refuses, or one that reads + // back as another file; a link that does not open its file is worse + // than none. + page := wikiPage(p) + files, ok := wikiFiles(page) + if !ok || !slices.Contains(files, p) { + continue + } + pages = append(pages, page) + } + sort.Strings(pages) + return pages +} + +func (f *Forge) wikiForm(w http.ResponseWriter, r *http.Request, path string, repo Repo, file, body, message string) { + if !f.viewer(r).CanWiki(repo) { + f.fail(w, r, http.StatusForbidden, "you may not edit this wiki") + return + } + dir := f.wikiDir(path) + base, err := f.wikiBlob(dir, file) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + // An editor opened empty over a page would save it away. + if body == "" && base != "" { + current, err := catFile(dir, base) + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + body = string(current) + } + + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "wiki", repo) + d.Error = message + d.Data = wikiEdit{Path: path, Page: wikiPage(file), File: file, Body: body, Base: base} + f.render(w, "wiki-edit.html", http.StatusOK, d) +} + +// wikiBlob is the page's blob id now. The editor carries it back and wikiSave +// compares it inside the commit: a compare and swap on the page, not the +// branch. +func (f *Forge) wikiBlob(dir, file string) (string, error) { + head, err := resolveRef(dir, branch) + if err != nil { + return "", err + } + return blobIn(dir, head, file) +} + +// blobIn is the id of one path's blob in one commit, or "" when the repository +// is empty or the commit does not hold that path. +func blobIn(dir, commit, file string) (string, error) { + if commit == "" { + return "", nil + } + return resolveRef(dir, commit+":"+file) +} + +// errStale is the comparison failing inside the commit. +var errStale = errors.New("the page changed while it was being edited") + +func (f *Forge) wikiSave(w http.ResponseWriter, r *http.Request, path string, repo Repo, file string) { + if !f.viewer(r).CanWiki(repo) { + f.fail(w, r, http.StatusForbidden, "you may not edit this wiki") + return + } + body := strings.ReplaceAll(r.PostFormValue("body"), "\r\n", "\n") + base := r.PostFormValue("base") + + if len(body) > maxBody { + f.fail(w, r, http.StatusBadRequest, "that page is too big") + return + } + if r.PostFormValue("action") == "preview" { + d := f.page(r) + d.Repo = f.repoBar(r, path, "", "wiki", repo) + d.Data = wikiEdit{ + Path: path, Page: wikiPage(file), File: file, Body: body, Base: base, + Preview: renderMarkdown(body), + } + f.render(w, "wiki-edit.html", http.StatusOK, d) + return + } + + dir := f.wikiDir(path) + verb := "update" + if base == "" { + verb = "create" + } + if !strings.HasSuffix(body, "\n") { + body += "\n" + } + // Compared inside the commit, or both saves of one page would land. + err := commitWith(dir, f.actor(r), f.store.forgeAuthor(), func(commit string) ([]change, string, error) { + now, err := blobIn(dir, commit, file) + if err != nil { + return nil, "", err + } + if now != base { + return nil, "", errStale + } + return []change{{path: file, data: []byte(body)}}, fmt.Sprintf("wiki: %s %s", verb, file), nil + }) + if errors.Is(err, errStale) { + f.wikiForm(w, r, path, repo, file, body, + "somebody else changed this page while you were editing it. open it again in another window, merge your text in, and save") + return + } + if err != nil { + f.problem(w, r, http.StatusInternalServerError, err) + return + } + http.Redirect(w, r, "/"+path+"/wiki/"+wikiPage(file), http.StatusSeeOther) +} diff --git a/wiki_test.go b/wiki_test.go new file mode 100644 index 0000000..eae2c4e --- /dev/null +++ b/wiki_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" +) + +// A page name has to survive the trip out to a URL and back to a path in the +// tree, or a page is listed under a link that does not open it. +func TestWikiPageNames(t *testing.T) { + for _, file := range []string{ + "index.md", "docs/index.md", "notes/deep/index.md", + "plain.md", "myindex.md", "a/myindex.md", "index.notes.md", + } { + page := wikiPage(file) + files, ok := wikiFiles(page) + if !ok { + t.Errorf("%s becomes the page %q, which is not a usable name", file, page) + continue + } + if !slices.Contains(files, file) { + t.Errorf("%s becomes the page %q, which reads %v instead", file, page, files) + } + } +} + +// No pages yet is a state, not a failure. +func TestEmptyWikiIsNotAnError(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) + if code := get(f.routes(), "/notes/wiki", nil).Code; code != http.StatusOK { + t.Errorf("an empty wiki answered %d", code) + } + if code := get(f.routes(), "/notes/wiki/nope", nil).Code; code != http.StatusOK { + t.Errorf("a missing page in an empty wiki answered %d", code) + } +} + +// A push can put a name in the tree that reading refuses, and a link that +// answers 404 is worse than no link. +func TestWikiIndexOnlyListsPagesItCanOpen(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) + dir := f.wikiDir("notes") + author := f.store.forgeAuthor() + err := commitWith(dir, author, author, func(base string) ([]change, string, error) { + return []change{ + {path: "fine.md", data: []byte("# fine\n")}, + {path: "a b.md", data: []byte("# spaces\n")}, + {path: ".hidden.md", data: []byte("# a dot\n")}, + // Both of these read as a page that opens index.md instead, so + // listing them offers a link that never reaches the file. + {path: ".md", data: []byte("# a file called .md\n")}, + {path: "index.md.md", data: []byte("# doubled\n")}, + }, "wiki: written by push", nil + }) + if err != nil { + t.Fatal(err) + } + + head, err := resolveRef(dir, branch) + if err != nil { + t.Fatal(err) + } + pages := f.wikiPages(dir, head) + if !slices.Contains(pages, "fine") { + t.Errorf("a usable page is not listed: %v", pages) + } + // One entry per file, and every entry opens the file it came from. + if len(pages) != 1 { + t.Errorf("three files that cannot be opened were listed anyway: %v", pages) + } + h := f.routes() + for _, page := range []string{"a b", ".hidden"} { + if slices.Contains(pages, page) { + t.Errorf("%q is listed but cannot be opened: %v", page, pages) + } + if code := get(h, "/notes/wiki/"+url.PathEscape(page), nil).Code; code != http.StatusNotFound { + t.Errorf("%q answered %d, so it could have been listed after all", page, code) + } + } +} + +// Two people saving one page from the same base must not both be told they +// saved. Comparing before the commit rather than inside it leaves the whole +// commit between the check and the write, so this has to be concurrent. +func TestWikiSaveIsACompareAndSwap(t *testing.T) { + f := testForge(t) + addUser(t, f, "alice", "hunter2hunter2") + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers}) + h := f.routes() + session := signIn(t, f, h, "alice") + token := f.csrf(withCookies("/", session)) + post(h, "/notes/wiki/page", url.Values{ + "csrf": {token}, "base": {""}, "action": {"save"}, "body": {"original\n"}}, session) + + dir := f.wikiDir("notes") + base, err := f.wikiBlob(dir, "page.md") + if err != nil || base == "" { + t.Fatalf("the first page did not land: %q %v", base, err) + } + + const writers = 4 + codes := make([]int, writers) + var wg sync.WaitGroup + for i := range writers { + wg.Add(1) + go func() { + defer wg.Done() + codes[i] = post(h, "/notes/wiki/page", url.Values{ + "csrf": {token}, "base": {base}, "action": {"save"}, + "body": {fmt.Sprintf("rewritten by %d\n", i)}}, session).Code + }() + } + wg.Wait() + + saved := 0 + for i, code := range codes { + switch code { + case http.StatusSeeOther: + saved++ + case http.StatusOK: // handed back the editor and told to merge + default: + t.Errorf("writer %d: status %d", i, code) + } + } + if saved != 1 { + t.Errorf("%d of %d writers from one base were told they saved", saved, writers) + } + // And the one that did save is what the page holds. + head, _ := resolveRef(dir, branch) + body, err := catFile(dir, head+":page.md") + if err != nil || !strings.HasPrefix(string(body), "rewritten by ") { + t.Errorf("the page reads %q, %v", body, err) + } +} + +// An empty repository and one that is not there must not look the same, or a +// bad restore renders as a friendly page. Both at the primitive and at the +// page that shows it. +func TestMissingRepoIsAnError(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + h := f.routes() + dir := filepath.Join(f.store.reposDir, "notes.wiki.git") + if head, err := resolveRef(dir, branch); head != "" || err != nil { + t.Fatalf("an empty repository should be empty and fine: %q %v", head, err) + } + if code := get(h, "/notes/wiki", nil).Code; code != http.StatusOK { + t.Fatalf("an empty wiki answered %d", code) + } + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + if _, err := resolveRef(dir, branch); err == nil { + t.Error("a missing repository still looks like an empty one") + } + if code := get(h, "/notes/wiki", nil).Code; code != http.StatusInternalServerError { + t.Errorf("a wiki with no repository behind it answered %d", code) + } + // And the tracker, which is the same question at the other door. + if err := os.RemoveAll(f.issuesDir("notes")); err != nil { + t.Fatal(err) + } + if code := get(h, "/notes/issues", nil).Code; code != http.StatusInternalServerError { + t.Errorf("a tracker with no repository behind it answered %d", code) + } +} |