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/gitbounds.go +67
| @@ -0,0 +1,67 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // Bounds for the pages that shell out to git. | ||
| 4 | // | ||
| 5 | // Search, compare, blame, history and archives each start a git process against a | ||
| 6 | // repository somebody else controls. A repository can be large, a diff can be | ||
| 7 | // quadratic, and a blame walks history: none of these is bounded by anything the | ||
| 8 | // instance configured, and all of them are reachable by an anonymous reader on a | ||
| 9 | // public repository. So each one takes a slot from a fixed-size pool and runs under | ||
| 10 | // a deadline; past the wait the reader is told to come back rather than queued | ||
| 11 | // behind an unbounded number of other readers. | ||
| 12 | |||
| 13 | import ( | ||
| 14 | "context" | ||
| 15 | "net/http" | ||
| 16 | "time" | ||
| 17 | |||
| 18 | "github.com/rodent-software/vole/core/git" | ||
| 19 | "github.com/rodent-software/vole/forge/repo" | ||
| 20 | ) | ||
| 21 | |||
| 22 | const ( | ||
| 23 | // webGitConcurrent bounds how many of these pages run git at once. | ||
| 24 | webGitConcurrent = 8 | ||
| 25 | // webGitTimeout bounds one page's git work. A read of a repository this size | ||
| 26 | // takes milliseconds; a minute means something is wrong, and the process is | ||
| 27 | // killed rather than left to finish after the reader has gone. | ||
| 28 | webGitTimeout = 30 * time.Second | ||
| 29 | // webGitWait is how long a page waits for a slot before answering 503. | ||
| 30 | webGitWait = 5 * time.Second | ||
| 31 | ) | ||
| 32 | |||
| 33 | // gitSlot takes one of the bounded slots, waiting a short while so a burst of page | ||
| 34 | // loads queues instead of failing. It answers the request itself when there is no | ||
| 35 | // slot, and reports whether the handler should continue. | ||
| 36 | func (w *Web) gitSlot(rw http.ResponseWriter, r *http.Request) (func(), bool) { | ||
| 37 | if w.gitSem == nil { | ||
| 38 | return func() {}, true | ||
| 39 | } | ||
| 40 | select { | ||
| 41 | case w.gitSem <- struct{}{}: | ||
| 42 | return func() { <-w.gitSem }, true | ||
| 43 | default: | ||
| 44 | } | ||
| 45 | wait := w.gitWait | ||
| 46 | if wait <= 0 { | ||
| 47 | wait = webGitWait | ||
| 48 | } | ||
| 49 | t := time.NewTimer(wait) | ||
| 50 | defer t.Stop() | ||
| 51 | select { | ||
| 52 | case w.gitSem <- struct{}{}: | ||
| 53 | return func() { <-w.gitSem }, true | ||
| 54 | case <-t.C: | ||
| 55 | rw.Header().Set("Retry-After", "5") | ||
| 56 | http.Error(rw, "the server is busy running other repository queries; try again shortly", http.StatusServiceUnavailable) | ||
| 57 | return nil, false | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | // gitRepo returns the repository handle with this page's deadline attached, so a | ||
| 62 | // git process that outlives it is killed instead of holding a slot. The caller must | ||
| 63 | // call cancel when the handler is done. | ||
| 64 | func gitRepo(r *http.Request, m *repo.Meta) (*git.Repo, context.CancelFunc) { | ||
| 65 | ctx, cancel := context.WithTimeout(r.Context(), webGitTimeout) | ||
| 66 | return m.Git().WithContext(ctx), cancel | ||
| 67 | } | ||