vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software: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 · 13d 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/releases.go +13−9
| @@ -19,10 +19,11 @@ import ( | |||
| 19 | 19 | "github.com/rodent-software/vole/metadata" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | // archiveConc bounds concurrent compressed archives. Plain tar streams at | ||
| 23 | // ~1 GiB/s and needs no bound; gzip is single-threaded per stream, and eight | ||
| 24 | // concurrent gzipped archives of a 100 MiB tree cost 13 s of CPU. Queueing a | ||
| 25 | // request is better than making every request late. | ||
| 22 | // archiveConc bounds concurrent archives. Plain tar streams at ~1 GiB/s while gzip | ||
| 23 | // is single-threaded per stream, and eight concurrent gzipped archives of a 100 MiB | ||
| 24 | // tree cost 13 s of CPU. Both spawn git and stream for as long as the client reads, | ||
| 25 | // so both take a slot: plain tar is cheaper, not free. Queueing a request is better | ||
| 26 | // than making every request late. | ||
| 26 | 27 | const archiveConc = 4 | |
| 27 | 28 | ||
| 28 | 29 | // maxReleaseCommits bounds the "since the previous release" list. A release a | |
| @@ -222,6 +223,14 @@ func (w *Web) releaseArchive(rw http.ResponseWriter, r *http.Request, format str | |||
| 222 | 223 | return | |
| 223 | 224 | } | |
| 224 | 225 | ||
| 226 | // Both formats spawn git and stream for as long as the client reads, so both take | ||
| 227 | // a slot from the same pool. Plain tar is cheaper than gzip but not free, and an | ||
| 228 | // unbounded number of either is the thing the bound exists for. | ||
| 229 | if w.sem != nil { | ||
| 230 | w.sem <- struct{}{} | ||
| 231 | defer func() { <-w.sem }() | ||
| 232 | } | ||
| 233 | |||
| 225 | 234 | contentType, ext := "application/x-tar", ".tar" | |
| 226 | 235 | if format == "tar.gz" { | |
| 227 | 236 | contentType, ext = "application/gzip", ".tar.gz" | |
| @@ -246,11 +255,6 @@ func (w *Web) releaseArchive(rw http.ResponseWriter, r *http.Request, format str | |||
| 246 | 255 | return | |
| 247 | 256 | } | |
| 248 | 257 | ||
| 249 | if format == "tar.gz" { | ||
| 250 | w.sem <- struct{}{} | ||
| 251 | defer func() { <-w.sem }() | ||
| 252 | } | ||
| 253 | |||
| 254 | 258 | prefix := releaseSafe(m.Name) + "-" + releaseSafe(t.Name) + "/" | |
| 255 | 259 | if err := g.Archive(rw, t.Commit, format, prefix); err != nil { | |
| 256 | 260 | // Headers are already on the wire, so the reader gets a cut archive; the | |