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