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/auth_test.go +37
| @@ -899,3 +899,40 @@ func TestAccountPageIsNotCached(t *testing.T) { | |||
| 899 | 899 | t.Fatalf("account page Referrer-Policy = %q", got) | |
| 900 | 900 | } | |
| 901 | 901 | } | |
| 902 | |||
| 903 | // A cross-origin form post is refused even with a valid CSRF token: the token is | ||
| 904 | // the first lock, and the browser's own statement about where the request came | ||
| 905 | // from is the second. A same-origin post is unaffected. | ||
| 906 | func TestCrossOriginPostIsRefused(t *testing.T) { | ||
| 907 | t.Parallel() | ||
| 908 | f := browserFixture(t) | ||
| 909 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 910 | |||
| 911 | post := func(origin string) *http.Response { | ||
| 912 | body := strings.NewReader(url.Values{"csrf": {sess.CSRF}}.Encode()) | ||
| 913 | req, err := http.NewRequest("POST", f.http.URL+"/logout", body) | ||
| 914 | if err != nil { | ||
| 915 | t.Fatal(err) | ||
| 916 | } | ||
| 917 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
| 918 | req.Header.Set("Cookie", cookie) | ||
| 919 | req.Header.Set("Origin", origin) | ||
| 920 | resp, err := noRedirectAuth.Do(req) | ||
| 921 | if err != nil { | ||
| 922 | t.Fatal(err) | ||
| 923 | } | ||
| 924 | resp.Body.Close() | ||
| 925 | return resp | ||
| 926 | } | ||
| 927 | |||
| 928 | if resp := post("https://evil.example"); resp.StatusCode != http.StatusForbidden { | ||
| 929 | t.Fatalf("cross-origin logout = %d, want 403", resp.StatusCode) | ||
| 930 | } | ||
| 931 | if _, err := f.srv.Sessions.Lookup(sess.Token); err != nil { | ||
| 932 | t.Fatal("a refused cross-origin request revoked the session") | ||
| 933 | } | ||
| 934 | // public_url is the instance's origin, which is what the browser says. | ||
| 935 | if resp := post("https://forge.test"); resp.StatusCode != http.StatusSeeOther { | ||
| 936 | t.Fatalf("same-origin logout = %d, want 303", resp.StatusCode) | ||
| 937 | } | ||
| 938 | } | ||