Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions internal/strategy/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type Config struct {
RepackInterval time.Duration `hcl:"repack-interval,optional" help:"How often to run full repack. 0 disables." default:"0"`
ZstdThreads int `hcl:"zstd-threads,optional" help:"Threads for zstd compression/decompression. 0 = all CPU cores; useful for short-lived CLI invocations but risky on a long-running server where multiple snapshot/restore operations can run concurrently." default:"4"`
BundleCacheTTL time.Duration `hcl:"bundle-cache-ttl,optional" help:"TTL of cached server-side git bundles." default:"2h"`
MaxRequestBodyMB int `hcl:"max-request-body-mb,optional" help:"Maximum size in megabytes of git request bodies that are buffered in memory (upload-pack negotiation and ensure-refs). Pushes and other requests streamed to upstream are not limited. 0 disables the limit." default:"100"`

SnapshotFilters map[string]string `hcl:"snapshot-filters,optional" help:"Per-repository git partial-clone filter applied to workstation snapshots, keyed by host/org/repo (e.g. {\"github.com/org/repo\": \"blob:none\"}). Filtered snapshots contain full history metadata but only the blobs needed for the HEAD checkout; clients lazily fetch historical blobs through cachew on demand."`
}
Expand Down Expand Up @@ -410,6 +411,10 @@ func (s *Strategy) handleGitRequest(w http.ResponseWriter, r *http.Request, host
ctx := r.Context()
logger := logging.FromContext(ctx)

if !s.limitRequestBody(w, r) {
return
}

repoPath := ExtractRepoPath(pathValue)
upstreamURL := "https://" + host + "/" + repoPath

Expand All @@ -434,7 +439,9 @@ func (s *Strategy) handleGitRequest(w http.ResponseWriter, r *http.Request, host

switch state {
case gitclone.StateReady:
if err := s.serveReadyRepo(w, r, repo, host, pathValue, isInfoRefs); err != nil {
if err := s.serveReadyRepo(w, r, repo, host, pathValue, isInfoRefs); requestTooLarge(err) {
writeRequestTooLarge(w, r)
} else if err != nil {
logger.ErrorContext(ctx, "Failed to serve from local mirror", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
Expand All @@ -446,7 +453,9 @@ func (s *Strategy) handleGitRequest(w http.ResponseWriter, r *http.Request, host
return s.startClone(ctx, repo)
})
}
if err := s.serveWithSpool(w, r, host, pathValue, upstreamURL); err != nil {
if err := s.serveWithSpool(w, r, host, pathValue, upstreamURL); requestTooLarge(err) {
writeRequestTooLarge(w, r)
} else if err != nil {
logger.WarnContext(ctx, "Spool failed, forwarding to upstream", "error", err)
s.forwardToUpstream(w, r, host, pathValue)
}
Expand Down Expand Up @@ -633,6 +642,28 @@ func (s *Strategy) serveWithSpool(w http.ResponseWriter, r *http.Request, host,
return nil
}

func (s *Strategy) limitRequestBody(w http.ResponseWriter, r *http.Request) bool {
if s.config.MaxRequestBodyMB <= 0 || r.Body == nil || r.Body == http.NoBody {
return true
}
limit := int64(s.config.MaxRequestBodyMB) << 20
if r.ContentLength > limit {
writeRequestTooLarge(w, r)
return false
}
r.Body = http.MaxBytesReader(w, r.Body, limit)
return true
}

func requestTooLarge(err error) bool {
var maxErr *http.MaxBytesError
return errors.As(err, &maxErr)
}

func writeRequestTooLarge(w http.ResponseWriter, r *http.Request) {
cachewhttputil.ErrorResponse(w, r, http.StatusRequestEntityTooLarge, "request body too large")
}

// isGitRequest reports whether pathValue matches a git smart HTTP protocol
// endpoint (info/refs discovery or git-upload-pack negotiation).
func isGitRequest(pathValue string) bool {
Expand Down
7 changes: 6 additions & 1 deletion internal/strategy/git/info_refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ type infoRefsFixture struct {

func newInfoRefsFixture(t *testing.T) *infoRefsFixture {
t.Helper()
return newInfoRefsFixtureWithConfig(t, git.Config{})
}

func newInfoRefsFixtureWithConfig(t *testing.T, config git.Config) *infoRefsFixture {
t.Helper()

tmpDir := t.TempDir()
upstreamPath := filepath.Join(tmpDir, "upstream.git")
Expand All @@ -78,7 +83,7 @@ func newInfoRefsFixture(t *testing.T) *infoRefsFixture {
ctx := logging.ContextWithLogger(t.Context(), logger)
mux := newTestMux()
manager := gitclone.NewManagerProvider(ctx, gitclone.Config{MirrorRoot: mirrorRoot}, nil)
strategy, err := git.New(ctx, git.Config{}, newTestScheduler(ctx, t), nil, mux, manager,
strategy, err := git.New(ctx, config, newTestScheduler(ctx, t), nil, mux, manager,
func() (*githubapp.TokenManager, error) { return nil, nil }) //nolint:nilnil
assert.NoError(t, err)
waitForReady(t, strategy)
Expand Down
11 changes: 10 additions & 1 deletion internal/strategy/git/refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,17 @@ func (s *Strategy) handleEnsureRefs(w http.ResponseWriter, r *http.Request, host
return
}

if !s.limitRequestBody(w, r) {
status = "too_large"
return
}

var req EnsureRefsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(r.Body).Decode(&req); requestTooLarge(err) {
writeRequestTooLarge(w, r)
status = "too_large"
return
} else if err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
status = "bad_request"
span.RecordError(err)
Expand Down
70 changes: 70 additions & 0 deletions internal/strategy/git/request_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package git_test

import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/alecthomas/assert/v2"

"github.com/block/cachew/internal/strategy/git"
)

func uploadPackBody(want string, haves int) string {
var b strings.Builder
b.WriteString(pkt("want " + want + "\n"))
b.WriteString("0000")
for i := range haves {
b.WriteString(pkt(fmt.Sprintf("have %040x\n", i+1)))
}
b.WriteString(pkt("done\n"))
return b.String()
}

func (f *infoRefsFixture) post(t *testing.T, path, body string, chunked bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(f.ctx, http.MethodPost, "/git/example.test/"+path, strings.NewReader(body))
if chunked {
req.ContentLength = -1
}
req.Header.Set("Content-Type", "application/x-git-upload-pack-request")
req.SetPathValue("host", "example.test")
req.SetPathValue("path", path)
w := httptest.NewRecorder()
f.uploadPack.ServeHTTP(w, req)
return w
}

func TestRequestBodyLimit(t *testing.T) {
const limit = 1 << 20
fixture := newInfoRefsFixtureWithConfig(t, git.Config{MaxRequestBodyMB: 1})
setInfoRefsLsRemote(t, fixture.upstreamPath, false)
head := runInfoRefsGit(t, "-C", fixture.mirrorPath, "rev-parse", "HEAD")
oversized := uploadPackBody(head, limit/50)
assert.True(t, len(oversized) > limit)

tests := []struct {
name string
path string
body string
chunked bool
status int
}{
{"UploadPackUnderLimit", "org/repo/git-upload-pack", uploadPackBody(head, 15000), true, http.StatusOK},
{"UploadPackContentLength", "org/repo/git-upload-pack", oversized, false, http.StatusRequestEntityTooLarge},
{"UploadPackChunked", "org/repo/git-upload-pack", oversized, true, http.StatusRequestEntityTooLarge},
{"UploadPackChunkedWhileCloning", "org/other/git-upload-pack", oversized, true, http.StatusRequestEntityTooLarge},
{"EnsureRefsChunked", "org/repo/ensure-refs", `{"commits":["` + strings.Repeat("a", limit) + `"]}`, true,
http.StatusRequestEntityTooLarge},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
before := fixture.transport.hits.Load()
w := fixture.post(t, tt.path, tt.body, tt.chunked)
assert.Equal(t, tt.status, w.Code, w.Body.String())
assert.Equal(t, before, fixture.transport.hits.Load())
})
}
}
Loading