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 == "" { | |