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/lfs_base_test.go +44
| @@ -0,0 +1,44 @@ | |||
| 1 | package server | ||
| 2 | |||
| 3 | // The LFS batch response tells a git-lfs client where to download from. That href | ||
| 4 | // is followed by the client, so it must not be a host the caller chose. | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "net/http/httptest" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "github.com/rodent-software/vole/forge/config" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func TestLFSObjectBasePrefersTheConfiguredOrigin(t *testing.T) { | ||
| 15 | t.Parallel() | ||
| 16 | s := &Server{Cfg: &config.Config{PublicURL: "https://forge.example.com"}} | ||
| 17 | r := httptest.NewRequest("POST", "/git/demo.git"+objectShard+"objects/batch", nil) | ||
| 18 | r.Host = "evil.example" | ||
| 19 | r.Header.Set("X-Forwarded-Proto", "http") | ||
| 20 | |||
| 21 | got := s.lfsObjectBase(r, "demo") | ||
| 22 | if !strings.HasPrefix(got, "https://forge.example.com/git/demo.git") { | ||
| 23 | t.Fatalf("lfsObjectBase = %q, want the configured origin", got) | ||
| 24 | } | ||
| 25 | if strings.Contains(got, "evil.example") { | ||
| 26 | t.Fatalf("lfsObjectBase followed the request Host: %q", got) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | func TestLFSObjectBaseHonoursForwardedHeadersOnlyBehindAProxy(t *testing.T) { | ||
| 31 | t.Parallel() | ||
| 32 | r := httptest.NewRequest("POST", "/git/demo.git"+objectShard+"objects/batch", nil) | ||
| 33 | r.Host = "127.0.0.1:8080" | ||
| 34 | r.Header.Set("X-Forwarded-Proto", "https") | ||
| 35 | |||
| 36 | s := &Server{Cfg: &config.Config{}} | ||
| 37 | if got := s.lfsObjectBase(r, "demo"); !strings.HasPrefix(got, "http://127.0.0.1:8080/") { | ||
| 38 | t.Fatalf("without a declared proxy lfsObjectBase = %q", got) | ||
| 39 | } | ||
| 40 | s = &Server{Cfg: &config.Config{GitHTTPClientIPHeader: "X-Forwarded-For"}} | ||
| 41 | if got := s.lfsObjectBase(r, "demo"); !strings.HasPrefix(got, "https://127.0.0.1:8080/") { | ||
| 42 | t.Fatalf("behind a declared proxy lfsObjectBase = %q", got) | ||
| 43 | } | ||
| 44 | } | ||