VYPR
Medium severity4.2NVD Advisory· Published Jun 8, 2026

CVE-2026-11479

CVE-2026-11479

Description

Qdrant index poisoning in grepai allows cross-project data overwrites via manipulated chunk IDs, impacting data integrity and search results.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Qdrant index poisoning in grepai allows cross-project data overwrites via manipulated chunk IDs, impacting data integrity and search results.

Vulnerability

A vulnerability exists in yoanbernabeu grepai versions prior to the fix in pull request #248, specifically affecting the indexer/chunker.go component when using a shared Qdrant collection. The issue arises because chunk IDs are generated using only filePath and chunkIndex, allowing chunks from different projects with identical relative file paths and chunk indices to overwrite each other in the Qdrant backend due to Qdrant's upsert semantics [1].

Exploitation

An attacker can exploit this vulnerability by configuring two separate grepai projects to use the same Qdrant collection. If both projects contain a file with the same name (e.g., README.md) and an indexer processes chunks with the same relative path and chunk index from both projects, the second project's data will overwrite the first project's data in the shared Qdrant collection. This requires the attacker to have control over at least one of the projects indexing into the shared collection and can be performed remotely if the Qdrant instance is accessible [1].

Impact

Successful exploitation leads to index integrity issues and data poisoning within the shared Qdrant collection. This means that search results and readback operations will return incorrect or attacker-controlled data, potentially causing significant disruption to applications relying on the integrity of the vector index. The scope of the compromise is limited to the data within the affected shared Qdrant collection [1].

Mitigation

