Commit 617b05d09b
Verified · cmc ci/build: success
cmd/gitbayd/main.go +29 −2
| @@ -170,10 +170,31 @@ func serveCmd() *cobra.Command { | ||
| 170 | 170 | errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile) |
| 171 | 171 | case "acme": |
| 172 | 172 | host := cfg.SiteHost() |
| 173 | stripPort := func(hp string) string { | |
| 174 | if h, _, err := net.SplitHostPort(hp); err == nil { | |
| 175 | return h | |
| 176 | } | |
| 177 | return hp | |
| 178 | } | |
| 179 | // Beyond the site host, allow <owner>.<pages domain> | |
| 180 | // for owners that exist — certs come on demand per | |
| 181 | // subdomain, no wildcard needed. | |
| 182 | hostPolicy := func(ctx context.Context, h string) error { | |
| 183 | if h == host { | |
| 184 | return nil | |
| 185 | } | |
| 186 | if pd := cfg.Pages.Domain; pd != "" { | |
| 187 | if owner, ok := strings.CutSuffix(h, "."+pd); ok && | |
| 188 | !strings.Contains(owner, ".") && st.OwnerExists(owner) { | |
| 189 | return nil | |
| 190 | } | |
| 191 | } | |
| 192 | return fmt.Errorf("host %q not served here", h) | |
| 193 | } | |
| 173 | 194 | m := &autocert.Manager{ |
| 174 | 195 | Prompt: autocert.AcceptTOS, |
| 175 | 196 | Cache: autocert.DirCache(filepath.Join(cfg.Server.Root, "acme")), |
| 176 | HostPolicy: autocert.HostWhitelist(host), | |
| 197 | HostPolicy: hostPolicy, | |
| 177 | 198 | Email: cfg.HTTP.ACMEEmail, |
| 178 | 199 | } |
| 179 | 200 | // TLS-ALPN-01 rides the HTTPS port itself. The optional |
| @@ -181,7 +202,13 @@ func serveCmd() *cobra.Command { | ||
| 181 | 202 | // it (port 80 taken, no privileges) is not fatal. |
| 182 | 203 | if addr := cfg.HTTP.ACMEHTTPAddr; addr != "" && addr != "off" { |
| 183 | 204 | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 184 | http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently) | |
| 205 | // Pages hosts redirect to themselves, not the | |
| 206 | // forge host. | |
| 207 | target := host | |
| 208 | if hostPolicy(r.Context(), stripPort(r.Host)) == nil { | |
| 209 | target = stripPort(r.Host) | |
| 210 | } | |
| 211 | http.Redirect(w, r, "https://"+target+r.URL.RequestURI(), http.StatusMovedPermanently) | |
| 185 | 212 | }) |
| 186 | 213 | go func() { |
| 187 | 214 | slog.Info("acme http listening", "addr", addr) |
e2e/pages_test.go added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "io" | |
| 6 | "net/http" | |
| 7 | "os" | |
| 8 | "path/filepath" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | ) | |
| 12 | ||
| 13 | // pagesGet fetches a path with a pages Host header against the instance. | |
| 14 | func (i *instance) pagesGet(t *testing.T, host, path string) (*http.Response, string) { | |
| 15 | t.Helper() | |
| 16 | req, err := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d%s", i.httpPort, path), nil) | |
| 17 | if err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | req.Host = host | |
| 21 | resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { | |
| 22 | return http.ErrUseLastResponse | |
| 23 | }}).Do(req) | |
| 24 | if err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | defer resp.Body.Close() | |
| 28 | body, _ := io.ReadAll(resp.Body) | |
| 29 | return resp, string(body) | |
| 30 | } | |
| 31 | ||
| 32 | func TestPages(t *testing.T) { | |
| 33 | inst := startInstanceWith(t, "[pages]\ndomain = \"p.test\"\n") | |
| 34 | aliceKey := inst.newKey(t, "alice") | |
| 35 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 36 | ||
| 37 | env := inst.gitEnv(aliceKey) | |
| 38 | pushPages := func(repo string, files map[string]string) { | |
| 39 | t.Helper() | |
| 40 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", repo); code != 0 { | |
| 41 | t.Fatalf("create %s: %s", repo, errOut) | |
| 42 | } | |
| 43 | work := t.TempDir() | |
| 44 | mustGit(t, work, env, "clone", inst.sshURL(repo), "w") | |
| 45 | dir := filepath.Join(work, "w") | |
| 46 | for name, content := range files { | |
| 47 | os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755) | |
| 48 | os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) | |
| 49 | } | |
| 50 | mustGit(t, dir, env, "checkout", "-q", "-b", "pages") | |
| 51 | mustGit(t, dir, env, "add", ".") | |
| 52 | mustGit(t, dir, env, "commit", "-q", "-m", "site") | |
| 53 | mustGit(t, dir, env, "push", "-q", "origin", "pages") | |
| 54 | } | |
| 55 | ||
| 56 | pushPages("alice/pages", map[string]string{ | |
| 57 | "index.html": "<h1>alice root</h1><script>x=1</script>", | |
| 58 | }) | |
| 59 | pushPages("alice/site", map[string]string{ | |
| 60 | "index.html": "<h1>project site</h1>", | |
| 61 | "style.css": "body{color:red}", | |
| 62 | "guide/index.html": "<h1>guide</h1>", | |
| 63 | }) | |
| 64 | ||
| 65 | // Root site from the "pages" repo, scripts intact, no forge CSP. | |
| 66 | resp, body := inst.pagesGet(t, "alice.p.test", "/") | |
| 67 | if resp.StatusCode != 200 || !strings.Contains(body, "alice root") || !strings.Contains(body, "<script>") { | |
| 68 | t.Fatalf("root site: %d\n%s", resp.StatusCode, body) | |
| 69 | } | |
| 70 | if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { | |
| 71 | t.Fatalf("root content-type: %s", ct) | |
| 72 | } | |
| 73 | if resp.Header.Get("Content-Security-Policy") != "" { | |
| 74 | t.Fatal("forge CSP leaked onto a pages response") | |
| 75 | } | |
| 76 | ||
| 77 | // Project site under /<repo>/, with a redirect adding the slash. | |
| 78 | if resp, _ = inst.pagesGet(t, "alice.p.test", "/site"); resp.StatusCode != 301 { | |
| 79 | t.Fatalf("bare project path: %d", resp.StatusCode) | |
| 80 | } | |
| 81 | if resp, body = inst.pagesGet(t, "alice.p.test", "/site/"); !strings.Contains(body, "project site") { | |
| 82 | t.Fatalf("project index: %d\n%s", resp.StatusCode, body) | |
| 83 | } | |
| 84 | if resp, _ = inst.pagesGet(t, "alice.p.test", "/site/style.css"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/css") { | |
| 85 | t.Fatalf("css content-type: %s", resp.Header.Get("Content-Type")) | |
| 86 | } | |
| 87 | // Directory paths inside a site serve their index and gain a slash. | |
| 88 | if resp, _ = inst.pagesGet(t, "alice.p.test", "/site/guide"); resp.StatusCode != 301 { | |
| 89 | t.Fatalf("dir redirect: %d", resp.StatusCode) | |
| 90 | } | |
| 91 | if _, body = inst.pagesGet(t, "alice.p.test", "/site/guide/"); !strings.Contains(body, "guide") { | |
| 92 | t.Fatalf("dir index:\n%s", body) | |
| 93 | } | |
| 94 | ||
| 95 | // Private repos never serve pages; unknown owners and the apex 404. | |
| 96 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { | |
| 97 | t.Fatalf("create secret: %s", errOut) | |
| 98 | } | |
| 99 | work := t.TempDir() | |
| 100 | mustGit(t, work, env, "clone", inst.sshURL("alice/secret"), "w") | |
| 101 | sdir := filepath.Join(work, "w") | |
| 102 | os.WriteFile(filepath.Join(sdir, "index.html"), []byte("hidden"), 0o644) | |
| 103 | mustGit(t, sdir, env, "checkout", "-q", "-b", "pages") | |
| 104 | mustGit(t, sdir, env, "add", ".") | |
| 105 | mustGit(t, sdir, env, "commit", "-q", "-m", "s") | |
| 106 | mustGit(t, sdir, env, "push", "-q", "origin", "pages") | |
| 107 | for _, tc := range []struct{ host, path string }{ | |
| 108 | {"alice.p.test", "/secret/"}, | |
| 109 | {"bob.p.test", "/"}, | |
| 110 | {"p.test", "/"}, | |
| 111 | } { | |
| 112 | if resp, _ = inst.pagesGet(t, tc.host, tc.path); resp.StatusCode != 404 { | |
| 113 | t.Fatalf("%s%s: %d, want 404", tc.host, tc.path, resp.StatusCode) | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | // The forge itself still answers on its own host. | |
| 118 | if status, _ := inst.get(t, "/explore"); status != 200 { | |
| 119 | t.Fatalf("forge routes broken: %d", status) | |
| 120 | } | |
| 121 | } | |
internal/config/config.go +16
| @@ -21,6 +21,7 @@ type Config struct { | ||
| 21 | 21 | Registration Registration `toml:"registration"` |
| 22 | 22 | API API `toml:"api"` |
| 23 | 23 | Webhooks Webhooks `toml:"webhooks"` |
| 24 | Pages Pages `toml:"pages"` | |
| 24 | 25 | Limits Limits `toml:"limits"` |
| 25 | 26 | Mail Mail `toml:"mail"` |
| 26 | 27 | Mirrors Mirrors `toml:"mirrors"` |
| @@ -71,6 +72,13 @@ type Registration struct { | ||
| 71 | 72 | Mode string `toml:"mode"` // closed | invite | open |
| 72 | 73 | } |
| 73 | 74 | |
| 75 | // Pages serves each public repo's `pages` branch as a static site on | |
| 76 | // <owner>.<domain> — a separate origin, so page-authored scripts never run | |
| 77 | // on the forge's own host. Empty domain disables the feature. | |
| 78 | type Pages struct { | |
| 79 | Domain string `toml:"domain"` | |
| 80 | } | |
| 81 | ||
| 74 | 82 | // API controls the HTTPS/JSON control-plane API (bearer tokens minted over |
| 75 | 83 | // SSH). Off by default: an instance that never enables it has no |
| 76 | 84 | // credential-bearing HTTP surface at all. |
| @@ -155,6 +163,14 @@ func (c Config) Validate() error { | ||
| 155 | 163 | if c.Server.Root == "" { |
| 156 | 164 | errs = append(errs, errors.New("server.root is required")) |
| 157 | 165 | } |
| 166 | if d := c.Pages.Domain; d != "" { | |
| 167 | if d == c.SiteHost() { | |
| 168 | errs = append(errs, errors.New("pages.domain must differ from the site host: pages serve repo-authored scripts, which must not run on the forge's origin")) | |
| 169 | } | |
| 170 | if strings.HasSuffix(c.SiteHost(), "."+d) { | |
| 171 | errs = append(errs, errors.New("pages.domain must not be a parent of the site host")) | |
| 172 | } | |
| 173 | } | |
| 158 | 174 | if c.Server.SiteURL == "" { |
| 159 | 175 | errs = append(errs, errors.New("server.site_url is required")) |
| 160 | 176 | } |
internal/httpd/pages.go added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "mime" | |
| 5 | "net" | |
| 6 | "net/http" | |
| 7 | "path" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/control" | |
| 11 | "gitbay.org/gitbay/internal/gitutil" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | // PagesBranch is the branch a repo publishes as its static site. | |
| 16 | const PagesBranch = "refs/heads/pages" | |
| 17 | ||
| 18 | // pagesRouter sends <owner>.<domain> requests to the pages server and | |
| 19 | // everything else to the forge. Pages responses deliberately bypass the | |
| 20 | // forge's security headers: sites need their own scripts, and they run on | |
| 21 | // a separate origin where the forge has no cookies to protect. | |
| 22 | func (s *Server) pagesRouter(forge http.Handler) http.Handler { | |
| 23 | domain := s.cfg.Pages.Domain | |
| 24 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 25 | host := hostOnly(r.Host) | |
| 26 | if host == domain || strings.HasSuffix(host, "."+domain) { | |
| 27 | s.servePage(w, r, host) | |
| 28 | return | |
| 29 | } | |
| 30 | forge.ServeHTTP(w, r) | |
| 31 | }) | |
| 32 | } | |
| 33 | ||
| 34 | func hostOnly(hostport string) string { | |
| 35 | if h, _, err := net.SplitHostPort(hostport); err == nil { | |
| 36 | return h | |
| 37 | } | |
| 38 | return hostport | |
| 39 | } | |
| 40 | ||
| 41 | // servePage maps <owner>.<domain>/<repo>/<path> to the repo's pages | |
| 42 | // branch, and <owner>.<domain>/<path> to the owner's repo named "pages". | |
| 43 | // Private repos and missing branches are plain 404s. | |
| 44 | func (s *Server) servePage(w http.ResponseWriter, r *http.Request, host string) { | |
| 45 | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |
| 46 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |
| 47 | return | |
| 48 | } | |
| 49 | owner, ok := strings.CutSuffix(host, "."+s.cfg.Pages.Domain) | |
| 50 | if !ok || owner == "" || strings.Contains(owner, ".") { | |
| 51 | http.NotFound(w, r) | |
| 52 | return | |
| 53 | } | |
| 54 | reqPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") | |
| 55 | ||
| 56 | // A first segment naming a public repo with a pages branch wins; | |
| 57 | // everything else falls through to the owner's "pages" repo. | |
| 58 | if seg, rest, _ := strings.Cut(reqPath, "/"); seg != "" && seg != "pages" { | |
| 59 | if repo, err := s.st.RepoByPath(owner + "/" + seg); err == nil && repo.Visibility == "public" { | |
| 60 | dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 61 | if _, err := gitutil.ResolveRef(dir, PagesBranch); err == nil { | |
| 62 | if rest == "" && !strings.HasSuffix(r.URL.Path, "/") { | |
| 63 | http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) | |
| 64 | return | |
| 65 | } | |
| 66 | s.servePageFile(w, r, repo, rest) | |
| 67 | return | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | repo, err := s.st.RepoByPath(owner + "/pages") | |
| 72 | if err != nil || repo.Visibility != "public" { | |
| 73 | http.NotFound(w, r) | |
| 74 | return | |
| 75 | } | |
| 76 | s.servePageFile(w, r, repo, reqPath) | |
| 77 | } | |
| 78 | ||
| 79 | func (s *Server) servePageFile(w http.ResponseWriter, r *http.Request, repo store.Repo, filePath string) { | |
| 80 | dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 81 | if filePath == "" { | |
| 82 | filePath = "index.html" | |
| 83 | } | |
| 84 | data, err := gitutil.ReadBlob(dir, PagesBranch, filePath, s.cfg.Limits.MaxBlobBytes) | |
| 85 | if err != nil { | |
| 86 | // A directory path serves its index.html; /guide -> /guide/ keeps | |
| 87 | // relative links working. | |
| 88 | if idx, ierr := gitutil.ReadBlob(dir, PagesBranch, filePath+"/index.html", s.cfg.Limits.MaxBlobBytes); ierr == nil { | |
| 89 | if !strings.HasSuffix(r.URL.Path, "/") { | |
| 90 | http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) | |
| 91 | return | |
| 92 | } | |
| 93 | data, filePath = idx, filePath+"/index.html" | |
| 94 | } else { | |
| 95 | http.NotFound(w, r) | |
| 96 | return | |
| 97 | } | |
| 98 | } | |
| 99 | ct := mime.TypeByExtension(path.Ext(filePath)) | |
| 100 | if ct == "" { | |
| 101 | ct = http.DetectContentType(data) | |
| 102 | } | |
| 103 | w.Header().Set("Content-Type", ct) | |
| 104 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 105 | w.Header().Set("Cache-Control", "public, max-age=60") | |
| 106 | if r.Method == http.MethodHead { | |
| 107 | return | |
| 108 | } | |
| 109 | w.Write(data) | |
| 110 | } | |
internal/httpd/routes.go +5 −1
| @@ -122,7 +122,11 @@ func (s *Server) Handler() http.Handler { | ||
| 122 | 122 | if len(s.cfg.GoImport) > 0 { |
| 123 | 123 | h = s.goImportHandler(mux) |
| 124 | 124 | } |
| 125 | return s.securityHeaders(h) | |
| 125 | h = s.securityHeaders(h) | |
| 126 | if s.cfg.Pages.Domain != "" { | |
| 127 | h = s.pagesRouter(h) | |
| 128 | } | |
| 129 | return h | |
| 126 | 130 | } |
| 127 | 131 | |
| 128 | 132 | // securityHeaders sets defensive response headers on every reply. The CSP |
internal/store/users.go +9
| @@ -46,6 +46,15 @@ func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { | ||
| 46 | 46 | return res.LastInsertId() |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | // OwnerExists reports whether a user or org owns the name — the ACME host | |
| 50 | // policy check for pages subdomains. | |
| 51 | func (s *Store) OwnerExists(name string) bool { | |
| 52 | var n int | |
| 53 | s.DB.QueryRow(`SELECT (SELECT COUNT(*) FROM users WHERE username = ?1) | |
| 54 | + (SELECT COUNT(*) FROM orgs WHERE name = ?1)`, name).Scan(&n) | |
| 55 | return n > 0 | |
| 56 | } | |
| 57 | ||
| 49 | 58 | func (s *Store) UserByUsername(name string) (User, error) { |
| 50 | 59 | var u User |
| 51 | 60 | var admin, pending, disabled int |