Path Traversal

MEDIUM
grafana/grafana
Commit: 237be1dea917
Affected: 12.4.0
2026-04-24 14:01 UTC

Description

The commit addresses a potential path traversal vulnerability in on-disk storage handling for Bleve snapshot uploads and related index path construction. Previously, resource paths were assembled by joining the root with namespace/resource segments, which could be influenced by attacker-controlled resource identifiers. The patch introduces a sanitized path construction via resourceSubPath (which applies cleanFileSegment to Namespace and Resource/Group components) and guards staging paths with isPathWithinRoot to ensure the final path cannot escape the configured root. It also creates a bounded staging directory under Root/snapshots for uploads and updates remote/index path helpers to rely on the sanitized resourceSubPath. These changes reduce the risk of path traversal and unauthorized filesystem access during snapshot uploads and index storage.

Commit Details

Author: Peter Štibraný

Date: 2026-04-24 13:16 UTC

Message:

Add Bleve snapshot upload primitive (#123474) * Add bleve snapshot upload primitive * Align snapshot path formatting

Triage Assessment

Vulnerability Type: Path Traversal

Confidence: MEDIUM

Reasoning:

The patch introduces path handling improvements and explicit path validity checks (isPathWithinRoot) when creating snapshot staging directories and resource paths, reducing the risk of path traversal vulnerabilities in on-disk storage. It also adds bounded staging areas and locking, which together harden the upload path against unauthorized filesystem access. Although the primary goal is a feature/maintenance change (Bleve snapshot upload), these changes address a concrete security risk related to on-disk path handling.

Verification Assessment

Vulnerability Type: Path Traversal

Confidence: MEDIUM

Affected Versions: 12.4.0

Code Diff

diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 5bb97aed065a9..f373d98add1e6 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -622,7 +622,13 @@ func (b *bleveBackend) BuildIndex( } func (b *bleveBackend) getResourceDir(key resource.NamespacedResource) string { - return filepath.Join(b.opts.Root, cleanFileSegment(key.Namespace), cleanFileSegment(fmt.Sprintf("%s.%s", key.Resource, key.Group))) + return filepath.Join(b.opts.Root, resourceSubPath(key)) +} + +// resourceSubPath returns the namespaced on-disk/object-store path for a resource, +// for example: default/dashboards.dashboard.grafana.app +func resourceSubPath(key resource.NamespacedResource) string { + return filepath.Join(cleanFileSegment(key.Namespace), cleanFileSegment(fmt.Sprintf("%s.%s", key.Resource, key.Group))) } func cleanFileSegment(input string) string { diff --git a/pkg/storage/unified/search/bleve_snapshot_upload.go b/pkg/storage/unified/search/bleve_snapshot_upload.go new file mode 100644 index 0000000000000..a50a6bc5ba630 --- /dev/null +++ b/pkg/storage/unified/search/bleve_snapshot_upload.go @@ -0,0 +1,116 @@ +package search + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/blevesearch/bleve/v2" + + "github.com/grafana/grafana/pkg/storage/unified/resource" +) + +func (b *bleveBackend) uploadSnapshot(ctx context.Context, key resource.NamespacedResource, idx *bleveIndex) (retErr error) { + lock, err := b.opts.Snapshot.Store.LockBuildIndex(ctx, key) + if err != nil { + return fmt.Errorf("acquiring snapshot upload lock: %w", err) + } + defer func() { + releaseErr := lock.Release() + if releaseErr == nil { + return + } + if retErr == nil { + retErr = fmt.Errorf("releasing snapshot upload lock: %w", releaseErr) + return + } + retErr = errors.Join(retErr, fmt.Errorf("releasing snapshot upload lock: %w", releaseErr)) + }() + + stagingDir, err := b.newSnapshotStagingDir(key) + if err != nil { + return fmt.Errorf("creating snapshot staging dir: %w", err) + } + defer func() { _ = os.RemoveAll(stagingDir) }() + + start := time.Now() + if err := b.snapshotIndex(idx.index, stagingDir); err != nil { + return err + } + // Lock loss is checked only at step boundaries. The main value of the + // distributed lock is preventing duplicate snapshot/upload work up front, and + // the upload path is safe to retry because remote snapshots are immutable and + // keyed by unique ULIDs. + if err := checkSnapshotLock(lock); err != nil { + return err + } + + // Read RV/build info from the staged snapshot instead of the live index so + // the uploaded metadata matches the copied snapshot contents even if the live + // index advanced while CopyTo was running. + snapshotIdx, err := bleve.OpenUsing(stagingDir, map[string]interface{}{"bolt_timeout": boltTimeout}) + if err != nil { + return fmt.Errorf("opening staged snapshot: %w", err) + } + + rv, rvErr := getRV(snapshotIdx) + bi, biErr := getBuildInfo(snapshotIdx) + if closeErr := snapshotIdx.Close(); closeErr != nil { + return fmt.Errorf("closing staged snapshot: %w", closeErr) + } + if rvErr != nil { + return fmt.Errorf("reading snapshot rv: %w", rvErr) + } + if biErr != nil { + return fmt.Errorf("reading snapshot build info: %w", biErr) + } + + _, err = b.opts.Snapshot.Store.UploadIndex(ctx, key, stagingDir, IndexMeta{ + GrafanaBuildVersion: bi.BuildVersion, + LatestResourceVersion: rv, + }) + if err != nil { + return fmt.Errorf("uploading snapshot: %w", err) + } + if err := checkSnapshotLock(lock); err != nil { + return err + } + + elapsed := time.Since(start) + b.log.Info("Uploaded remote index snapshot", "key", key, "elapsed", elapsed, "rv", rv) + return nil +} + +func (b *bleveBackend) snapshotIndex(idx bleve.Index, destDir string) error { + copyable, ok := idx.(bleve.IndexCopyable) + if !ok { + return fmt.Errorf("index does not support snapshot copy") + } + if err := copyable.CopyTo(bleve.FileSystemDirectory(destDir)); err != nil { + return fmt.Errorf("copying index snapshot: %w", err) + } + return nil +} + +func (b *bleveBackend) newSnapshotStagingDir(key resource.NamespacedResource) (string, error) { + parent := filepath.Join(b.opts.Root, "snapshots", resourceSubPath(key)) + if !isPathWithinRoot(parent, b.opts.Root) { + return "", fmt.Errorf("invalid path %s", parent) + } + if err := os.MkdirAll(parent, 0o700); err != nil { + return "", err + } + return os.MkdirTemp(parent, "upload-*") +} + +func checkSnapshotLock(lock IndexStoreLock) error { + select { + case <-lock.Lost(): + return fmt.Errorf("snapshot upload lock lost") + default: + return nil + } +} diff --git a/pkg/storage/unified/search/bleve_snapshot_upload_test.go b/pkg/storage/unified/search/bleve_snapshot_upload_test.go new file mode 100644 index 0000000000000..194ee50ca3d59 --- /dev/null +++ b/pkg/storage/unified/search/bleve_snapshot_upload_test.go @@ -0,0 +1,160 @@ +package search + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/blevesearch/bleve/v2" + "github.com/oklog/ulid/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/storage/unified/resource" +) + +type uploadTestStore struct { + lockErr error + + uploadErr error + uploadCalls atomic.Int32 + uploadMeta IndexMeta + uploaded []string +} + +func (s *uploadTestStore) LockBuildIndex(context.Context, resource.NamespacedResource) (IndexStoreLock, error) { + if s.lockErr != nil { + return nil, s.lockErr + } + return &noopIndexStoreLock{lost: make(chan struct{})}, nil +} + +func (s *uploadTestStore) UploadIndex(_ context.Context, _ resource.NamespacedResource, localDir string, meta IndexMeta) (ulid.ULID, error) { + s.uploadCalls.Add(1) + s.uploadMeta = meta + + err := filepath.WalkDir(localDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(localDir, path) + if err != nil { + return err + } + s.uploaded = append(s.uploaded, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return ulid.ULID{}, err + } + if s.uploadErr != nil { + return ulid.ULID{}, s.uploadErr + } + return ulid.Make(), nil +} + +func (s *uploadTestStore) DownloadIndex(context.Context, resource.NamespacedResource, ulid.ULID, string) (*IndexMeta, error) { + panic("DownloadIndex not implemented for uploadTestStore") +} + +func (s *uploadTestStore) ListIndexes(context.Context, resource.NamespacedResource) (map[ulid.ULID]*IndexMeta, error) { + panic("ListIndexes not implemented for uploadTestStore") +} + +func (s *uploadTestStore) DeleteIndex(context.Context, resource.NamespacedResource, ulid.ULID) error { + panic("DeleteIndex not implemented for uploadTestStore") +} + +func (s *uploadTestStore) CleanupIncompleteUploads(context.Context, resource.NamespacedResource, time.Duration) (int, error) { + panic("CleanupIncompleteUploads not implemented for uploadTestStore") +} + +func newUploadTestIndex(t *testing.T, be *bleveBackend, key resource.NamespacedResource, rv int64) *bleveIndex { + t.Helper() + resourceDir := be.getResourceDir(key) + require.NoError(t, os.MkdirAll(resourceDir, 0o750)) + + idx, err := newBleveIndex(filepath.Join(resourceDir, formatIndexName(time.Now())), bleve.NewIndexMapping(), time.Now(), be.opts.BuildVersion, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = idx.Close() }) + + require.NoError(t, idx.Index("dash-1", map[string]string{"title": "Production Overview"})) + require.NoError(t, setRV(idx, rv)) + + wrapped := be.newBleveIndex(key, idx, indexStorageFile, nil, nil, nil, nil, be.log) + wrapped.resourceVersion.Store(rv) + return wrapped +} + +func TestSnapshotIndex_CreatesUsableCopy(t *testing.T) { + be, _ := newTestBleveBackend(t, SnapshotOptions{}) + key := newTestNsResource() + src := newUploadTestIndex(t, be, key, 42) + + destDir := filepath.Join(t.TempDir(), "snapshot") + require.NoError(t, be.snapshotIndex(src.index, destDir)) + + copied, err := bleve.OpenUsing(destDir, map[string]interface{}{"bolt_timeout": boltTimeout}) + require.NoError(t, err) + defer func() { _ = copied.Close() }() + + rv, err := getRV(copied) + require.NoError(t, err) + assert.Equal(t, int64(42), rv) + + count, err := copied.DocCount() + require.NoError(t, err) + assert.Equal(t, uint64(1), count) +} + +func TestUploadSnapshot_Success(t *testing.T) { + store := &uploadTestStore{} + be, _ := newTestBleveBackend(t, SnapshotOptions{Store: store}) + key := newTestNsResource() + idx := newUploadTestIndex(t, be, key, 42) + + require.NoError(t, be.uploadSnapshot(context.Background(), key, idx)) + assert.Equal(t, int32(1), store.uploadCalls.Load()) + assert.Equal(t, int64(42), store.uploadMeta.LatestResourceVersion) + assert.Equal(t, be.opts.BuildVersion, store.uploadMeta.GrafanaBuildVersion) + assert.NotEmpty(t, store.uploaded) + + snapshotParent := filepath.Join(be.opts.Root, "snapshots", resourceSubPath(key)) + entries, err := os.ReadDir(snapshotParent) + require.NoError(t, err) + assert.Empty(t, entries) +} + +func TestUploadSnapshot_LockContention(t *testing.T) { + store := &uploadTestStore{lockErr: errLockHeld} + be, _ := newTestBleveBackend(t, SnapshotOptions{Store: store}) + key := newTestNsResource() + idx := newUploadTestIndex(t, be, key, 42) + + err := be.uploadSnapshot(context.Background(), key, idx) + require.ErrorIs(t, err, errLockHeld) + assert.Zero(t, store.uploadCalls.Load()) +} + +func TestUploadSnapshot_UploadErrorCleansStagingDir(t *testing.T) { + store := &uploadTestStore{uploadErr: errors.New("boom")} + be, _ := newTestBleveBackend(t, SnapshotOptions{Store: store}) + key := newTestNsResource() + idx := newUploadTestIndex(t, be, key, 42) + + err := be.uploadSnapshot(context.Background(), key, idx) + require.Error(t, err) + + snapshotParent := filepath.Join(be.opts.Root, "snapshots", resourceSubPath(key)) + entries, readErr := os.ReadDir(snapshotParent) + require.NoError(t, readErr) + assert.Empty(t, entries) +} diff --git a/pkg/storage/unified/search/remote_index_store.go b/pkg/storage/unified/search/remote_index_store.go index c722c60603ea6..1557360f0e134 100644 --- a/pkg/storage/unified/search/remote_index_store.go +++ b/pkg/storage/unified/search/remote_index_store.go @@ -109,16 +109,16 @@ func NewBucketRemoteIndexStore(bucket resource.CDKBucket, lockBackend lockBacken // indexPrefix returns the object storage prefix for a namespaced resource + index key. func indexPrefix(ns resource.NamespacedResource, indexKey string) string { - return fmt.Sprintf("%s/%s.%s/%s/", ns.Namespace, ns.Group, ns.Resource, indexKey) + return fmt.Sprintf("%s/%s/", resourceSubPath(ns), indexKey) } // nsPrefix returns the object storage prefix for a namespaced resource (without index key). func nsPrefix(ns resource.NamespacedResource) string { - return fmt.Sprintf("%s/%s.%s/", ns.Namespace, ns.Group, ns.Resource) + return fmt.Sprintf("%s/", resourceSubPath(ns)) } func buildIndexLockKey(ns resource.NamespacedResource) string { - return fmt.Sprintf("%s/%s.%s/locks/build", ns.Namespace, ns.Group, ns.Resource) + return fmt.Sprintf("%s/locks/build", resourceSubPath(ns)) } func (s *BucketRemoteIndexStore) LockBuildIndex(ctx context.Context, nsResource resource.NamespacedResource) (IndexStoreLock, error) {
← Back to Alerts View on GitHub →