1// Git smart HTTP handler — serves git clone and push using native git
2// subprocesses (git-upload-pack / git-receive-pack --stateless-rpc).
3//
4// Security notes:
5// - No shell is used; exec.Command receives explicit argument slices.
6// - The subprocess environment is sanitised: variables that could redirect
7// git's operations (GIT_DIR, GIT_EXEC_PATH, GIT_SSH, …) are stripped.
8// - The repository path is resolved from our internal config, never from
9// URL parameters or request body content.
10// - Client stderr is captured and discarded; it is never forwarded to the
11// HTTP response.
12//
13// Routes (registered on the /api/repos/{owner}/{repo} subrouter):
14//
15// GET /info/refs?service=git-{upload,receive}-pack → capability advertisement
16// POST /git-upload-pack → fetch / clone
17// POST /git-receive-pack → push (blocked in read-only mode)
18
19package http
20
21import (
22 "bytes"
23 "compress/gzip"
24 "fmt"
25 "io"
26 "net/http"
27 "os"
28 "os/exec"
29 "strings"
30
31 pktline "github.com/go-git/go-git/v5/plumbing/format/pktline"
32
33 "github.com/git-bug/git-bug/cache"
34)
35
36// GitServeHandler exposes the repository over git's smart HTTP protocol.
37type GitServeHandler struct {
38 mrc *cache.MultiRepoCache
39 readOnly bool
40}
41
42func NewGitServeHandler(mrc *cache.MultiRepoCache, readOnly bool) *GitServeHandler {
43 return &GitServeHandler{mrc: mrc, readOnly: readOnly}
44}
45
46// ServeInfoRefs handles GET /info/refs — the capability advertisement step.
47// Runs `git {upload,receive}-pack --stateless-rpc --advertise-refs` and
48// prepends the required PKT-LINE service header.
49// For upload-pack the advertised refs are filtered to heads and tags only so
50// that cloners do not inadvertently fetch git-bug internal objects.
51func (h *GitServeHandler) ServeInfoRefs(w http.ResponseWriter, r *http.Request) {
52 service := r.URL.Query().Get("service")
53 if service != "git-upload-pack" && service != "git-receive-pack" {
54 http.Error(w, "unknown service", http.StatusForbidden)
55 return
56 }
57 if service == "git-receive-pack" && h.readOnly {
58 http.Error(w, "repository is read-only", http.StatusForbidden)
59 return
60 }
61
62 repoPath, err := h.repoPathFor(r)
63 if err != nil {
64 http.Error(w, err.Error(), http.StatusNotFound)
65 return
66 }
67
68 // "git-upload-pack" → "upload-pack", "git-receive-pack" → "receive-pack"
69 subCmd := strings.TrimPrefix(service, "git-")
70
71 cmd := exec.CommandContext(r.Context(),
72 "git", subCmd, "--stateless-rpc", "--advertise-refs", repoPath)
73 cmd.Env = safeGitEnv()
74
75 out, err := cmd.Output()
76 if err != nil {
77 http.Error(w, "git advertisement failed", http.StatusInternalServerError)
78 return
79 }
80
81 w.Header().Set("Cache-Control", "no-cache")
82 w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
83
84 // PKT-LINE service header required by the smart HTTP protocol.
85 enc := pktline.NewEncoder(w)
86 if err := enc.EncodeString(fmt.Sprintf("# service=%s\n", service)); err != nil {
87 return
88 }
89 if err := enc.Flush(); err != nil {
90 return
91 }
92
93 // For upload-pack, filter out internal git-bug refs (refs/bugs/,
94 // refs/identities/, …) so cloners only receive source code objects.
95 if service == "git-upload-pack" {
96 _ = writeFilteredInfoRefs(w, out)
97 } else {
98 _, _ = w.Write(out)
99 }
100}
101
102// ServeUploadPack handles POST /git-upload-pack — serves a fetch or clone.
103// The request body is piped directly to `git upload-pack --stateless-rpc`.
104func (h *GitServeHandler) ServeUploadPack(w http.ResponseWriter, r *http.Request) {
105 repoPath, err := h.repoPathFor(r)
106 if err != nil {
107 http.Error(w, err.Error(), http.StatusNotFound)
108 return
109 }
110
111 body, err := requestBody(r)
112 if err != nil {
113 http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest)
114 return
115 }
116 defer body.Close()
117
118 cmd := exec.CommandContext(r.Context(),
119 "git", "upload-pack", "--stateless-rpc", repoPath)
120 cmd.Env = safeGitEnv()
121 cmd.Stdin = body
122
123 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
124 w.Header().Set("Cache-Control", "no-cache")
125 cmd.Stdout = w
126
127 var stderr bytes.Buffer
128 cmd.Stderr = &stderr
129 // Errors after this point can't change the HTTP status (headers already
130 // committed on first write), so we just return silently.
131 _ = cmd.Run()
132}
133
134// ServeReceivePack handles POST /git-receive-pack — accepts a push.
135// Before running git, the PKT-LINE ref-update commands are parsed so that the
136// git-bug cache can be synchronised for any git-bug namespaces that were
137// updated.
138func (h *GitServeHandler) ServeReceivePack(w http.ResponseWriter, r *http.Request) {
139 if h.readOnly {
140 http.Error(w, "repository is read-only", http.StatusForbidden)
141 return
142 }
143
144 repoPath, err := h.repoPathFor(r)
145 if err != nil {
146 http.Error(w, err.Error(), http.StatusNotFound)
147 return
148 }
149
150 body, err := requestBody(r)
151 if err != nil {
152 http.Error(w, "decompressing request: "+err.Error(), http.StatusBadRequest)
153 return
154 }
155 defer body.Close()
156
157 // Parse the PKT-LINE ref-update commands so we know which git-bug entities
158 // to resync after the push completes. The full request body is
159 // reconstructed (commands + flush + packfile) for git's stdin.
160 updatedRefs, fullBody, err := parseReceivePackCommands(body)
161 if err != nil {
162 http.Error(w, "parsing receive-pack request: "+err.Error(), http.StatusBadRequest)
163 return
164 }
165
166 cmd := exec.CommandContext(r.Context(),
167 "git", "receive-pack", "--stateless-rpc", repoPath)
168 cmd.Env = safeGitEnv()
169 cmd.Stdin = fullBody
170
171 w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
172 w.Header().Set("Cache-Control", "no-cache")
173 cmd.Stdout = w
174
175 var stderr bytes.Buffer
176 cmd.Stderr = &stderr
177
178 if err := cmd.Run(); err != nil {
179 // Headers may already be committed; best-effort return.
180 return
181 }
182
183 h.syncAfterPush(r, updatedRefs)
184}
185
186// ── helpers ───────────────────────────────────────────────────────────────────
187
188// repoPathFor returns the filesystem path of the repository referenced in the
189// request URL variables. The path is always resolved from our internal
190// MultiRepoCache configuration — it is never derived from request content.
191func (h *GitServeHandler) repoPathFor(r *http.Request) (string, error) {
192 rc, err := repoFromPath(h.mrc, r)
193 if err != nil {
194 return "", err
195 }
196 return rc.GetPath(), nil
197}
198
199// syncAfterPush updates the git-bug in-memory cache for any refs that were
200// updated by the push.
201func (h *GitServeHandler) syncAfterPush(r *http.Request, refs []string) {
202 if len(refs) == 0 {
203 return
204 }
205 rc, err := repoFromPath(h.mrc, r)
206 if err != nil {
207 return
208 }
209 _ = rc.SyncLocalRefs(refs)
210}
211
212// writeFilteredInfoRefs re-encodes the raw PKT-LINE advertisement output from
213// git, keeping only HEAD and refs/heads/* and refs/tags/*. The first line is
214// always forwarded unchanged because it carries the server capability list
215// (appended after a NUL byte).
216func writeFilteredInfoRefs(w io.Writer, raw []byte) error {
217 scanner := pktline.NewScanner(bytes.NewReader(raw))
218 enc := pktline.NewEncoder(w)
219 first := true
220 for scanner.Scan() {
221 b := scanner.Bytes()
222 if len(b) == 0 { // flush packet
223 return enc.Flush()
224 }
225 if first {
226 // First line always passes — it carries server capabilities.
227 first = false
228 if err := enc.Encode(b); err != nil {
229 return err
230 }
231 continue
232 }
233 // Lines are "<sha> <refname>\n"; strip the newline to get the ref name.
234 line := strings.TrimSuffix(string(b), "\n")
235 parts := strings.SplitN(line, " ", 2)
236 if len(parts) == 2 {
237 ref := parts[1]
238 if strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/") {
239 if err := enc.Encode(b); err != nil {
240 return err
241 }
242 }
243 }
244 }
245 return scanner.Err()
246}
247
248// parseReceivePackCommands reads the PKT-LINE ref-update command lines from the
249// receive-pack request body (up to and including the flush packet), extracts
250// the ref names, and returns an io.Reader that replays the full original body
251// (commands + flush + packfile) for the git subprocess.
252func parseReceivePackCommands(r io.Reader) (refs []string, full io.Reader, err error) {
253 // TeeReader mirrors everything consumed by the scanner into cmds, so we
254 // can replay it verbatim later.
255 var cmds bytes.Buffer
256 scanner := pktline.NewScanner(io.TeeReader(r, &cmds))
257 for scanner.Scan() {
258 b := scanner.Bytes()
259 if len(b) == 0 { // flush — end of command list
260 break
261 }
262 // Command format: "<old-sha> <new-sha> <refname>\0<caps>" (first line)
263 // or "<old-sha> <new-sha> <refname>" (subsequent)
264 line := strings.TrimSuffix(string(b), "\n")
265 if i := strings.IndexByte(line, 0); i >= 0 {
266 line = line[:i] // strip NUL + capability list
267 }
268 parts := strings.SplitN(line, " ", 3)
269 if len(parts) == 3 {
270 refs = append(refs, parts[2])
271 }
272 }
273 if err = scanner.Err(); err != nil {
274 return nil, nil, err
275 }
276 // cmds holds [commands + flush]; r holds the remaining packfile data.
277 return refs, io.MultiReader(&cmds, r), nil
278}
279
280// requestBody returns the request body, transparently decompressing it when
281// the client sent Content-Encoding: gzip (git does this by default).
282func requestBody(r *http.Request) (io.ReadCloser, error) {
283 if r.Header.Get("Content-Encoding") == "gzip" {
284 gr, err := gzip.NewReader(r.Body)
285 if err != nil {
286 return nil, err
287 }
288 return gr, nil
289 }
290 return r.Body, nil
291}
292
293// safeGitEnv returns a sanitised copy of the process environment for use with
294// git subprocesses. Variables that could redirect git's operations to
295// unintended paths or trigger credential prompts are removed.
296func safeGitEnv() []string {
297 // These variables could redirect git internals to attacker-controlled
298 // paths or commands when the git-bug server process itself inherits a
299 // tainted environment.
300 blocked := map[string]bool{
301 "GIT_DIR": true,
302 "GIT_WORK_TREE": true,
303 "GIT_INDEX_FILE": true,
304 "GIT_OBJECT_DIRECTORY": true,
305 "GIT_ALTERNATE_OBJECT_DIRECTORIES": true,
306 "GIT_EXEC_PATH": true,
307 "GIT_SSH": true,
308 "GIT_SSH_COMMAND": true,
309 "GIT_PROXY_COMMAND": true,
310 "GIT_ASKPASS": true,
311 "SSH_ASKPASS": true,
312 "GIT_TRACE": true,
313 "GIT_TRACE_PACKET": true,
314 "GIT_TRACE_PERFORMANCE": true,
315 }
316 parent := os.Environ()
317 safe := make([]string, 0, len(parent)+1)
318 for _, kv := range parent {
319 key := kv
320 if i := strings.IndexByte(kv, '='); i >= 0 {
321 key = kv[:i]
322 }
323 if !blocked[key] {
324 safe = append(safe, kv)
325 }
326 }
327 // Prevent git from blocking on a credential/passphrase prompt, which
328 // would hang the HTTP handler goroutine.
329 safe = append(safe, "GIT_TERMINAL_PROMPT=0")
330 return safe
331}