Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }