From 19092d9daedbaee8c05ae864bd3ac3d0e3069f19 Mon Sep 17 00:00:00 2001 From: Jeremy Hatfield Date: Sat, 10 Oct 2026 03:21:06 +0000 Subject: [PATCH] fix: validate CrowdSec archive entry names with a standard local-path check --- backend/internal/crowdsec/hub_sync.go | 16 +++--------- backend/internal/crowdsec/hub_sync_test.go | 29 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go index 7a320ff68..32bc58c2f 100644 --- a/backend/internal/crowdsec/hub_sync.go +++ b/backend/internal/crowdsec/hub_sync.go @@ -930,10 +930,11 @@ func (s *HubService) extractTarGz(ctx context.Context, archive []byte, targetDir if hdr.FileInfo().Mode()&os.ModeType != 0 && !hdr.FileInfo().Mode().IsRegular() && !hdr.FileInfo().IsDir() { continue } - cleanName := filepath.Clean(hdr.Name) - if hasParentComponent(cleanName) || filepath.IsAbs(cleanName) { + // IsLocal rejects absolute, empty and ".." component names (but accepts "..foo"). + if !filepath.IsLocal(hdr.Name) { return fmt.Errorf("unsafe path %s", hdr.Name) } + cleanName := filepath.Clean(hdr.Name) if IsEngineOwnedPath(cleanName) { continue } @@ -1025,14 +1026,3 @@ func isGzip(data []byte) bool { } return data[0] == 0x1f && data[1] == 0x8b } - -// hasParentComponent reports whether any path component equals "..". -// Names that merely start with two dots (e.g. "..foo") are legitimate. -func hasParentComponent(name string) bool { - for _, part := range strings.Split(filepath.ToSlash(name), "/") { - if part == ".." { - return true - } - } - return false -} diff --git a/backend/internal/crowdsec/hub_sync_test.go b/backend/internal/crowdsec/hub_sync_test.go index 15858765c..2e36f3095 100644 --- a/backend/internal/crowdsec/hub_sync_test.go +++ b/backend/internal/crowdsec/hub_sync_test.go @@ -2299,3 +2299,32 @@ func TestHubBaseCandidates_DistinctRolesKeepOrder(t *testing.T) { defaultHubBaseURL, }, svc.hubBaseCandidates()) } + +func TestExtractTarGzLocalPathEdgeCases(t *testing.T) { + t.Parallel() + svc := NewHubService(nil, nil, t.TempDir()) + + t.Run("accepts dot directory entry", func(t *testing.T) { + t.Parallel() + targetDir := t.TempDir() + buf := &bytes.Buffer{} + gw := gzip.NewWriter(buf) + tw := tar.NewWriter(gw) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "./", Typeflag: tar.TypeDir, Mode: 0o755})) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "./a.txt", Mode: 0o644, Size: 2})) + _, err := tw.Write([]byte("ok")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + require.NoError(t, svc.extractTarGz(context.Background(), buf.Bytes(), targetDir)) + require.FileExists(t, filepath.Join(targetDir, "a.txt")) + }) + + t.Run("rejects empty name", func(t *testing.T) { + t.Parallel() + archive := makeTarGz(t, map[string]string{"": "bad"}) + err := svc.extractTarGz(context.Background(), archive, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), "unsafe path") + }) +}