git@cosmocr.at/gitweb.git · summary refs
Never list or render upspin Access files when browsing
commit 2e0a533d009e0e5d7037bdb6760a87a236afc607
Patrick Gallagher <rpg137@gmail.com> · 2026-07-05 21:47 -0400
parent f85e00a1
An Access file's contents enumerate the upspin users and groups granted
access to a tree, which should not be exposed to browsers. Hide them in
both browse paths:

- Plain namespace browse: omit Access from directory listings and 404 a
  direct request for one.
- Git repo tree: omit Access from tree listings and 404 its blob/raw
  views, covering the case where an Access file was committed into a
  repo.

A repo's own upspin Access already 404'd via the git view (unknown
verb); the real exposure was a parent-directory listing rendering it.
Adds TestAccessFilesHidden.
internal/server/git.go
package server
import (
"bytes"
"errors"
"html/template"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"gitweb/internal/gitrepo"
"gitweb/internal/render"
)
var gitTmpl = template.Must(template.ParseFS(tmplFS, "templates/git.html"))
const logLimit = 100
// serveGit handles a request that Locate resolved to a bare repo. repoPath is
// the upspin repo root (e.g. "u@d.com/proj.git"); rest is the remaining URL
// after it (e.g. "blob/main/src/x.go"). base is "/"+repoPath.
func (s *Server) serveGit(w http.ResponseWriter, r *http.Request, repoPath, rest string) {
repo, err := s.repos.Get(repoPath)
if err != nil {
s.storeErr(w, err)
return
}
base := "/" + repoPath
rest = strings.Trim(rest, "/")
verb, rem, _ := strings.Cut(rest, "/")
switch verb {
case "":
s.gitSummary(w, repo, repoPath, base)
case "refs":
s.gitRefs(w, repo, repoPath, base)
case "tree":
s.gitTree(w, repo, repoPath, base, rem)
case "blob":
s.gitBlob(w, repo, repoPath, base, rem, false, r.URL.Query())
case "raw":
s.gitBlob(w, repo, repoPath, base, rem, true, nil)
case "log":
s.gitLog(w, repo, repoPath, base, rem)
case "commit":
s.gitCommit(w, repo, repoPath, base, rem)
default:
s.notFound(w)
}
}
// --- view models ---
type gitNav struct {
RepoName string
Base string
Ref string
LogURL string
}
func newNav(repoName, base, ref string) gitNav {
n := gitNav{RepoName: repoName, Base: base, Ref: ref}
if ref != "" {
n.LogURL = base + "/log/" + escapePath(ref)
}
return n
}
type treeEntry struct {
Name string
IsDir bool
URL string
Size string
}
type crumb struct {
Name string
URL string // empty for the last crumb
}
type refLink struct {
Name string
Short string
URL string
LogURL string
}
// --- summary ---
func (s *Server) gitSummary(w http.ResponseWriter, repo *git.Repository, repoName, base string) {
hash, ref, _, err := gitrepo.ResolveRef(repo, "")
if err != nil {
s.notFound(w)
return
}
nav := newNav(repoName, base, ref)
tree, err := treeAt(repo, hash, "")
if err != nil {
s.serverErr(w, err)
return
}
entries := treeEntries(repo, tree, base, ref, "")
branches, tags, err := gitrepo.Refs(repo)
if err != nil {
s.serverErr(w, err)
return
}
var readme template.HTML
if name := findReadme(tree); name != "" {
if f, err := tree.File(name); err == nil {
if data, err := f.Contents(); err == nil {
if h, err := s.renderer.ToHTML([]byte(data)); err == nil {
readme = rebaseRepoHTML(template.HTML(h), base, ref, path.Dir(name))
}
}
}
}
s.renderGit(w, repoName, "summary", struct {
Nav gitNav
Branches []refLink
Tags []refLink
Entries []treeEntry
Readme template.HTML
}{
Nav: nav,
Branches: refLinks(branches, base, "tree"),
Tags: refLinks(tags, base, "tree"),
Entries: entries,
Readme: readme,
})
}
// --- tree ---
func (s *Server) gitTree(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) {
hash, ref, sub, err := gitrepo.ResolveRef(repo, rem)
if err != nil {
s.notFound(w)
return
}
tree, err := treeAt(repo, hash, sub)
if err != nil {
s.notFound(w)
return
}
nav := newNav(repoName, base, ref)
title := repoName
if sub != "" {
title = repoName + "/" + sub
}
s.renderGit(w, repoName, "tree", struct {
Nav gitNav
Title string
Crumbs []crumb
Entries []treeEntry
}{
Nav: nav,
Title: title,
Crumbs: buildCrumbs(base, ref, sub, true),
Entries: treeEntries(repo, tree, base, ref, sub),
})
}
// --- blob / raw ---
func (s *Server) gitBlob(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string, raw bool, q url.Values) {
hash, ref, sub, err := gitrepo.ResolveRef(repo, rem)
if err != nil || sub == "" {
s.notFound(w)
return
}
+ if isAccessFile(path.Base(sub)) {
+ s.notFound(w) // never render an upspin ACL, blob or raw
+ return
+ }
tree, err := treeAt(repo, hash, "")
if err != nil {
s.notFound(w)
return
}
file, err := tree.File(sub)
if err != nil {
s.notFound(w)
return
}
data, err := file.Contents()
if err != nil {
s.serverErr(w, err)
return
}
content := []byte(data)
if raw {
ctype := http.DetectContentType(content)
w.Header().Set("Content-Type", ctype)
w.Write(content)
return
}
rawURL := base + "/raw/" + escapePath(ref) + "/" + escapePath(sub)
binary := render.IsBinary(content)
name := path.Base(sub)
force := q.Get("force") == "1"
selected := s.renderers.Select(name, content, q.Get("render"), force)
var body template.HTML
var choices []rendererChoice
var forceURL string
if !binary {
body, err = selected.Render(name, content)
if err != nil {
s.serverErr(w, err)
return
}
if selected.ID == "markdown" {
// Rewrite relative links/images so they resolve to the file's
// directory in the repo rather than the current URL.
body = rebaseRepoHTML(body, base, ref, path.Dir(sub))
}
blobURL := base + "/blob/" + escapePath(ref) + "/" + escapePath(sub)
choices, forceURL = renderControls(s.renderers, blobURL, name, content, selected.ID, force)
}
s.renderGit(w, repoName, "blob", blobView{
Nav: newNav(repoName, base, ref),
Crumbs: buildCrumbs(base, ref, sub, true),
Path: sub,
Size: humanSize(file.Size),
RawURL: rawURL,
Binary: binary,
Body: body,
Renderers: choices,
Force: force,
ForceURL: forceURL,
})
}
// blobView is the file-view model, including the renderer control bar.
type blobView struct {
Nav gitNav
Crumbs []crumb
Path string
Size string
RawURL string
Binary bool
Body template.HTML
Renderers []rendererChoice
Force bool
ForceURL string
}
// rendererChoice is one selectable renderer link in the control bar.
type rendererChoice struct {
Label string
URL string
Active bool
}
// renderControls builds the renderer switch links and the force-toggle link for
// a blob. With force on, every registered renderer is offered; otherwise only
// the eligible ones. The default renderer's link carries no ?render= so its URL
// stays clean.
func renderControls(reg *render.Registry, blobURL, name string, content []byte, selectedID string, force bool) ([]rendererChoice, string) {
mods := reg.Eligible(name, content)
if force {
mods = reg.All()
}
defID := reg.Default().ID
choices := make([]rendererChoice, 0, len(mods))
for _, m := range mods {
choices = append(choices, rendererChoice{
Label: m.Label,
URL: blobURL + blobQuery(m.ID, defID, force),
Active: m.ID == selectedID,
})
}
var forceURL string
if force {
// Turning force off: keep the selection only if it stays eligible.
keep := selectedID
if _, ok := find(reg.Eligible(name, content), keep); !ok {
keep = defID
}
forceURL = blobURL + blobQuery(keep, defID, false)
} else {
forceURL = blobURL + blobQuery(selectedID, defID, true)
}
return choices, forceURL
}
// blobQuery builds the query string for a blob link with the given renderer and
// force state. The default renderer is encoded as no ?render= param.
func blobQuery(id, defID string, force bool) string {
v := url.Values{}
if id != "" && id != defID {
v.Set("render", id)
}
if force {
v.Set("force", "1")
}
if len(v) == 0 {
return ""
}
return "?" + v.Encode()
}
func find(mods []render.Module, id string) (render.Module, bool) {
for _, m := range mods {
if m.ID == id {
return m, true
}
}
return render.Module{}, false
}
// --- log ---
func (s *Server) gitLog(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) {
hash, ref, _, err := gitrepo.ResolveRef(repo, rem)
if err != nil {
s.notFound(w)
return
}
iter, err := repo.Log(&git.LogOptions{From: hash})
if err != nil {
s.serverErr(w, err)
return
}
type commitRow struct {
Subject, Author, When, Short, URL string
}
var commits []commitRow
_ = iter.ForEach(func(c *object.Commit) error {
if len(commits) >= logLimit {
return errStopIter
}
commits = append(commits, commitRow{
Subject: subjectOf(c.Message),
Author: c.Author.Name,
When: c.Author.When.Format("2006-01-02"),
Short: c.Hash.String()[:8],
URL: base + "/commit/" + c.Hash.String(),
})
return nil
})
s.renderGit(w, repoName, "log", struct {
Nav gitNav
Commits interface{}
}{
Nav: newNav(repoName, base, ref),
Commits: commits,
})
}
// --- commit ---
func (s *Server) gitCommit(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) {
rem = strings.Trim(rem, "/")
h, err := repo.ResolveRevision(plumbing.Revision(rem))
if err != nil {
s.notFound(w)
return
}
c, err := repo.CommitObject(*h)
if err != nil {
s.notFound(w)
return
}
var fromTree *object.Tree
var parents []struct{ Short, URL string }
if c.NumParents() > 0 {
p, err := c.Parent(0)
if err == nil {
fromTree, _ = p.Tree()
parents = append(parents, struct{ Short, URL string }{
Short: p.Hash.String()[:8],
URL: base + "/commit/" + p.Hash.String(),
})
}
}
toTree, err := c.Tree()
if err != nil {
s.serverErr(w, err)
return
}
changes, err := object.DiffTree(fromTree, toTree)
if err != nil {
s.serverErr(w, err)
return
}
patch, err := changes.Patch()
if err != nil {
s.serverErr(w, err)
return
}
s.renderGit(w, repoName, "commit", struct {
Nav gitNav
Subject string
Body string
Hash string
Author string
When string
Parents []struct{ Short, URL string }
Diff template.HTML
}{
Nav: newNav(repoName, base, ""),
Subject: subjectOf(c.Message),
Body: bodyOf(c.Message),
Hash: c.Hash.String(),
Author: c.Author.Name + " <" + c.Author.Email + ">",
When: c.Author.When.Format("2006-01-02 15:04 -0700"),
Parents: parents,
Diff: render.Diff(patch),
})
}
// --- refs ---
func (s *Server) gitRefs(w http.ResponseWriter, repo *git.Repository, repoName, base string) {
branches, tags, err := gitrepo.Refs(repo)
if err != nil {
s.serverErr(w, err)
return
}
s.renderGit(w, repoName, "refs", struct {
Nav gitNav
Branches []refLink
Tags []refLink
}{
Nav: newNav(repoName, base, ""),
Branches: refLinks(branches, base, "tree"),
Tags: refLinks(tags, base, "tree"),
})
}
// --- helpers ---
// errStopIter is a sentinel to stop a commit ForEach early.
var errStopIter = errors.New("stop")
func (s *Server) renderGit(w http.ResponseWriter, repoName, name string, data interface{}) {
var body bytes.Buffer
if err := gitTmpl.ExecuteTemplate(&body, name, data); err != nil {
s.serverErr(w, err)
return
}
s.renderPage(w, repoName, template.HTML(body.String()))
}
// treeAt returns the tree at sub within the commit identified by hash.
func treeAt(repo *git.Repository, hash plumbing.Hash, sub string) (*object.Tree, error) {
c, err := repo.CommitObject(hash)
if err != nil {
return nil, err
}
tree, err := c.Tree()
if err != nil {
return nil, err
}
if sub == "" {
return tree, nil
}
return tree.Tree(sub)
}
func treeEntries(repo *git.Repository, tree *object.Tree, base, ref, sub string) []treeEntry {
out := make([]treeEntry, 0, len(tree.Entries))
for _, e := range tree.Entries {
+ if isAccessFile(e.Name) {
+ continue // don't list an upspin ACL committed into the tree
+ }
child := e.Name
if sub != "" {
child = sub + "/" + e.Name
}
isDir := e.Mode == filemode.Dir
te := treeEntry{Name: e.Name, IsDir: isDir}
if isDir {
te.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(child)
} else {
te.URL = base + "/blob/" + escapePath(ref) + "/" + escapePath(child)
if b, err := repo.BlobObject(e.Hash); err == nil {
te.Size = humanSize(b.Size)
}
}
out = append(out, te)
}
// Directories first, then by name.
sortTreeEntries(out)
return out
}
func sortTreeEntries(es []treeEntry) {
// Simple insertion sort keeps dirs first then name (lists are small).
for i := 1; i < len(es); i++ {
for j := i; j > 0 && less(es[j], es[j-1]); j-- {
es[j], es[j-1] = es[j-1], es[j]
}
}
}
func less(a, b treeEntry) bool {
if a.IsDir != b.IsDir {
return a.IsDir
}
return a.Name < b.Name
}
func buildCrumbs(base, ref, sub string, dirLinks bool) []crumb {
crumbs := []crumb{{Name: ref, URL: base + "/tree/" + escapePath(ref)}}
if sub == "" {
return crumbs
}
parts := strings.Split(sub, "/")
acc := ""
for i, p := range parts {
if acc == "" {
acc = p
} else {
acc = acc + "/" + p
}
c := crumb{Name: p}
last := i == len(parts)-1
if !last || dirLinks {
c.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(acc)
}
if last {
c.URL = "" // current location, not a link
}
crumbs = append(crumbs, c)
}
return crumbs
}
func refLinks(refs []gitrepo.RefName, base, verb string) []refLink {
out := make([]refLink, 0, len(refs))
for _, r := range refs {
out = append(out, refLink{
Name: r.Name,
Short: r.Hash.String()[:8],
URL: base + "/" + verb + "/" + escapePath(r.Name),
LogURL: base + "/log/" + escapePath(r.Name),
})
}
return out
}
func findReadme(tree *object.Tree) string {
for _, name := range []string{"README.md", "readme.md", "README.markdown", "README"} {
if _, err := tree.File(name); err == nil {
return name
}
}
return ""
}
// rebaseRepoHTML rewrites relative links/images in rendered repo markdown so
// they resolve to the source file's location in the repo (blob view for links,
// raw for images) at ref, given the markdown file's directory dir within the
// tree ("" or "." for the repo root).
func rebaseRepoHTML(h template.HTML, base, ref, dir string) template.HTML {
prefix := escapePath(ref) + "/"
if dir != "" && dir != "." {
prefix += escapePath(dir) + "/"
}
return render.Rebase(h, base+"/blob/"+prefix, base+"/raw/"+prefix)
}
// escapePath URL-escapes each slash-separated segment, preserving slashes.
func escapePath(p string) string {
parts := strings.Split(p, "/")
for i, s := range parts {
parts[i] = url.PathEscape(s)
}
return strings.Join(parts, "/")
}
func subjectOf(msg string) string {
if i := strings.IndexByte(msg, '\n'); i >= 0 {
return strings.TrimSpace(msg[:i])
}
return strings.TrimSpace(msg)
}
func bodyOf(msg string) string {
if i := strings.IndexByte(msg, '\n'); i >= 0 {
return strings.TrimSpace(msg[i+1:])
}
return ""
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + " B"
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + string("KMGT"[exp]) + "iB"
}
internal/server/git_test.go
package server
import (
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"gitweb/internal/store"
)
// newTestServer builds a Server over a LocalStore namespace containing a bare
// repo at u@d.com/demo.git and a plain dir u@d.com/docs.
func newTestServer(t *testing.T) (*httptest.Server, func()) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
ns := t.TempDir()
if err := os.MkdirAll(filepath.Join(ns, "u@d.com", "docs"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(ns, "u@d.com", "docs", "note.txt"), []byte("hi"), 0o644)
// Browsing is gated by Access files; make this whole namespace world-readable
// so the anonymous browse tests below succeed.
os.WriteFile(filepath.Join(ns, "u@d.com", "Access"), []byte("read,list: all\n*: u@d.com\n"), 0o644)
work := t.TempDir()
git := func(dir string, a ...string) {
t.Helper()
c := exec.Command("git", a...)
c.Dir = dir
c.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@e.com",
"GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@e.com")
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", a, err, out)
}
}
git(work, "init", "-q", "-b", "main")
os.WriteFile(filepath.Join(work, "README.md"), []byte("# Demo\n\nhello\n"), 0o644)
os.WriteFile(filepath.Join(work, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)
+ // An Access file committed into the repo tree, to prove it is hidden there too.
+ os.WriteFile(filepath.Join(work, "Access"), []byte("read,list: all\n*: secret@d.com\n"), 0o644)
git(work, "add", "-A")
git(work, "commit", "-qm", "initial")
git(work, "checkout", "-q", "-b", "feature/x")
os.WriteFile(filepath.Join(work, "main.go"), []byte("package main\n\nfunc main() { _ = 1 }\n"), 0o644)
git(work, "commit", "-qam", "tweak")
git(work, "tag", "v1.0.0")
git(work, "checkout", "-q", "main")
git(work, "clone", "-q", "--bare", work, filepath.Join(ns, "u@d.com", "demo.git"))
web := os.DirFS("../../web")
srv, err := New(Config{Store: store.NewLocalStore(ns), Web: http.FS(web)})
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
return ts, ts.Close
+}
+
+// TestAccessFilesHidden verifies that upspin Access files are never listed or
+// rendered — neither in the plain namespace browse nor in a git repo tree — so
+// the set of users/groups with access is not exposed to browsers.
+func TestAccessFilesHidden(t *testing.T) {
+ ts, done := newTestServer(t)
+ defer done()
+
+ get := func(p string) (int, string) {
+ resp, err := http.Get(ts.URL + p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, string(b)
+ }
+
+ // Namespace listing must not link to the Access file...
+ if code, body := get("/u@d.com/"); code != 200 {
+ t.Fatalf("namespace browse code %d", code)
+ } else if strings.Contains(body, `href="Access"`) {
+ t.Error("namespace listing exposed the Access file")
+ }
+ // ...and its contents must not be servable directly.
+ if code, _ := get("/u@d.com/Access"); code != 404 {
+ t.Errorf("GET /u@d.com/Access = %d, want 404", code)
+ }
+
+ // Git tree listing must not link to a committed Access file...
+ if code, body := get("/u@d.com/demo.git/tree/main/"); code != 200 {
+ t.Fatalf("tree code %d", code)
+ } else if strings.Contains(body, "/blob/main/Access") {
+ t.Error("git tree listing exposed a committed Access file")
+ }
+ // ...and neither blob nor raw view will render it.
+ if code, body := get("/u@d.com/demo.git/blob/main/Access"); code != 404 {
+ t.Errorf("blob Access = %d, want 404", code)
+ } else if strings.Contains(body, "secret@d.com") {
+ t.Error("blob view leaked Access contents")
+ }
+ if code, _ := get("/u@d.com/demo.git/raw/main/Access"); code != 404 {
+ t.Errorf("raw Access = %d, want 404", code)
+ }
}
func TestGitVerbs(t *testing.T) {
ts, done := newTestServer(t)
defer done()
get := func(p string) (int, string) {
resp, err := http.Get(ts.URL + p)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
cases := []struct {
path string
wantCode int
contains []string
}{
{"/u@d.com/docs/", 200, []string{"Index of", "note.txt"}},
{"/u@d.com/demo.git/", 200, []string{"feature/x", "v1.0.0", "README", "Demo"}},
{"/u@d.com/demo.git/refs", 200, []string{"Branches", "Tags", "feature/x", "v1.0.0"}},
{"/u@d.com/demo.git/tree/main/", 200, []string{"main.go", "README.md"}},
{"/u@d.com/demo.git/blob/main/main.go", 200, []string{"chroma", "func"}},
{"/u@d.com/demo.git/blob/feature/x/main.go", 200, []string{"chroma"}},
{"/u@d.com/demo.git/log/feature/x", 200, []string{"tweak", "/commit/"}},
{"/u@d.com/demo.git/bogus", 404, nil},
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
code, body := get(c.path)
if code != c.wantCode {
t.Fatalf("code = %d, want %d", code, c.wantCode)
}
for _, want := range c.contains {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q", want)
}
}
})
}
// Renderer switch: default README.md is highlighted source, with a
// Markdown switch offered; ?render=markdown renders the markdown.
{
_, src := get("/u@d.com/demo.git/blob/main/README.md")
if !strings.Contains(src, "chroma") {
t.Fatal("README.md default view should be highlighted source")
}
if !strings.Contains(src, `href="/u@d.com/demo.git/blob/main/README.md?render=markdown"`) {
t.Fatal("README.md should offer a Markdown switch link")
}
_, md := get("/u@d.com/demo.git/blob/main/README.md?render=markdown")
if !strings.Contains(md, "<h1") {
t.Fatal("?render=markdown should render markdown (expected <h1>)")
}
}
// Non-markdown file offers only the default; force exposes & runs markdown.
{
_, src := get("/u@d.com/demo.git/blob/main/main.go")
if strings.Contains(src, "?render=markdown\"") {
t.Fatal("main.go should not offer markdown without force")
}
if !strings.Contains(src, "force=1") {
t.Fatal("main.go should offer a force toggle")
}
code, _ := get("/u@d.com/demo.git/blob/main/main.go?render=markdown&force=1")
if code != 200 {
t.Fatalf("forced markdown on main.go: code %d", code)
}
}
// CSP header is present on git pages. Scripts are restricted to same-origin
// with no 'unsafe-inline', so markdown-injected inline script cannot run
// (mermaid is served as a same-origin file).
resp, _ := http.Get(ts.URL + "/u@d.com/demo.git/")
resp.Body.Close()
csp := resp.Header.Get("Content-Security-Policy")
if !strings.Contains(csp, "script-src 'self'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") {
t.Fatalf("missing/weak script CSP on git page: %q", csp)
}
// Commit page renders a diff.
_, logBody := get("/u@d.com/demo.git/log/feature/x")
i := strings.Index(logBody, "/u@d.com/demo.git/commit/")
if i < 0 {
t.Fatal("no commit link in log")
}
url := logBody[i : i+len("/u@d.com/demo.git/commit/")+40]
code, body := get(url)
if code != 200 || !strings.Contains(body, "diff-add") || !strings.Contains(body, "diff-del") {
t.Fatalf("commit page: code %d, diff classes present=%v", code,
strings.Contains(body, "diff-add"))
}
}
internal/server/server.go
// Package server is the HTTP layer of the git browser. The upspin path is the
// URL path: GET /<user@domain>/<path...>. If the path resolves inside a bare
// git repository the request is handled by the git browser (summary, tree,
// blob, log, commit, refs); otherwise it falls back to plain upspin namespace
// browsing (directory listings and raw files) so users can drill down to repos.
package server
import (
"bytes"
"embed"
"errors"
"html/template"
"io"
"log"
"mime"
"net/http"
"path"
"strings"
"gitweb/internal/auth"
"gitweb/internal/gitrepo"
"gitweb/internal/render"
"gitweb/internal/store"
)
//go:embed templates/*.html
var tmplFS embed.FS
var pageTmpl = template.Must(template.ParseFS(tmplFS, "templates/page.html"))
// Config configures a Server.
type Config struct {
Store store.WriteStore // read for browse/clone; write for push
Web http.FileSystem // static assets (style.css, landing index.html)
Realm string // HTTP Basic auth realm; defaults to "gitweb"
// Vault, if non-nil, verifies vault-issued SSO bearer tokens (the passkey
// login path). When nil, only PATs are accepted.
Vault *auth.VaultVerifier
}
// Server serves upspin-stored git repositories over HTTP: a browse UI, plus the
// git smart-HTTP protocol for clone and push. Access is gated by upspin Access
// files; private repos require Basic-auth credentials (a vault SSO token or a
// personal access token).
type Server struct {
store store.Store
web http.FileSystem
renderer *render.Renderer
renderers *render.Registry
repos *gitrepo.Cache
authz *auth.Authorizer
pat *auth.PATVerifier
vault *auth.VaultVerifier // may be nil
pusher *gitrepo.Pusher
realm string
chroma []byte // generated highlighter stylesheet
landing []byte // /index.html, served at "/"
}
// New builds a Server.
func New(c Config) (*Server, error) {
css, err := render.CSS()
if err != nil {
return nil, err
}
rnd := render.New()
repos := gitrepo.NewCache(c.Store)
realm := c.Realm
if realm == "" {
realm = "gitweb"
}
s := &Server{
store: c.Store,
web: c.Web,
renderer: rnd,
renderers: render.NewRegistry(rnd),
repos: repos,
authz: auth.NewAuthorizer(c.Store),
pat: auth.NewPATVerifier(c.Store),
vault: c.Vault,
pusher: gitrepo.NewPusher(c.Store, repos),
realm: realm,
chroma: css,
}
if f, err := c.Web.Open("index.html"); err == nil {
s.landing, _ = io.ReadAll(f)
f.Close()
}
return s, nil
}
// Handler returns the HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /_assets/chroma.css", s.handleChromaCSS)
mux.Handle("GET /_assets/", http.StripPrefix("/_assets/", http.FileServer(s.web)))
// The catch-all accepts every method: git smart-HTTP needs POST, and
// ServeMux cannot match the "/info/refs", "/git-upload-pack",
// "/git-receive-pack" suffixes, so handleRoot dispatches by suffix itself.
mux.HandleFunc("/", s.handleRoot)
return securityHeaders(mux)
}
// handleRoot dispatches between the git smart-HTTP endpoints and the browse UI.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if ep, ok := gitEndpoint(r); ok {
s.handleGitHTTP(w, r, ep)
return
}
if r.Method != http.MethodGet {
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return
}
s.handlePath(w, r)
}
func (s *Server) handleChromaCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(s.chroma)
}
// handlePath dispatches a request whose URL path is an upspin path.
func (s *Server) handlePath(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.URL.Path, "/")
if raw == "" {
s.serveLanding(w)
return
}
// First element is the upspin user; validate before building any path.
user, rest, _ := strings.Cut(raw, "/")
if !store.ValidUser(user) {
s.notFound(w)
return
}
// Clean the remainder; path.Clean collapses any ".." so it cannot escape
// the user's tree. clean always begins with "/".
clean := path.Clean("/" + rest)
upath := user + clean // e.g. "u@d.com/proj.git"; root is "u@d.com/"
// Identify the caller (anonymous if no/blank credentials) before deciding
// what they may see.
authUser, ok := s.authenticate(w, r)
if !ok {
return // 401 already written
}
// If the path lies inside a bare git repo, hand off to the git browser;
// otherwise fall back to plain upspin namespace browsing.
repoPath, gitRest, isRepo, err := gitrepo.Locate(s.store, user, clean)
if err != nil {
s.storeErr(w, err)
return
}
// Gate the browse UI by the same public/private rule as clone: anonymous
// users see only world-readable paths; others must be granted read.
resource := upath
if isRepo {
resource = repoPath
}
allowed, err := s.authz.CanRead(authUser, resource)
if err != nil {
s.serverErr(w, err)
return
}
if !allowed {
s.denyRead(w, authUser)
return
}
if isRepo {
s.serveGit(w, r, repoPath, gitRest)
return
}
isDir, err := s.store.Stat(upath)
if err != nil {
s.storeErr(w, err)
return
}
if isDir {
s.serveDir(w, r, user, clean, upath)
return
}
s.serveFile(w, upath, clean)
}
// serveDir renders a directory: README.md/index.md if present, else a listing.
func (s *Server) serveDir(w http.ResponseWriter, r *http.Request, user, clean, upath string) {
// A trailing slash is required so relative links resolve against the dir.
if !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
entries, err := s.store.List(upath)
if err != nil {
s.storeErr(w, err)
return
}
// Prefer an index document if the directory has one.
for _, name := range []string{"index.md", "README.md", "readme.md"} {
if hasEntry(entries, name) {
s.serveFile(w, strings.TrimRight(upath, "/")+"/"+name, clean)
return
}
}
var body bytes.Buffer
body.WriteString(`<h1>Index of `)
body.WriteString(template.HTMLEscapeString(displayPath(user, clean)))
body.WriteString("</h1>\n<ul class=\"dir\">\n")
if clean != "/" {
body.WriteString(`<li><a href="../">../</a></li>` + "\n")
}
for _, e := range entries {
+ if isAccessFile(e.Name) {
+ continue // never expose upspin ACLs in a listing
+ }
name := e.Name
if e.IsDir {
name += "/"
}
esc := template.HTMLEscapeString(name)
body.WriteString(`<li><a href="` + esc + `">` + esc + "</a></li>\n")
}
body.WriteString("</ul>\n")
s.renderPage(w, displayPath(user, clean), template.HTML(body.String()))
}
// serveFile renders a markdown file, or serves any other file raw.
func (s *Server) serveFile(w http.ResponseWriter, upath, clean string) {
+ // Never serve an upspin Access file's contents (its user/group list),
+ // even when requested directly by path.
+ if isAccessFile(path.Base(upath)) {
+ s.notFound(w)
+ return
+ }
data, err := s.store.Get(upath)
if err != nil {
s.storeErr(w, err)
return
}
if isMarkdown(upath) {
html, err := s.renderer.ToHTML(data)
if err != nil {
s.serverErr(w, err)
return
}
s.renderPage(w, path.Base(clean), template.HTML(html))
return
}
// Raw asset (images referenced by markdown, etc.).
ctype := mime.TypeByExtension(path.Ext(upath))
if ctype == "" {
ctype = http.DetectContentType(data)
}
w.Header().Set("Content-Type", ctype)
w.Write(data)
}
func (s *Server) renderPage(w http.ResponseWriter, title string, body template.HTML) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := pageTmpl.Execute(w, struct {
Title string
Body template.HTML
}{Title: title, Body: body})
if err != nil {
log.Printf("gitweb: template: %v", err)
}
}
func (s *Server) serveLanding(w http.ResponseWriter) {
if s.landing == nil {
s.notFound(w)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.landing)
}
// --- errors ---
func (s *Server) storeErr(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrNotFound) {
s.notFound(w)
return
}
s.serverErr(w, err)
}
func (s *Server) notFound(w http.ResponseWriter) {
http.Error(w, "404 not found", http.StatusNotFound)
}
// denyRead responds to a caller who may not read the requested resource. An
// anonymous caller gets a Basic-auth challenge (so a git/browser client can
// retry with credentials); an already-authenticated caller gets a 404, which
// does not reveal whether the resource exists.
func (s *Server) denyRead(w http.ResponseWriter, authUser string) {
if authUser == "" {
s.challenge(w)
return
}
s.notFound(w)
}
// challenge writes a 401 with a Basic-auth challenge. The body is deliberately
// vague so anonymous callers cannot distinguish "private" from "absent".
func (s *Server) challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="`+s.realm+`", charset="UTF-8"`)
http.Error(w, "not found, or authentication required", http.StatusUnauthorized)
}
func (s *Server) serverErr(w http.ResponseWriter, err error) {
log.Printf("gitweb: 500: %v", err)
http.Error(w, "500 internal server error", http.StatusInternalServerError)
}
// --- helpers ---
func isMarkdown(p string) bool {
switch strings.ToLower(path.Ext(p)) {
case ".md", ".markdown":
return true
}
return false
}
+
+// isAccessFile reports whether name is an upspin Access file. gitweb never lists
+// or renders these: their contents enumerate the upspin users and groups granted
+// access, which must not be exposed to browsers.
+func isAccessFile(name string) bool { return name == "Access" }
func hasEntry(entries []store.Entry, name string) bool {
for _, e := range entries {
if !e.IsDir && e.Name == name {
return true
}
}
return false
}
// displayPath renders the upspin path for a heading: "u@d.com/docs/".
func displayPath(user, clean string) string {
if clean == "/" {
return user + "/"
}
return user + clean
}
func securityHeaders(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
// script-src 'self': the only scripts are our own, same-origin mermaid
// files (mermaid-init.js and the vendored bundle). No 'unsafe-inline', so
// script injected via rendered markdown cannot execute. style-src allows
// 'unsafe-inline' because mermaid injects diagram styles at render time;
// this permits inline styles but not scripts. Markdown may embed external
// https images; everything else is same-origin.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
h.ServeHTTP(w, r)
})
}