Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Harden the web transport and bound the pages that run git

701232cf37157ff392e3b35a3279735d72e585d4 · Keenan Nemetz · 12d ago · parent 6bfc4294

Harden the web transport and bound the pages that run git

Generated clone and LFS download links were derived from the request's
Host header, with X-Forwarded-Proto believed from anyone. Those links are
copied out of a page and pasted into a terminal, and the LFS hrefs are
followed by a client, so a forged Host chose where somebody else's
download went.

Links now use public_url when it is configured, which is how a production
instance is meant to be set up; without it the request origin is all there
is, and forwarded headers are honoured only when git_http_client_ip_header
is set, because that is the instance's statement that a proxy it trusts
rewrites them. Site links follow the forge's scheme instead of always
saying http, so an HTTPS forge does not link to a mixed-content site.

The security headers now close the rest of the surface: object-src is
none, inline styles are no longer allowed (no template or generated
markup contains one), and Permissions-Policy, COOP and CORP are set. The
CSRF check gained a second lock: when the browser says where a request
came from, that origin must be this instance, on form posts and on the
JSON ceremony endpoints alike. A missing Origin and Referer is not
evidence, so only a request that names somewhere else is refused.

The pages that shell out to git are bounded. Search, compare, blame, file
history, commit history and both archive formats take a slot from a pool
of eight and run under a 30-second deadline, which is attached to the git
handle so the process is killed rather than left running after the reader
has gone. Past a five-second wait for a slot the page answers 503 with
Retry-After. Plain tar now takes the archive slot too: it is cheaper than
gzip, not free, and both stream for as long as the client reads.

Tests: clone URLs ignore a forged Host when public_url is set and honour
forwarded headers only behind a declared proxy; LFS hrefs likewise; site
links follow the scheme; the CSP has no unsafe-inline and names
object-src; a saturated git pool answers 503 with Retry-After; a
cross-origin post is refused with a valid CSRF token, end to end; the
JSON endpoints refuse a foreign origin.

Docs: public_url now explains that it decides generated links and that
forwarded headers are only trusted with the proxy header set; Sites gains
a same-site risk section recommending a separate registrable domain when
sites are not trusted.

server/web/codebrowser.go +65−14

@@ -110,12 +110,10 @@ func (w *Web) codeShell(r *http.Request, m *repo.Meta, ref, commit, active strin
110 110 Name: m.Name,
111 111 Ref: ref,
112 112 Commit: commit,
113 CloneURL: cloneURL(r, m.Name),
113 CloneURL: w.cloneURL(r, m.Name),
114 114 SSHURL: sshURL(w.Cfg.SSHAddr, m.Name),
115 115 }
116 if w.Cfg.PagesDomain != "" {
117 v.Site = "http://" + m.Name + "." + w.Cfg.PagesDomain
118 }
116 v.Site = w.siteURL(r, m.Name)
119 117 if !full {
120 118 return v
121 119 }
@@ -279,7 +277,13 @@ func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
279 277 return
280 278 }
281 279 ref := r.PathValue("ref")
282 g := m.Git()
280 release, ok := w.gitSlot(rw, r)
281 if !ok {
282 return
283 }
284 defer release()
285 g, cancel := gitRepo(r, m)
286 defer cancel()
283 287 commit, _ := g.RevParse(ref)
284 288 if commit == "" {
285 289 w.notFound(rw, r)
@@ -298,22 +302,69 @@ func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) {
298 302 }{w.page(r, m.Name+" · history"), m.Name, ref, commits}, 0)
299 303 }
300 304
301 // cloneBase is the read-only HTTP transport root for this instance, as the
302 // browser reached it. It is what the landing page shows an anonymous visitor
303 // who wants to clone without an account.
304 func cloneBase(r *http.Request) string {
305 // cloneBase is the read-only HTTP transport root this instance advertises. It is
306 // what a visitor who wants to clone without an account is shown.
307 //
308 // The configured public_url wins whenever it is set: a download or clone URL that
309 // follows the request's Host header is a URL an attacker can choose, and the
310 // browser is not the only consumer — a copied clone URL ends up in somebody's
311 // terminal. Without a public_url the origin the browser used is the only
312 // information available, and forwarded headers are honoured only when the instance
313 // declares a trusted proxy.
314 func (w *Web) cloneBase(r *http.Request) string {
315 return w.canonicalBase(r) + "/git/"
316 }
317
318 func (w *Web) cloneURL(r *http.Request, name string) string {
319 return w.cloneBase(r) + name + ".git"
320 }
321
322 // canonicalBase is the origin generated links use, without a trailing slash.
323 func (w *Web) canonicalBase(r *http.Request) string {
324 if w.Cfg != nil {
325 if base := w.Cfg.BaseURL(); base != "" {
326 return base
327 }
328 }
329 return requestOrigin(r, w.trustsProxy())
330 }
331
332 // requestOrigin reconstructs the origin a request reached, using the forwarding
333 // header only when the operator has declared a trusted proxy. A proxy that does not
334 // set it is not evidence for anything, and a client that sets it must not be
335 // believed.
336 func requestOrigin(r *http.Request, trustForwarded bool) string {
305 337 scheme := "http"
306 338 if r.TLS != nil {
307 339 scheme = "https"
308 340 }
309 if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
310 scheme = p
341 if trustForwarded {
342 if p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p == "https" || p == "http" {
343 scheme = p
344 }
311 345 }
312 return fmt.Sprintf("%s://%s/git/", scheme, r.Host)
346 return scheme + "://" + r.Host
347 }
348
349 // trustsProxy reports whether the instance is configured to believe forwarded
350 // headers. The client-address header is the instance's statement that something in
351 // front of it rewrites them.
352 func (w *Web) trustsProxy() bool {
353 return w.Cfg != nil && strings.TrimSpace(w.Cfg.GitHTTPClientIPHeader) != ""
313 354 }
314 355
315 func cloneURL(r *http.Request, name string) string {
316 return cloneBase(r) + name + ".git"
356 // siteURL is where a repository's site is served. A forge that is reached over
357 // HTTPS links to its sites over HTTPS: a link that downgrades the scheme is a
358 // mixed-content warning at best and a broken site at worst.
359 func (w *Web) siteURL(r *http.Request, name string) string {
360 if w.Cfg == nil || w.Cfg.PagesDomain == "" {
361 return ""
362 }
363 scheme := "http"
364 if base := w.canonicalBase(r); strings.HasPrefix(base, "https://") {
365 scheme = "https"
366 }
367 return scheme + "://" + name + "." + w.Cfg.PagesDomain
317 368 }
318 369
319 370 func sshURL(addr, name string) string {