internal/httpd/pages.go

a869e55cedfc2d279bbaba2e2a0a02eb1114d9b7
gitbay/internal/httpd/pages.go history · blame · raw

154 lines · 5484 bytes

  1package httpd
  2
  3import (
  4	"mime"
  5	"net"
  6	"net/http"
  7	"net/url"
  8	"path"
  9	"strings"
 10
 11	"gitbay.org/gitbay/internal/control"
 12	"gitbay.org/gitbay/internal/gitutil"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16// PagesBranch is the branch a repo publishes as its static site.
 17const PagesBranch = "refs/heads/pages"
 18
 19// pagesRouter sends <owner>.<domain> requests to the pages server and
 20// everything else to the forge. Pages responses deliberately bypass the
 21// forge's security headers: sites need their own scripts, and they run on
 22// a separate origin where the forge has no cookies to protect.
 23func (s *Server) pagesRouter(forge http.Handler) http.Handler {
 24	domain := s.cfg.Pages.Domain
 25	siteHost := s.cfg.SiteHost()
 26	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 27		host := hostOnly(r.Host)
 28		if domain != "" && (host == domain || strings.HasSuffix(host, "."+domain)) {
 29			s.servePage(w, r, host)
 30			return
 31		}
 32		// Any other foreign host may be a custom pages domain.
 33		if host != siteHost && host != "" {
 34			if repo, err := s.st.PageDomainRepo(host); err == nil && repo.Visibility == "public" {
 35				if r.Method != http.MethodGet && r.Method != http.MethodHead {
 36					http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 37					return
 38				}
 39				s.servePageFile(w, r, repo, strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/"))
 40				return
 41			}
 42		}
 43		forge.ServeHTTP(w, r)
 44	})
 45}
 46
 47func hostOnly(hostport string) string {
 48	if h, _, err := net.SplitHostPort(hostport); err == nil {
 49		return h
 50	}
 51	return hostport
 52}
 53
 54// servePage maps <owner>.<domain>/<repo>/<path> to the repo's pages
 55// branch, and <owner>.<domain>/<path> to the owner's repo named "pages".
 56// Private repos and missing branches are plain 404s.
 57func (s *Server) servePage(w http.ResponseWriter, r *http.Request, host string) {
 58	if r.Method != http.MethodGet && r.Method != http.MethodHead {
 59		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 60		return
 61	}
 62	// The apex has no site of its own; send visitors to the forge.
 63	if host == s.cfg.Pages.Domain {
 64		http.Redirect(w, r, s.cfg.Server.SiteURL, http.StatusFound)
 65		return
 66	}
 67	owner, ok := strings.CutSuffix(host, "."+s.cfg.Pages.Domain)
 68	if !ok || owner == "" || strings.Contains(owner, ".") {
 69		http.NotFound(w, r)
 70		return
 71	}
 72	reqPath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
 73
 74	// A first segment naming a public repo with a pages branch wins;
 75	// everything else falls through to the owner's "pages" repo.
 76	if seg, rest, _ := strings.Cut(reqPath, "/"); seg != "" && seg != "pages" {
 77		if repo, err := s.st.RepoByPath(owner + "/" + seg); err == nil && repo.Visibility == "public" {
 78			dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
 79			if _, err := gitutil.ResolveRef(dir, PagesBranch); err == nil {
 80				if rest == "" && !strings.HasSuffix(r.URL.Path, "/") {
 81					http.Redirect(w, r, pageRedirectTarget(r.URL.Path), http.StatusMovedPermanently)
 82					return
 83				}
 84				s.servePageFile(w, r, repo, rest)
 85				return
 86			}
 87		}
 88	}
 89	repo, err := s.st.RepoByPath(owner + "/pages")
 90	if err != nil || repo.Visibility != "public" {
 91		http.NotFound(w, r)
 92		return
 93	}
 94	s.servePageFile(w, r, repo, reqPath)
 95}
 96
 97func (s *Server) servePageFile(w http.ResponseWriter, r *http.Request, repo store.Repo, filePath string) {
 98	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
 99	if filePath == "" {
100		filePath = "index.html"
101	}
102	data, err := gitutil.ReadBlob(dir, PagesBranch, filePath, s.cfg.Limits.MaxBlobBytes)
103	if err != nil {
104		// A directory path serves its index.html; /guide -> /guide/ keeps
105		// relative links working.
106		if idx, ierr := gitutil.ReadBlob(dir, PagesBranch, filePath+"/index.html", s.cfg.Limits.MaxBlobBytes); ierr == nil {
107			if !strings.HasSuffix(r.URL.Path, "/") {
108				http.Redirect(w, r, pageRedirectTarget(r.URL.Path), http.StatusMovedPermanently)
109				return
110			}
111			data, filePath = idx, filePath+"/index.html"
112		} else {
113			http.NotFound(w, r)
114			return
115		}
116	}
117	ct := mime.TypeByExtension(path.Ext(filePath))
118	if ct == "" {
119		ct = http.DetectContentType(data)
120	}
121	w.Header().Set("Content-Type", ct)
122	w.Header().Set("X-Content-Type-Options", "nosniff")
123	w.Header().Set("Cache-Control", "public, max-age=60")
124	if r.Method == http.MethodHead {
125		return
126	}
127	w.Write(data)
128}
129
130// pageRedirectTarget is the "add a trailing slash" destination for a
131// directory URL, normalised so it cannot leave the site.
132//
133// The raw request path is not safe to redirect to. net/url keeps a
134// leading "//", and Go emits `Location: //evil.example/` unchanged, which
135// a browser reads as protocol-relative and follows to another origin.
136// Reaching it needed content named like a host under the subdomain's
137// owner — repository names permit dots — so it was narrow rather than
138// impossible (gosecurity:S5146, #153).
139//
140// path.Clean collapses the leading slashes and resolves any "..", and the
141// result is re-rooted, so the destination is always one same-origin
142// absolute path.
143func pageRedirectTarget(reqPath string) string {
144	clean := path.Clean("/" + reqPath)
145	if clean == "/" {
146		return "/"
147	}
148	// Encoding through url.URL rather than concatenating: a backslash is
149	// not a path separator here but browsers following the WHATWG URL
150	// rules treat one as a slash, so "/\\evil.example/" would be another
151	// way to say "//evil.example/". Escaping settles that, and every other
152	// byte a path can hold, without a denylist.
153	return (&url.URL{Path: clean + "/"}).String()
154}