Repository navigation
fix(security): harden server-side LFS snapshots #416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| .git-lfs-3.7.1.pkg | ||
| .git-lfs-3.8.1.pkg |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import ( | |
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/codes" | ||
| "go.opentelemetry.io/otel/trace" | ||
| "golang.org/x/mod/semver" | ||
|
|
||
| "github.com/block/cachew/internal/cache" | ||
| "github.com/block/cachew/internal/gitclone" | ||
|
|
@@ -38,6 +39,11 @@ import ( | |
| //nolint:gochecknoglobals // OTel tracer instances are package-scoped by convention | ||
| var tracer = otel.Tracer("github.com/block/cachew/internal/strategy/git") | ||
|
|
||
| const minimumGitLFSVersion = "v3.8.1" | ||
|
|
||
| //nolint:gochecknoglobals // The executable and its version are process-wide. | ||
| var gitLFSVersionCheck = sync.OnceValue(checkGitLFSVersion) | ||
|
|
||
| func Register(r *strategy.Registry, scheduler jobscheduler.Provider, cloneManagerProvider gitclone.ManagerProvider, tokenManagerProvider githubapp.TokenManagerProvider) { | ||
| strategy.Register(r, "git", "Caches Git repositories, including tarball snapshots.", func(ctx context.Context, config Config, cache cache.Cache, mux strategy.Mux) (*Strategy, error) { | ||
| return New(ctx, config, scheduler, cache, mux, cloneManagerProvider, tokenManagerProvider) | ||
|
|
@@ -108,8 +114,8 @@ func New( | |
|
|
||
| logger := logging.FromContext(ctx) | ||
|
|
||
| if _, err := exec.LookPath("git-lfs"); err != nil { | ||
| return nil, errors.New("git-lfs is required but not found in PATH") | ||
| if err := gitLFSVersionCheck(); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Get GitHub App token manager if configured | ||
|
|
@@ -226,6 +232,33 @@ func New( | |
| return s, nil | ||
| } | ||
|
|
||
| func gitLFSVersionSupported(output []byte) bool { | ||
| _, versionText, ok := strings.Cut(string(output), "git-lfs/") | ||
| if !ok { | ||
| return false | ||
| } | ||
| fields := strings.Fields(versionText) | ||
| if len(fields) == 0 { | ||
| return false | ||
| } | ||
| version := "v" + fields[0] | ||
| return semver.IsValid(version) && semver.Compare(version, minimumGitLFSVersion) >= 0 | ||
| } | ||
|
|
||
| func checkGitLFSVersion() error { | ||
| if _, err := exec.LookPath("git-lfs"); err != nil { | ||
| return errors.New("git-lfs is required but not found in PATH") | ||
| } | ||
| gitLFSVersion, err := exec.CommandContext(context.Background(), "git-lfs", "version").CombinedOutput() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On installations where Git has an older Useful? React with 👍 / 👎. |
||
| if err != nil { | ||
| return errors.Wrapf(err, "run git-lfs version: %s", string(gitLFSVersion)) | ||
| } | ||
| if !gitLFSVersionSupported(gitLFSVersion) { | ||
| return errors.Errorf("git-lfs %s or newer is required", strings.TrimPrefix(minimumGitLFSVersion, "v")) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| var _ strategy.Strategy = (*Strategy)(nil) | ||
| var _ strategy.Readier = (*Strategy)(nil) | ||
| var _ strategy.MetadataConsumer = (*Strategy)(nil) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package git //nolint:testpackage // white-box testing is required for process-boundary hardening | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "runtime" | ||
| "testing" | ||
|
|
||
| "github.com/alecthomas/assert/v2" | ||
| ) | ||
|
|
||
| func TestGitLFSVersionSupported(t *testing.T) { | ||
| assert.False(t, gitLFSVersionSupported([]byte("git-lfs/3.7.1 (GitHub; linux amd64; go 1.24.4)"))) | ||
| assert.False(t, gitLFSVersionSupported([]byte("unexpected output"))) | ||
| assert.True(t, gitLFSVersionSupported([]byte("git-lfs/3.8.1 (GitHub; linux amd64; go 1.27.3)"))) | ||
| assert.True(t, gitLFSVersionSupported([]byte("git-lfs/4.0.0 (GitHub; linux amd64; go 1.28)"))) | ||
| } | ||
|
|
||
| func TestLFSEndpointForUpstream(t *testing.T) { | ||
| tests := []struct { | ||
| upstream string | ||
| expected string | ||
| }{ | ||
| {"https://github.com/org/repo", "https://github.com/org/repo.git/info/lfs"}, | ||
| {"https://github.com/org/repo.git", "https://github.com/org/repo.git/info/lfs"}, | ||
| } | ||
| for _, test := range tests { | ||
| actual, err := lfsEndpointForUpstream(test.upstream) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, test.expected, actual) | ||
| } | ||
| } | ||
|
|
||
| func TestLFSEndpointForUpstreamRejectsUnsafeURL(t *testing.T) { | ||
| for _, upstream := range []string{ | ||
| "http://127.0.0.1/org/repo", | ||
| "https://user@example.com/org/repo", | ||
| "https://example.com/org/repo?endpoint=http://127.0.0.1", | ||
| "https://example.com/org/repo#x", | ||
| } { | ||
| _, err := lfsEndpointForUpstream(upstream) | ||
| assert.Error(t, err) | ||
| } | ||
| } | ||
|
|
||
| func TestReplaceRepositoryLFSConfigDoesNotFollowSymlink(t *testing.T) { | ||
| if runtime.GOOS == "windows" { | ||
| t.Skip("symlink setup requires privileges on Windows") | ||
| } | ||
| outside := filepath.Join(t.TempDir(), "outside") | ||
| assert.NoError(t, os.WriteFile(outside, []byte("preserve"), 0o600)) | ||
| workDir := t.TempDir() | ||
| assert.NoError(t, os.Symlink(outside, filepath.Join(workDir, ".lfsconfig"))) | ||
|
|
||
| assert.NoError(t, replaceRepositoryLFSConfig(workDir)) | ||
| contents, err := os.ReadFile(outside) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, "preserve", string(contents)) | ||
| info, err := os.Lstat(filepath.Join(workDir, ".lfsconfig")) | ||
| assert.NoError(t, err) | ||
| assert.False(t, info.Mode()&os.ModeSymlink != 0) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
snapshot-intervalis left at its documented default of 0, this unconditional check makes the entire Git strategy fail to initialize on hosts without git-lfs 3.8.1 or newer, even though LFS snapshot jobs are scheduled only behindSnapshotInterval > 0and the ordinary Git proxy never launches git-lfs. This regresses binary/non-Docker deployments that intentionally disable snapshots; gate the requirement alongside the existing tar/pzstd checks.Useful? React with 👍 / 👎.