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