A pull request (#248) has been submitted to address this vulnerability by namespacing Qdrant point IDs by project/workspace to prevent chunk overwrites in shared collections. As of the publication date, this fix awaits acceptance. No patched version or workaround has been disclosed in the available references [3].

AI Insight generated on Jun 8, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
aa470525bdf4

Merge f6dbf8dbb74c1f80aec37531964665bb71bdc6db into c4f294b38552dc4dad253d6a8118ba2799a2d61d

https://github.com/yoanbernabeu/grepai3em0May 20, 2026via nvd-ref
7 files changed · +197 49
  • cli/search.go+2 2 modified
    @@ -252,7 +252,7 @@ func runSearch(cmd *cobra.Command, args []string) error {
     			collectionName = store.SanitizeCollectionName(projectRoot)
     		}
     		var err error
    -		st, err = store.NewQdrantStore(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
    +		st, err = store.NewQdrantStoreWithNamespace(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, projectRoot, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
     		if err != nil {
     			return fmt.Errorf("failed to connect to qdrant: %w", err)
     		}
    @@ -586,7 +586,7 @@ func runWorkspaceSearch(ctx context.Context, query string, projects []string, pa
     		if collectionName == "" {
     			collectionName = "workspace_" + ws.Name
     		}
    -		st, err = store.NewQdrantStore(ctx, ws.Store.Qdrant.Endpoint, ws.Store.Qdrant.Port, ws.Store.Qdrant.UseTLS, collectionName, ws.Store.Qdrant.APIKey, ws.Embedder.GetDimensions())
    +		st, err = store.NewQdrantStoreWithNamespace(ctx, ws.Store.Qdrant.Endpoint, ws.Store.Qdrant.Port, ws.Store.Qdrant.UseTLS, collectionName, projectID, ws.Store.Qdrant.APIKey, ws.Embedder.GetDimensions())
     		if err != nil {
     			return fmt.Errorf("failed to connect to qdrant: %w", err)
     		}
    
  • cli/status.go+1 1 modified
    @@ -470,7 +470,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
     			collectionName = store.SanitizeCollectionName(projectRoot)
     		}
     		var err error
    -		st, err = store.NewQdrantStore(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
    +		st, err = store.NewQdrantStoreWithNamespace(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, projectRoot, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
     		if err != nil {
     			return fmt.Errorf("failed to connect to qdrant: %w", err)
     		}
    
  • cli/watch.go+2 2 modified
    @@ -460,7 +460,7 @@ func initializeStore(ctx context.Context, cfg *config.Config, projectRoot string
     		if collectionName == "" {
     			collectionName = store.SanitizeCollectionName(projectRoot)
     		}
    -		return store.NewQdrantStore(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
    +		return store.NewQdrantStoreWithNamespace(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, projectRoot, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
     	default:
     		return nil, fmt.Errorf("unknown storage backend: %s", cfg.Store.Backend)
     	}
    @@ -2876,7 +2876,7 @@ func initializeWorkspaceStore(ctx context.Context, ws *config.Workspace) (store.
     		if collectionName == "" {
     			collectionName = "workspace_" + ws.Name
     		}
    -		return store.NewQdrantStore(ctx, ws.Store.Qdrant.Endpoint, ws.Store.Qdrant.Port, ws.Store.Qdrant.UseTLS, collectionName, ws.Store.Qdrant.APIKey, ws.Embedder.GetDimensions())
    +		return store.NewQdrantStoreWithNamespace(ctx, ws.Store.Qdrant.Endpoint, ws.Store.Qdrant.Port, ws.Store.Qdrant.UseTLS, collectionName, projectID, ws.Store.Qdrant.APIKey, ws.Embedder.GetDimensions())
     	default:
     		return nil, fmt.Errorf("unsupported backend for workspace: %s", ws.Store.Backend)
     	}
    
  • mcp/server.go+2 2 modified
    @@ -1010,7 +1010,7 @@ func (s *Server) createWorkspaceStore(ctx context.Context, ws *config.Workspace)
     		if collectionName == "" {
     			collectionName = "workspace_" + ws.Name
     		}
    -		return store.NewQdrantStore(ctx, ws.Store.Qdrant.Endpoint, ws.Store.Qdrant.Port, ws.Store.Qdrant.UseTLS, collectionName, ws.Store.Qdrant.APIKey, ws.Embedder.GetDimensions())
    +		return store.NewQdrantStoreWithNamespace(ctx, ws.Store.Qdrant.Endpoint, ws.Store.Qdrant.Port, ws.Store.Qdrant.UseTLS, collectionName, projectID, ws.Store.Qdrant.APIKey, ws.Embedder.GetDimensions())
     	default:
     		return nil, fmt.Errorf("unsupported backend for workspace: %s", ws.Store.Backend)
     	}
    @@ -2052,7 +2052,7 @@ func (s *Server) createStore(ctx context.Context, cfg *config.Config) (store.Vec
     		if collectionName == "" {
     			collectionName = store.SanitizeCollectionName(s.projectRoot)
     		}
    -		return store.NewQdrantStore(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
    +		return store.NewQdrantStoreWithNamespace(ctx, cfg.Store.Qdrant.Endpoint, cfg.Store.Qdrant.Port, cfg.Store.Qdrant.UseTLS, collectionName, s.projectRoot, cfg.Store.Qdrant.APIKey, cfg.Embedder.GetDimensions())
     	default:
     		return nil, fmt.Errorf("unknown storage backend: %s", cfg.Store.Backend)
     	}
    
  • store/qdrant.go+61 36 modified
    @@ -21,10 +21,11 @@ func sanitizeUTF8(s string) string {
     }
     
     type QdrantStore struct {
    -	client         *qdrant.Client
    -	collectionName string
    -	dimensions     int
    -	apiKey         string
    +	client           *qdrant.Client
    +	collectionName   string
    +	projectNamespace string
    +	dimensions       int
    +	apiKey           string
     }
     
     func parseHost(endpoint string) string {
    @@ -43,11 +44,18 @@ func parseHost(endpoint string) string {
     }
     
     func NewQdrantStore(ctx context.Context, endpoint string, port int, useTLS bool, collection, apiKey string, dimensions int) (*QdrantStore, error) {
    +	return NewQdrantStoreWithNamespace(ctx, endpoint, port, useTLS, collection, collection, apiKey, dimensions)
    +}
    +
    +func NewQdrantStoreWithNamespace(ctx context.Context, endpoint string, port int, useTLS bool, collection, projectNamespace, apiKey string, dimensions int) (*QdrantStore, error) {
     	host := parseHost(endpoint)
     
     	if port <= 0 {
     		port = 6334
     	}
    +	if projectNamespace == "" {
    +		projectNamespace = collection
    +	}
     
     	client, err := qdrant.NewClient(&qdrant.Config{
     		Host:   host,
    @@ -60,10 +68,11 @@ func NewQdrantStore(ctx context.Context, endpoint string, port int, useTLS bool,
     	}
     
     	store := &QdrantStore{
    -		client:         client,
    -		collectionName: collection,
    -		dimensions:     dimensions,
    -		apiKey:         apiKey,
    +		client:           client,
    +		collectionName:   collection,
    +		projectNamespace: projectNamespace,
    +		dimensions:       dimensions,
    +		apiKey:           apiKey,
     	}
     
     	if err := store.ensureCollection(ctx); err != nil {
    @@ -102,6 +111,11 @@ func (s *QdrantStore) ensureCollection(ctx context.Context) error {
     		FieldName:      "content_hash",
     		FieldType:      qdrant.PtrOf(qdrant.FieldType_FieldTypeKeyword),
     	})
    +	_, _ = s.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
    +		CollectionName: s.collectionName,
    +		FieldName:      "project_namespace",
    +		FieldType:      qdrant.PtrOf(qdrant.FieldType_FieldTypeKeyword),
    +	})
     
     	return nil
     }
    @@ -116,7 +130,22 @@ func SanitizeCollectionName(path string) string {
     
     func (s *QdrantStore) getUUIDForChunk(chunkID string) uuid.UUID {
     	namespace := uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
    -	return uuid.NewSHA1(namespace, []byte(chunkID))
    +	return uuid.NewSHA1(namespace, []byte(s.namespacedChunkID(chunkID)))
    +}
    +
    +func (s *QdrantStore) namespacedChunkID(chunkID string) string {
    +	return s.projectNamespace + "\x00" + chunkID
    +}
    +
    +func (s *QdrantStore) namespaceCondition() *qdrant.Condition {
    +	return qdrant.NewMatch("project_namespace", s.projectNamespace)
    +}
    +
    +func (s *QdrantStore) filterWithNamespace(conditions ...*qdrant.Condition) *qdrant.Filter {
    +	must := make([]*qdrant.Condition, 0, len(conditions)+1)
    +	must = append(must, s.namespaceCondition())
    +	must = append(must, conditions...)
    +	return &qdrant.Filter{Must: must}
     }
     
     func (s *QdrantStore) SaveChunks(ctx context.Context, chunks []Chunk) error {
    @@ -143,6 +172,7 @@ func (s *QdrantStore) SaveChunks(ctx context.Context, chunks []Chunk) error {
     	_, err := s.client.Upsert(ctx, &qdrant.UpsertPoints{
     		CollectionName: s.collectionName,
     		Points:         points,
    +		Wait:           qdrant.PtrOf(true),
     	})
     	if err != nil {
     		return fmt.Errorf("failed to upsert points: %w", err)
    @@ -183,13 +213,18 @@ func (s *QdrantStore) buildChunkPayload(chunk Chunk) (map[string]*qdrant.Value,
     	if err != nil {
     		return nil, fmt.Errorf("failed to create updated_at value: %w", err)
     	}
    +	projectNamespaceVal, err := qdrant.NewValue(s.projectNamespace)
    +	if err != nil {
    +		return nil, fmt.Errorf("failed to create project_namespace value: %w", err)
    +	}
     
     	payload["file_path"] = filePathVal
     	payload["start_line"] = startLineVal
     	payload["end_line"] = endLineVal
     	payload["content"] = contentVal
     	payload["hash"] = hashVal
     	payload["updated_at"] = updatedAtVal
    +	payload["project_namespace"] = projectNamespaceVal
     
     	if chunk.ContentHash != "" {
     		contentHashVal, err := qdrant.NewValue(chunk.ContentHash)
    @@ -203,11 +238,7 @@ func (s *QdrantStore) buildChunkPayload(chunk Chunk) (map[string]*qdrant.Value,
     }
     
     func (s *QdrantStore) DeleteByFile(ctx context.Context, filePath string) error {
    -	filter := &qdrant.Filter{
    -		Must: []*qdrant.Condition{
    -			qdrant.NewMatch("file_path", filePath),
    -		},
    -	}
    +	filter := s.filterWithNamespace(qdrant.NewMatch("file_path", filePath))
     
     	_, err := s.client.Delete(ctx, &qdrant.DeletePoints{
     		CollectionName: s.collectionName,
    @@ -244,6 +275,7 @@ func (s *QdrantStore) Search(ctx context.Context, queryVector []float32, limit i
     	searchResult, err := s.client.Query(ctx, &qdrant.QueryPoints{
     		CollectionName: s.collectionName,
     		Query:          qdrant.NewQuery(queryVector...),
    +		Filter:         s.filterWithNamespace(),
     		Limit:          qdrant.PtrOf(fetchLimitU64),
     		WithPayload:    qdrant.NewWithPayloadInclude("file_path", "start_line", "end_line", "content", "hash", "updated_at"),
     	})
    @@ -305,11 +337,7 @@ func (s *QdrantStore) parseChunkPayload(payload map[string]*qdrant.Value) *Chunk
     }
     
     func (s *QdrantStore) GetDocument(ctx context.Context, filePath string) (*Document, error) {
    -	filter := &qdrant.Filter{
    -		Must: []*qdrant.Condition{
    -			qdrant.NewMatch("file_path", filePath),
    -		},
    -	}
    +	filter := s.filterWithNamespace(qdrant.NewMatch("file_path", filePath))
     
     	scrollResult, err := s.client.Scroll(ctx, &qdrant.ScrollPoints{
     		CollectionName: s.collectionName,
    @@ -344,6 +372,7 @@ func (s *QdrantStore) DeleteDocument(ctx context.Context, filePath string) error
     func (s *QdrantStore) ListDocuments(ctx context.Context) ([]string, error) {
     	scrollResult, err := s.client.Scroll(ctx, &qdrant.ScrollPoints{
     		CollectionName: s.collectionName,
    +		Filter:         s.filterWithNamespace(),
     		Limit:          qdrant.PtrOf(uint32(1000)),
     		WithPayload:    qdrant.NewWithPayloadInclude("file_path"),
     	})
    @@ -379,19 +408,21 @@ func (s *QdrantStore) Close() error {
     }
     
     func (s *QdrantStore) GetStats(ctx context.Context) (*IndexStats, error) {
    -	collectionInfo, err := s.client.GetCollectionInfo(ctx, s.collectionName)
    +	count, err := s.client.Count(ctx, &qdrant.CountPoints{
    +		CollectionName: s.collectionName,
    +		Filter:         s.filterWithNamespace(),
    +		Exact:          qdrant.PtrOf(true),
    +	})
     	if err != nil {
    -		return nil, fmt.Errorf("failed to get collection info: %w", err)
    +		return nil, fmt.Errorf("failed to count points: %w", err)
     	}
    -
    -	pointsCount := *collectionInfo.PointsCount
    -	if pointsCount > uint64(^uint(0)>>1) {
    -		return nil, fmt.Errorf("points count %d exceeds maximum int value", pointsCount)
    +	if count > uint64(^uint(0)>>1) {
    +		return nil, fmt.Errorf("points count %d exceeds maximum int value", count)
     	}
     
     	stats := &IndexStats{
     		TotalFiles:  0,
    -		TotalChunks: int(pointsCount),
    +		TotalChunks: int(count),
     		IndexSize:   0,
     		LastUpdated: time.Now(),
     	}
    @@ -402,6 +433,7 @@ func (s *QdrantStore) GetStats(ctx context.Context) (*IndexStats, error) {
     func (s *QdrantStore) ListFilesWithStats(ctx context.Context) ([]FileStats, error) {
     	scrollResult, err := s.client.Scroll(ctx, &qdrant.ScrollPoints{
     		CollectionName: s.collectionName,
    +		Filter:         s.filterWithNamespace(),
     		Limit:          qdrant.PtrOf(uint32(10000)),
     		WithPayload:    qdrant.NewWithPayloadInclude("file_path", "start_line", "end_line"),
     	})
    @@ -439,11 +471,7 @@ func (s *QdrantStore) ListFilesWithStats(ctx context.Context) ([]FileStats, erro
     }
     
     func (s *QdrantStore) GetChunksForFile(ctx context.Context, filePath string) ([]Chunk, error) {
    -	filter := &qdrant.Filter{
    -		Must: []*qdrant.Condition{
    -			qdrant.NewMatch("file_path", filePath),
    -		},
    -	}
    +	filter := s.filterWithNamespace(qdrant.NewMatch("file_path", filePath))
     
     	scrollResult, err := s.client.Scroll(ctx, &qdrant.ScrollPoints{
     		CollectionName: s.collectionName,
    @@ -473,6 +501,7 @@ func (s *QdrantStore) GetChunksForFile(ctx context.Context, filePath string) ([]
     func (s *QdrantStore) GetAllChunks(ctx context.Context) ([]Chunk, error) {
     	scrollResult, err := s.client.Scroll(ctx, &qdrant.ScrollPoints{
     		CollectionName: s.collectionName,
    +		Filter:         s.filterWithNamespace(),
     		Limit:          qdrant.PtrOf(uint32(100000)),
     		WithPayload:    qdrant.NewWithPayloadInclude("file_path", "start_line", "end_line", "content", "hash", "updated_at"),
     		WithVectors:    qdrant.NewWithVectors(true),
    @@ -501,11 +530,7 @@ func (s *QdrantStore) LookupByContentHash(ctx context.Context, contentHash strin
     		return nil, false, nil
     	}
     
    -	filter := &qdrant.Filter{
    -		Must: []*qdrant.Condition{
    -			qdrant.NewMatch("content_hash", contentHash),
    -		},
    -	}
    +	filter := s.filterWithNamespace(qdrant.NewMatch("content_hash", contentHash))
     
     	scrollResult, err := s.client.Scroll(ctx, &qdrant.ScrollPoints{
     		CollectionName: s.collectionName,
    
  • store/qdrant_integration_test.go+103 0 added
    @@ -0,0 +1,103 @@
    +package store
    +
    +import (
    +	"context"
    +	"os"
    +	"strings"
    +	"testing"
    +	"time"
    +)
    +
    +func TestQdrantStore_SharedCollectionIsolatedByProjectNamespace(t *testing.T) {
    +	endpoint := os.Getenv("GREPAI_QDRANT_TEST_ENDPOINT")
    +	if endpoint == "" {
    +		t.Skip("set GREPAI_QDRANT_TEST_ENDPOINT to run Qdrant integration tests")
    +	}
    +
    +	ctx := context.Background()
    +	collection := "grepai_test_namespace_" + strings.ReplaceAll(time.Now().Format("20060102150405.000000000"), ".", "_")
    +
    +	victim, err := NewQdrantStoreWithNamespace(ctx, endpoint, 6334, false, collection, "/repos/victim", "", 3)
    +	if err != nil {
    +		t.Fatalf("NewQdrantStore victim: %v", err)
    +	}
    +	defer victim.Close()
    +
    +	attacker, err := NewQdrantStoreWithNamespace(ctx, endpoint, 6334, false, collection, "/repos/attacker", "", 3)
    +	if err != nil {
    +		t.Fatalf("NewQdrantStore attacker: %v", err)
    +	}
    +	defer attacker.Close()
    +
    +	now := time.Now()
    +	victimContent := "File: README.md\n\nVictim private deployment runbook"
    +	attackerContent := "File: README.md\n\nAttacker decoy repository content"
    +
    +	if err := victim.SaveChunks(ctx, []Chunk{{
    +		ID:        "README.md_0",
    +		FilePath:  "README.md",
    +		StartLine: 1,
    +		EndLine:   1,
    +		Content:   victimContent,
    +		Vector:    []float32{1, 0, 0},
    +		Hash:      "victim-hash",
    +		UpdatedAt: now,
    +	}}); err != nil {
    +		t.Fatalf("victim SaveChunks: %v", err)
    +	}
    +
    +	if err := attacker.SaveChunks(ctx, []Chunk{{
    +		ID:        "README.md_0",
    +		FilePath:  "README.md",
    +		StartLine: 1,
    +		EndLine:   1,
    +		Content:   attackerContent,
    +		Vector:    []float32{1, 0, 0},
    +		Hash:      "attacker-hash",
    +		UpdatedAt: now,
    +	}}); err != nil {
    +		t.Fatalf("attacker SaveChunks: %v", err)
    +	}
    +
    +	victimChunks, err := victim.GetChunksForFile(ctx, "README.md")
    +	if err != nil {
    +		t.Fatalf("victim GetChunksForFile: %v", err)
    +	}
    +	if len(victimChunks) != 1 {
    +		t.Fatalf("victim chunks = %d, want 1", len(victimChunks))
    +	}
    +	if victimChunks[0].Content != victimContent {
    +		t.Fatalf("victim content = %q, want %q", victimChunks[0].Content, victimContent)
    +	}
    +	victimResults, err := victim.Search(ctx, []float32{1, 0, 0}, 10, SearchOptions{})
    +	if err != nil {
    +		t.Fatalf("victim Search: %v", err)
    +	}
    +	if len(victimResults) != 1 {
    +		t.Fatalf("victim search results = %d, want 1", len(victimResults))
    +	}
    +	if victimResults[0].Chunk.Content != victimContent {
    +		t.Fatalf("victim search content = %q, want %q", victimResults[0].Chunk.Content, victimContent)
    +	}
    +
    +	attackerChunks, err := attacker.GetChunksForFile(ctx, "README.md")
    +	if err != nil {
    +		t.Fatalf("attacker GetChunksForFile: %v", err)
    +	}
    +	if len(attackerChunks) != 1 {
    +		t.Fatalf("attacker chunks = %d, want 1", len(attackerChunks))
    +	}
    +	if attackerChunks[0].Content != attackerContent {
    +		t.Fatalf("attacker content = %q, want %q", attackerChunks[0].Content, attackerContent)
    +	}
    +	attackerResults, err := attacker.Search(ctx, []float32{1, 0, 0}, 10, SearchOptions{})
    +	if err != nil {
    +		t.Fatalf("attacker Search: %v", err)
    +	}
    +	if len(attackerResults) != 1 {
    +		t.Fatalf("attacker search results = %d, want 1", len(attackerResults))
    +	}
    +	if attackerResults[0].Chunk.Content != attackerContent {
    +		t.Fatalf("attacker search content = %q, want %q", attackerResults[0].Chunk.Content, attackerContent)
    +	}
    +}
    
  • store/qdrant_test.go+26 6 modified
    @@ -96,6 +96,19 @@ func TestGetUUIDForChunk(t *testing.T) {
     	}
     }
     
    +func TestGetUUIDForChunk_IncludesProjectNamespace(t *testing.T) {
    +	chunkID := "README.md_0"
    +	victimStore := &QdrantStore{projectNamespace: "/repos/victim"}
    +	attackerStore := &QdrantStore{projectNamespace: "/repos/attacker"}
    +
    +	victimUUID := victimStore.getUUIDForChunk(chunkID)
    +	attackerUUID := attackerStore.getUUIDForChunk(chunkID)
    +
    +	if victimUUID == attackerUUID {
    +		t.Fatalf("expected different UUIDs for same chunk ID in different namespaces, got %s", victimUUID)
    +	}
    +}
    +
     // TestParseChunkPayload tests parsing of Qdrant point payloads
     func TestParseChunkPayload(t *testing.T) {
     	store := &QdrantStore{}
    @@ -202,7 +215,7 @@ func TestParseChunkPayload(t *testing.T) {
     
     // TestBuildChunkPayload tests building of Qdrant payloads from chunks
     func TestBuildChunkPayload(t *testing.T) {
    -	store := &QdrantStore{}
    +	store := &QdrantStore{projectNamespace: "/repos/test"}
     
     	now := time.Now().UTC()
     
    @@ -278,6 +291,12 @@ func TestBuildChunkPayload(t *testing.T) {
     				} else if val.GetStringValue() != tt.chunk.Hash {
     					t.Errorf("expected hash %s, got %s", tt.chunk.Hash, val.GetStringValue())
     				}
    +
    +				if val, ok := payload["project_namespace"]; !ok {
    +					t.Error("expected project_namespace in payload")
    +				} else if val.GetStringValue() != store.projectNamespace {
    +					t.Errorf("expected project_namespace %s, got %s", store.projectNamespace, val.GetStringValue())
    +				}
     			}
     		})
     	}
    @@ -330,14 +349,15 @@ func TestSaveChunks_EmptySlice(t *testing.T) {
     // TestQdrantStore_StructFields verifies struct has all expected fields
     func TestQdrantStore_StructFields(t *testing.T) {
     	store := &QdrantStore{
    -		collectionName: "test-collection",
    -		dimensions:     768,
    -		apiKey:         "test-key",
    +		collectionName:   "test-collection",
    +		projectNamespace: "/repos/test",
    +		dimensions:       768,
    +		apiKey:           "test-key",
     	}
     
     	// Use all fields to avoid unused variable warnings
    -	if store.collectionName != "test-collection" || store.dimensions != 768 || store.apiKey != "test-key" {
    -		t.Errorf("expected collectionName 'test-collection', dimensions 768, and apiKey 'test-key'")
    +	if store.collectionName != "test-collection" || store.projectNamespace != "/repos/test" || store.dimensions != 768 || store.apiKey != "test-key" {
    +		t.Errorf("expected collectionName 'test-collection', projectNamespace '/repos/test', dimensions 768, and apiKey 'test-key'")
     	}
     }
     
    

Vulnerability mechanics

Root cause

"The Qdrant store uses chunk IDs derived solely from file path and index, leading to collisions when multiple projects share a collection."

Attack vector

An attacker can configure multiple grepai projects to use the same Qdrant collection. By indexing a project with a file (e.g., README.md) that has the same relative path and chunk index as a file in a previously indexed project, the attacker can overwrite the original project's data with their own. This manipulation leads to index poisoning and incorrect search results for the victim project [ref_id=1]. The attack is remote and complex, requiring careful coordination of indexing operations [CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:L].

Affected code

The vulnerability lies in the `store/qdrant.go` file, specifically within the `getUUIDForChunk` function, which generates point IDs without considering project-specific namespaces. The `SaveChunks` function, which uses these IDs for upserting points, is also implicated. The `indexer/chunker.go` file is where chunk IDs are generated based on file path and index, contributing to the collision issue [ref_id=1].

What the fix does

The patch introduces a `projectNamespace` field to the `QdrantStore` and modifies the `getUUIDForChunk` function to incorporate this namespace into the generated UUID. This ensures that chunks from different projects, even with identical file paths and indices, will have unique identifiers in Qdrant. Additionally, filters are updated to include the `project_namespace` in operations like `Search`, `DeleteByFile`, `GetDocument`, `ListDocuments`, `GetStats`, `ListFilesWithStats`, `GetChunksForFile`, `GetAllChunks`, and `LookupByContentHash`, thereby isolating data across different projects within a shared collection [patch_id=5164434].

Preconditions

  • configMultiple grepai projects must be configured to use the same Qdrant collection.
  • inputThe attacker must control a project that can be indexed into the shared Qdrant collection.

Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.