git_serve_handler.go

  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}