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/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