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/auth.go +45−1

@@ -344,8 +344,18 @@ func (w *Web) securityHeaders(rw http.ResponseWriter, r *http.Request) {
344 344 rw.Header().Set("X-Content-Type-Options", "nosniff")
345 345 rw.Header().Set("X-Frame-Options", "DENY")
346 346 rw.Header().Set("Referrer-Policy", "same-origin")
347 // There is no inline style in any template and none is generated, so the
348 // stylesheet is the only style source. object-src is closed because nothing here
349 // embeds a plugin, and a page that never loads one cannot be made to.
347 350 rw.Header().Set("Content-Security-Policy",
348 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
351 "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'")
352 // Nothing on this page needs a device, a payment API or an interest cohort, and
353 // the site runs beside its own pages on a pages subdomain: COOP/CORP keep a
354 // document from being opened or read as somebody else's resource.
355 rw.Header().Set("Permissions-Policy",
356 "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()")
357 rw.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
358 rw.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
349 359 // HSTS belongs to the transport, not to passkeys: an instance behind a TLS
350 360 // proxy with no browser sign-in is just as much an HTTPS instance, and a
351 361 // browser that learns that here cannot be downgraded later.
@@ -474,12 +484,18 @@ func (w *Web) clearCookieFor(rw http.ResponseWriter, name string, secure bool) {
474 484 // X-Vole-CSRF header. It never parses the body, so a handler that has not bounded
475 485 // the request cannot be made to parse one here.
476 486 func (w *Web) checkCSRF(r *http.Request) bool {
487 if !w.sameOriginRequest(r) {
488 return false
489 }
477 490 return w.csrfToken(r, r.Header.Get("X-Vole-CSRF"))
478 491 }
479 492
480 493 // csrfOK is the form endpoints' check: the header, or the csrf field of a body
481 494 // that readForm has already bounded and parsed.
482 495 func (w *Web) csrfOK(r *http.Request) bool {
496 if !w.sameOriginRequest(r) {
497 return false
498 }
483 499 got := r.Header.Get("X-Vole-CSRF")
484 500 if got == "" {
485 501 got = r.PostFormValue("csrf")
@@ -487,6 +503,34 @@ func (w *Web) csrfOK(r *http.Request) bool {
487 503 return w.csrfToken(r, got)
488 504 }
489 505
506 // sameOriginRequest is the second lock on the same door as the CSRF token: when the
507 // browser says where a request came from, that answer has to be this instance. A
508 // missing Origin and Referer is not evidence against a request — some clients and
509 // privacy settings send neither — so this can only refuse a request that named
510 // somewhere else.
511 func (w *Web) sameOriginRequest(r *http.Request) bool {
512 origin := strings.TrimSpace(r.Header.Get("Origin"))
513 if origin == "" {
514 origin = strings.TrimSpace(r.Header.Get("Referer"))
515 }
516 if origin == "" {
517 return true
518 }
519 u, err := url.Parse(origin)
520 if err != nil || u.Scheme == "" || u.Host == "" {
521 return false
522 }
523 have := u.Scheme + "://" + u.Host
524 want := w.canonicalBase(r)
525 if have == want {
526 return true
527 }
528 // Behind a proxy the browser's origin is the public one while the request's own
529 // origin is the internal one; public_url is then the only correct comparison,
530 // which canonicalBase already used when it is set.
531 return w.Cfg != nil && w.Cfg.BaseURL() != "" && strings.EqualFold(have, want)
532 }
533
490 534 func (w *Web) csrfToken(r *http.Request, got string) bool {
491 535 pr := w.principal(r)
492 536 if pr == nil || pr.session == nil || pr.session.CSRF == "" || got == "" {