Limit Git HTTP resource usage

2e54bc699c2d22f9722c2489618ad5ebd1869ee1
Alexis Sellier committed ago 1 parent 272b954a
git_http.go +31 -8
5 5
	"net/http/cgi"
6 6
	"os/exec"
7 7
	"strings"
8 8
)
9 9
10 +
const (
11 +
	gitHTTPDeltaBaseCacheLimit = "32m"
12 +
	gitHTTPMaxConcurrent       = 8
13 +
)
14 +
15 +
func gitHTTPEnv() []string {
16 +
	return []string{
17 +
		"GIT_PROJECT_ROOT=",
18 +
		"GIT_HTTP_EXPORT_ALL=1",
19 +
		"GIT_CONFIG_COUNT=3",
20 +
		"GIT_CONFIG_KEY_0=safe.directory",
21 +
		"GIT_CONFIG_VALUE_0=*",
22 +
		"GIT_CONFIG_KEY_1=pack.threads",
23 +
		"GIT_CONFIG_VALUE_1=1",
24 +
		"GIT_CONFIG_KEY_2=core.deltaBaseCacheLimit",
25 +
		"GIT_CONFIG_VALUE_2=" + gitHTTPDeltaBaseCacheLimit,
26 +
	}
27 +
}
28 +
10 29
// handleGitHTTP serves Git smart HTTP protocol requests by proxying to git-http-backend.
11 30
// This enables `git clone` over HTTPS.
12 31
func (s *server) handleGitHTTP(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
32 +
	select {
33 +
	case s.gitHTTPSlots <- struct{}{}:
34 +
		defer func() { <-s.gitHTTPSlots }()
35 +
	default:
36 +
		w.Header().Set("Retry-After", "1")
37 +
		http.Error(w, "too many concurrent Git operations", http.StatusServiceUnavailable)
38 +
		return
39 +
	}
13 40
	gitHTTPBackend, err := exec.LookPath("git-http-backend")
14 41
	if err != nil {
15 42
		gitExecPath, err2 := exec.Command("git", "--exec-path").Output()
16 43
		if err2 != nil {
17 44
			http.Error(w, "git-http-backend not found", http.StatusInternalServerError)
27 54
		pathInfo = strings.TrimPrefix(pathInfo, s.baseURL)
28 55
	}
29 56
	// Replace /<reponame>/ with /<reponame>.git/
30 57
	pathInfo = strings.Replace(pathInfo, "/"+repo.Name+"/", "/"+repo.Name+".git/", 1)
31 58
59 +
	env := gitHTTPEnv()
60 +
	env[0] += s.scanPath
32 61
	handler := &cgi.Handler{
33 -
		Path: gitHTTPBackend,
34 -
		Env: []string{
35 -
			"GIT_PROJECT_ROOT=" + s.scanPath,
36 -
			"GIT_HTTP_EXPORT_ALL=1",
37 -
			"GIT_CONFIG_COUNT=1",
38 -
			"GIT_CONFIG_KEY_0=safe.directory",
39 -
			"GIT_CONFIG_VALUE_0=*",
40 -
		},
62 +
		Path:       gitHTTPBackend,
63 +
		Env:        env,
41 64
		InheritEnv: []string{"PATH"},
42 65
	}
43 66
44 67
	// Override PATH_INFO for the CGI handler.
45 68
	r2 := r.Clone(r.Context())
handler_test.go +70 -0
97 97
		title:          "Test Forge",
98 98
		description:    "test site description",
99 99
		baseURL:        "",
100 100
		sshClonePrefix: "git@code.cloudhead.io:cloudhead/",
101 101
		scanPath:       tmpDir,
102 +
		gitHTTPSlots:   make(chan struct{}, gitHTTPMaxConcurrent),
102 103
	}
103 104
}
104 105
105 106
func gitRun(t *testing.T, dir string, args ...string) {
106 107
	t.Helper()
1096 1097
1097 1098
// ===================================================================
1098 1099
// isGitHTTPRequest
1099 1100
// ===================================================================
1100 1101
1102 +
func TestGitHTTPEnvLimitsReceivePackMemory(t *testing.T) {
1103 +
	env := gitHTTPEnv()
1104 +
	values := make(map[string]string, len(env))
1105 +
	for _, entry := range env {
1106 +
		key, value, ok := strings.Cut(entry, "=")
1107 +
		if !ok {
1108 +
			t.Fatalf("invalid environment entry %q", entry)
1109 +
		}
1110 +
		values[key] = value
1111 +
	}
1112 +
1113 +
	if got := values["GIT_CONFIG_COUNT"]; got != "3" {
1114 +
		t.Fatalf("GIT_CONFIG_COUNT = %q, want 3", got)
1115 +
	}
1116 +
	if got := values["GIT_CONFIG_KEY_1"]; got != "pack.threads" {
1117 +
		t.Errorf("GIT_CONFIG_KEY_1 = %q, want pack.threads", got)
1118 +
	}
1119 +
	if got := values["GIT_CONFIG_VALUE_1"]; got != "1" {
1120 +
		t.Errorf("GIT_CONFIG_VALUE_1 = %q, want 1", got)
1121 +
	}
1122 +
	if got := values["GIT_CONFIG_KEY_2"]; got != "core.deltaBaseCacheLimit" {
1123 +
		t.Errorf("GIT_CONFIG_KEY_2 = %q, want core.deltaBaseCacheLimit", got)
1124 +
	}
1125 +
	if got := values["GIT_CONFIG_VALUE_2"]; got != gitHTTPDeltaBaseCacheLimit {
1126 +
		t.Errorf("GIT_CONFIG_VALUE_2 = %q, want %s", got, gitHTTPDeltaBaseCacheLimit)
1127 +
	}
1128 +
}
1129 +
1130 +
func TestGitHTTPRejectsExcessConcurrency(t *testing.T) {
1131 +
	srv := testServer(t)
1132 +
	for range gitHTTPMaxConcurrent {
1133 +
		srv.gitHTTPSlots <- struct{}{}
1134 +
	}
1135 +
1136 +
	req := httptest.NewRequest(http.MethodPost, "/testrepo/git-receive-pack", nil)
1137 +
	w := httptest.NewRecorder()
1138 +
	srv.handleGitHTTP(w, req, srv.repos["testrepo"])
1139 +
1140 +
	if w.Code != http.StatusServiceUnavailable {
1141 +
		t.Fatalf("status = %d, want %d", w.Code, http.StatusServiceUnavailable)
1142 +
	}
1143 +
	if got := w.Header().Get("Retry-After"); got != "1" {
1144 +
		t.Errorf("Retry-After = %q, want 1", got)
1145 +
	}
1146 +
}
1147 +
1148 +
func TestGitHTTPPush(t *testing.T) {
1149 +
	srv := testServer(t)
1150 +
	gitRun(t, "", "--git-dir", srv.repos["testrepo"].GitDir, "config", "http.receivepack", "true")
1151 +
1152 +
	httpServer := httptest.NewServer(http.HandlerFunc(srv.route))
1153 +
	t.Cleanup(httpServer.Close)
1154 +
1155 +
	workDir := filepath.Join(t.TempDir(), "work")
1156 +
	gitRun(t, "", "clone", httpServer.URL+"/testrepo", workDir)
1157 +
	gitRun(t, workDir, "config", "user.email", "test@example.com")
1158 +
	gitRun(t, workDir, "config", "user.name", "Test User")
1159 +
	if err := os.WriteFile(filepath.Join(workDir, "pushed.txt"), []byte("bounded push\n"), 0644); err != nil {
1160 +
		t.Fatal(err)
1161 +
	}
1162 +
	gitRun(t, workDir, "add", "pushed.txt")
1163 +
	gitRun(t, workDir, "commit", "-m", "Exercise HTTP receive-pack")
1164 +
	gitRun(t, workDir, "push", "origin", "HEAD")
1165 +
1166 +
	if _, err := srv.repos["testrepo"].Git.getBlob("HEAD", "pushed.txt"); err != nil {
1167 +
		t.Fatalf("pushed blob is unavailable: %v", err)
1168 +
	}
1169 +
}
1170 +
1101 1171
func TestIsGitHTTPRequest(t *testing.T) {
1102 1172
	tests := []struct {
1103 1173
		path  string
1104 1174
		query string
1105 1175
		want  bool
file.go +0 -0
old.go +0 -0
new.go +0 -0
image.png +0 -0
file.go +0 -0
a.go +0 -0
b.go +0 -0
main.go +11 -2
8 8
	"net/http"
9 9
	"os"
10 10
	"path/filepath"
11 11
	"sort"
12 12
	"strings"
13 +
	"time"
13 14
)
14 15
15 16
type server struct {
16 17
	repos          map[string]*RepoInfo
17 18
	sorted         []string
24 25
	scanPath       string
25 26
	username       string
26 27
	password       string
27 28
	dev            bool
28 29
	discussions    bool
30 +
	gitHTTPSlots   chan struct{}
29 31
}
30 32
31 33
func main() {
32 34
	listen := flag.String("listen", ":8080", "listen address")
33 35
	scanPath := flag.String("scan-path", ".", "path to scan for git repos")
91 93
		scanPath:       abs,
92 94
		username:       *username,
93 95
		password:       *password,
94 96
		dev:            *dev,
95 97
		discussions:    *discussions,
98 +
		gitHTTPSlots:   make(chan struct{}, gitHTTPMaxConcurrent),
96 99
	}
97 100
98 101
	mux := http.NewServeMux()
99 102
	mux.HandleFunc("/style.css", srv.serveCSS)
100 103
	mux.HandleFunc("/radiant.svg", srv.serveLogo)
105 108
106 109
	var handler http.Handler = mux
107 110
	if srv.username != "" {
108 111
		handler = srv.basicAuth(mux)
109 112
	}
110 -
113 +
	httpServer := &http.Server{
114 +
		Addr:              *listen,
115 +
		Handler:           handler,
116 +
		ReadHeaderTimeout: 10 * time.Second,
117 +
		ReadTimeout:       30 * time.Minute,
118 +
		IdleTimeout:       2 * time.Minute,
119 +
	}
111 120
	log.Printf("listening on %s (scanning %s, %d repos)", *listen, abs, len(repos))
112 -
	if err := http.ListenAndServe(*listen, handler); err != nil {
121 +
	if err := httpServer.ListenAndServe(); err != nil {
113 122
		log.Fatal(err)
114 123
	}
115 124
}
116 125
117 126
func (s *server) basicAuth(next http.Handler) http.Handler {