1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package githttp serves Git over smart HTTP: it routes, authenticates and
4// authorizes a request, then lets `git http-backend` answer it through
5// net/http/cgi. It also hosts the Unix-socket server that the push hooks call
6// back into.
7package githttp
8
9import (
10 "context"
11 "errors"
12 "log"
13 "math"
14 "net/http"
15 "net/http/cgi"
16 "net/netip"
17 "regexp"
18 "strconv"
19 "strings"
20
21 "github.com/bnema/zerowrap"
22
23 "bnema.dev/ermite/internal/clientlimit"
24 "bnema.dev/ermite/internal/domain"
25 "bnema.dev/ermite/internal/ports"
26)
27
28// Patterns are the ServeMux patterns the handler serves. Everything else
29// under /<name>/ (dumb HTTP, HEAD, objects/...) is a 404 from the mux.
30var Patterns = []string{
31 "GET /{repo}/info/refs",
32 "POST /{repo}/git-upload-pack",
33 "POST /{repo}/git-receive-pack",
34}
35
36// DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
37// is zero.
38const DefaultMaxBodyBytes int64 = 4 << 30
39
40// uploadPackMaxBody bounds the body of git-upload-pack, which is only a list
41// of wants and haves: git http-backend buffers it whole when its length is
42// unknown, so it must stay small whatever Config.MaxBodyBytes says.
43const uploadPackMaxBody int64 = 64 << 20
44
45// Names of the environment variables that carry the hook callback settings
46// from ermite, through git, to the hook process.
47const (
48 // EnvHookSocket is the path of the hook Unix socket.
49 EnvHookSocket = "ERMITE_HOOK_SOCKET"
50 // EnvHookToken is the per-request hook token.
51 EnvHookToken = "ERMITE_HOOK_TOKEN"
52)
53
54const (
55 serviceUpload = "git-upload-pack"
56 serviceReceive = "git-receive-pack"
57)
58
59// Authenticator turns the secret of a request into a principal. It returns
60// domain.ErrUnauthenticated for a secret that is not valid. app.Auth fits.
61type Authenticator interface {
62 Authenticate(ctx context.Context, secret string) (domain.Principal, error)
63}
64
65// Repos is the read side of the repository use cases. A repository the
66// principal may not read is domain.ErrNotFound. app.Repos fits.
67type Repos interface {
68 Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)
69}
70
71// Pushes opens a repository for a push: it is domain.ErrUnauthenticated for an
72// anonymous caller, domain.ErrNotFound for a repository the caller may not
73// write and cannot see (or that is missing and may not be created),
74// domain.ErrForbidden for one it can see but not write. With create, a missing
75// repository the principal may write is created. app.Pushes fits.
76type Pushes interface {
77 Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error
78}
79
80// HookTokens hands out the per-request tokens the hooks of a push present
81// back to ermite, and the socket they present them on.
82type HookTokens interface {
83 // Register binds a new token to the repository and principal of a push.
84 // release revokes it and must be called when the request ends.
85 Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (token string, release func())
86 // Socket is the path of the hook socket.
87 Socket() string
88}
89
90// Config configures a Handler.
91type Config struct {
92 Auth Authenticator
93 Repos Repos
94 Pushes Pushes
95 Backend ports.GitBackend
96 // Hooks provides hook tokens for pushes. It is required.
97 Hooks HookTokens
98 // TrustedProxies are the networks allowed to set X-Forwarded-For.
99 TrustedProxies []netip.Prefix
100 // Limiter counts failed authentications; nil builds a default one.
101 Limiter *clientlimit.Limiter
102 // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes.
103 // The body of a fetch is bounded by 64 MiB at most.
104 MaxBodyBytes int64
105}
106
107// Handler serves the smart HTTP routes of Patterns.
108type Handler struct {
109 cfg Config
110 limiter *clientlimit.Limiter
111 maxBody int64
112}
113
114// New builds a Handler.
115func New(cfg Config) *Handler {
116 h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}
117 if h.limiter == nil {
118 h.limiter = clientlimit.NewLimiter(nil)
119 }
120 if h.maxBody <= 0 {
121 h.maxBody = DefaultMaxBodyBytes
122 }
123 return h
124}
125
126// route is a parsed Git request.
127type route struct {
128 repo string // the path segment, with any .git suffix
129 suffix string // "info/refs", "git-upload-pack" or "git-receive-pack"
130 service string // serviceUpload or serviceReceive
131}
132
133func (rt route) push() bool { return rt.service == serviceReceive }
134
135// parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
136// and /<repo>/git-receive-pack. info/refs without a service is the dumb
137// protocol, which ermite does not serve.
138func parseRoute(r *http.Request) (route, int) {
139 seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
140 if !ok || seg == "" {
141 return route{}, http.StatusNotFound
142 }
143 rt := route{repo: seg, suffix: rest}
144 switch rest {
145 case "info/refs":
146 if r.Method != http.MethodGet && r.Method != http.MethodHead {
147 return route{}, http.StatusMethodNotAllowed
148 }
149 // Exactly one service value: a repeated parameter is ambiguous, and
150 // what is handed to the backend is rebuilt from this one anyway.
151 switch svcs := r.URL.Query()["service"]; {
152 case len(svcs) != 1:
153 return route{}, http.StatusNotFound
154 case svcs[0] == serviceUpload, svcs[0] == serviceReceive:
155 rt.service = svcs[0]
156 default:
157 return route{}, http.StatusNotFound
158 }
159 case serviceUpload, serviceReceive:
160 if r.Method != http.MethodPost {
161 return route{}, http.StatusMethodNotAllowed
162 }
163 rt.service = rest
164 default:
165 return route{}, http.StatusNotFound
166 }
167 return rt, 0
168}
169
170// ServeHTTP implements http.Handler.
171func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172 var rt route
173 var name domain.RepoName
174 var principal domain.Principal
175 defer func() {
176 log := zerowrap.FromCtx(r.Context())
177 log.Debug().
178 Str("repo", name.String()).
179 Str("service", rt.service).
180 Str("principal", principal.Label()).
181 Str("token_id", principal.TokenID()).
182 Msg("git request")
183 }()
184
185 rt, status := parseRoute(r)
186 if status != 0 {
187 http.Error(w, http.StatusText(status), status)
188 return
189 }
190 name, err := domain.ParseRepoPath(rt.repo)
191 if err != nil {
192 notFound(w)
193 return
194 }
195
196 ctx := r.Context()
197 log := zerowrap.FromCtx(ctx)
198 addr := clientlimit.ClientAddr(r, h.cfg.TrustedProxies)
199 if blocked, wait := h.limiter.Blocked(ctx, addr); blocked {
200 log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
201 w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
202 http.Error(w, "too many failed authentications", http.StatusTooManyRequests)
203 return
204 }
205
206 principal, err = h.authenticate(r, addr)
207 if err != nil {
208 if errors.Is(err, domain.ErrUnauthenticated) {
209 challenge(w)
210 return
211 }
212 log.Error().Err(err).Msg("authenticate git request")
213 http.Error(w, "internal error", http.StatusInternalServerError)
214 return
215 }
216
217 if rt.push() {
218 // Only the first request of a push may create the repository.
219 err = h.cfg.Pushes.Open(ctx, principal, name, rt.suffix == "info/refs")
220 } else {
221 _, err = h.cfg.Repos.Get(ctx, principal, name)
222 }
223 if err != nil {
224 h.denied(ctx, w, principal, err)
225 return
226 }
227
228 h.serveBackend(w, r, rt, name, principal)
229}
230
231// authenticate reads the credentials of the request. No credentials is the
232// anonymous principal; credentials that are not valid count as a failure of
233// the client address and give domain.ErrUnauthenticated.
234func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
235 secret, present := credentials(r)
236 if !present {
237 return domain.Anonymous(), nil
238 }
239 p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
240 if err != nil {
241 if errors.Is(err, domain.ErrUnauthenticated) {
242 h.limiter.Fail(r.Context(), addr)
243 log := zerowrap.FromCtx(r.Context())
244 log.Debug().Str("client", addr).Msg("invalid credentials")
245 }
246 return domain.Anonymous(), err
247 }
248 return p, nil
249}
250
251// credentials extracts the token from an Authorization header: the password of
252// Basic (the username is ignored) or a Bearer token. present is true when the
253// header exists, even if it carries nothing usable, which then fails
254// authentication.
255func credentials(r *http.Request) (secret string, present bool) {
256 auth := r.Header.Get("Authorization")
257 if auth == "" {
258 return "", false
259 }
260 if _, pass, ok := r.BasicAuth(); ok {
261 return pass, true
262 }
263 if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") {
264 return strings.TrimSpace(tok), true
265 }
266 return "", true
267}
268
269// denied answers an authorization failure. A caller without valid
270// credentials is asked for them wherever the repository does not show itself,
271// which keeps a private repository and a missing one indistinguishable.
272func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
273 switch {
274 case errors.Is(err, domain.ErrUnauthenticated):
275 challenge(w)
276 case errors.Is(err, domain.ErrNotFound):
277 if p.IsAnonymous() {
278 challenge(w)
279 return
280 }
281 notFound(w)
282 case errors.Is(err, domain.ErrForbidden):
283 http.Error(w, "forbidden", http.StatusForbidden)
284 default:
285 log := zerowrap.FromCtx(ctx)
286 log.Error().Err(err).Msg("authorize git request")
287 http.Error(w, "internal error", http.StatusInternalServerError)
288 }
289}
290
291func challenge(w http.ResponseWriter) {
292 w.Header().Set("WWW-Authenticate", `Basic realm="ermite"`)
293 http.Error(w, "authentication required", http.StatusUnauthorized)
294}
295
296func notFound(w http.ResponseWriter) { http.Error(w, "not found", http.StatusNotFound) }
297
298// gitProtocolRe is what the Git-Protocol header may carry: colon-separated
299// key=value pairs such as "version=2". Git passes it on as GIT_PROTOCOL to
300// its subprocesses, so anything else is dropped.
301var gitProtocolRe = regexp.MustCompile(`^[a-z0-9._-]+=[a-z0-9._-]+(:[a-z0-9._-]+=[a-z0-9._-]+)*$`)
302
303// maxGitProtocolLen bounds the Git-Protocol header.
304const maxGitProtocolLen = 128
305
306// inheritedEnv are the variables net/http/cgi copies from the ermite process
307// on some platforms (osDefaultInheritEnv). Setting them empty keeps the
308// environment of the process out of Git and its hooks.
309var inheritedEnv = []string{
310 "LD_LIBRARY_PATH=", "LD_LIBRARYN32_PATH=", "LD_LIBRARY64_PATH=",
311 "LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=",
312}
313
314// serveBackend runs git http-backend for an authorized request.
315func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal) {
316 ctx := r.Context()
317 logger := zerowrap.FromCtx(ctx)
318
319 be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push())
320 if err != nil {
321 logger.Error().Err(err).Msg("prepare git http-backend")
322 http.Error(w, "internal error", http.StatusInternalServerError)
323 return
324 }
325 env := append(append([]string(nil), be.Env...), inheritedEnv...)
326 if rt.suffix == serviceReceive {
327 token, release := h.cfg.Hooks.Register(ctx, name, p)
328 defer release()
329 env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token)
330 }
331
332 req, status := h.backendRequest(w, r, rt, name)
333 if status != 0 {
334 http.Error(w, http.StatusText(status), status)
335 return
336 }
337
338 (&cgi.Handler{
339 Path: be.Path,
340 Dir: be.Dir,
341 Env: env,
342 Stderr: logWriter{ctx: ctx, repo: name.String()},
343 Logger: log.New(logWriter{ctx: ctx, repo: name.String(), warn: true}, "", 0),
344 }).ServeHTTP(w, req)
345}
346
347// bodyLimit is the largest request body accepted for the route.
348func (h *Handler) bodyLimit(rt route) int64 {
349 if rt.push() {
350 return h.maxBody
351 }
352 return min(h.maxBody, uploadPackMaxBody)
353}
354
355// backendRequest builds the request handed to CGI. The path names the
356// directory of the repository, the query is rebuilt from the validated
357// service, and the headers are rebuilt from an allowlist: CGI turns every
358// header into an environment variable of Git and of its hooks, so nothing
359// else (credentials, cookies, forwarding headers, anything arbitrary) goes
360// through. The body is streamed and cut off at the limit; a body of unknown
361// length (chunked, which CGI refuses) is passed on with its length unknown.
362// status is non-zero when the request must be refused.
363func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName) (req *http.Request, status int) {
364 limit := h.bodyLimit(rt)
365 if r.ContentLength > limit {
366 return nil, http.StatusRequestEntityTooLarge
367 }
368 req = r.Clone(r.Context())
369 req.URL.Path = "/" + name.String() + ".git/" + rt.suffix
370 req.URL.RawPath = ""
371 req.URL.RawQuery = ""
372 if rt.suffix == "info/refs" {
373 req.URL.RawQuery = "service=" + rt.service
374 }
375
376 req.Header = make(http.Header, 5)
377 for _, k := range []string{"Content-Type", "Content-Encoding", "Accept", "User-Agent"} {
378 if v := r.Header.Get(k); v != "" {
379 req.Header.Set(k, v)
380 }
381 }
382 if v := r.Header.Get("Git-Protocol"); v != "" && len(v) <= maxGitProtocolLen && gitProtocolRe.MatchString(v) {
383 req.Header.Set("Git-Protocol", v)
384 }
385
386 req.Body = http.MaxBytesReader(w, r.Body, limit)
387 if r.ContentLength < 0 || len(r.TransferEncoding) > 0 {
388 req.ContentLength = -1
389 req.TransferEncoding = nil
390 }
391 return req, 0
392}
393
394// logWriter sends what the Git process (or the CGI layer) writes to its
395// standard error to the logger of the request.
396type logWriter struct {
397 ctx context.Context
398 repo string
399 warn bool
400}
401
402func (l logWriter) Write(b []byte) (int, error) {
403 msg := strings.TrimSpace(string(b))
404 if msg == "" {
405 return len(b), nil
406 }
407 logger := zerowrap.FromCtx(l.ctx)
408 if l.warn {
409 logger.Warn().Str("repo", l.repo).Msg(msg)
410 } else {
411 logger.Debug().Str("repo", l.repo).Str("source", "git").Msg(msg)
412 }
413 return len(b), nil
414}