VYPR
Low severity2.5NVD Advisory· Published Jun 8, 2026

CVE-2026-11481

CVE-2026-11481

Description

A cross-project cache reuse vulnerability in grepai's Postgres Embedding Cache allows attackers to retrieve vectors from other projects using a weak hash lookup.

AI Insight

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

A cross-project cache reuse vulnerability in grepai's Postgres Embedding Cache allows attackers to retrieve vectors from other projects using a weak hash lookup.

Vulnerability

The function PostgresStore.LookupByContentHash in indexer/chunker.go of the Postgres Embedding Cache component in yoanbernabeu grepai up to version 0.35.0 is vulnerable. It performs embedding cache lookups using only the content_hash argument, failing to scope lookups by project_id. This allows a project to reuse a vector generated for another project if both chunks share the same raw-content hash [1].

Exploitation

An attacker must configure two different grepai projects to use the same Postgres DSN. The attacker then indexes or stores a victim chunk in one project (proj_victim) with a specific content_hash. Subsequently, from a separate project (proj_attacker), the attacker requests an embedding cache lookup for the same content_hash. The vulnerable query will return the victim project's vector because the lookup does not filter by project_id [1]. This attack requires local access and a high level of complexity [1].

Impact

Successful exploitation can lead to cross-project embedding reuse, loss of index integrity, corruption of search quality, and potential cache-hit side-channel observations. The vulnerability does not directly expose chunk data or source text, but it returns the vector associated with the victim's chunk [1]. The scope of compromise is limited to the retrieved vector data.

Mitigation

A pull request to fix this issue by scoping Postgres content-hash embedding cache lookups by project_id has been submitted and awaits acceptance [3]. The affected versions are up to 0.35.0. No patched version or workaround has been disclosed in the available references yet.

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
dbbdd4e66efc

Merge 6c12f483af7c65c38f9a215d3c38f9d4182158f3 into c4f294b38552dc4dad253d6a8118ba2799a2d61d

https://github.com/yoanbernabeu/grepai3em0May 20, 2026via nvd-ref
3 files changed · +94 3
  • store/postgres.go+5 3 modified
    @@ -16,6 +16,8 @@ type PostgresStore struct {
     	dimensions int
     }
     
    +const lookupByContentHashSQL = `SELECT vector FROM chunks WHERE project_id = $1 AND content_hash = $2 AND vector IS NOT NULL LIMIT 1`
    +
     func NewPostgresStore(ctx context.Context, dsn string, projectID string, vectorDimensions int) (*PostgresStore, error) {
     	pool, err := pgxpool.New(ctx, dsn)
     	if err != nil {
    @@ -61,7 +63,7 @@ func (s *PostgresStore) ensureSchema(ctx context.Context) error {
     			PRIMARY KEY (project_id, path)
     		)`,
     		`ALTER TABLE chunks ADD COLUMN IF NOT EXISTS content_hash TEXT DEFAULT ''`,
    -		`CREATE INDEX IF NOT EXISTS idx_chunks_content_hash ON chunks(content_hash) WHERE content_hash != ''`,
    +		`CREATE INDEX IF NOT EXISTS idx_chunks_project_content_hash ON chunks(project_id, content_hash) WHERE content_hash != ''`,
     		buildEnsureVectorSQL(s.dimensions),
     		// Migrate chunks primary key from (id) to (project_id, id) so that
     		// worktrees sharing the same database get their own chunk rows instead
    @@ -381,8 +383,8 @@ func (s *PostgresStore) LookupByContentHash(ctx context.Context, contentHash str
     
     	var vec pgvector.Vector
     	err := s.pool.QueryRow(ctx,
    -		`SELECT vector FROM chunks WHERE content_hash = $1 AND vector IS NOT NULL LIMIT 1`,
    -		contentHash,
    +		lookupByContentHashSQL,
    +		s.projectID, contentHash,
     	).Scan(&vec)
     
     	if err == pgx.ErrNoRows {
    
  • store/postgres_integration_test.go+70 0 added
    @@ -0,0 +1,70 @@
    +package store
    +
    +import (
    +	"context"
    +	"os"
    +	"testing"
    +	"time"
    +)
    +
    +func TestPostgresStoreLookupByContentHashIsProjectScoped(t *testing.T) {
    +	dsn := os.Getenv("GREPAI_POSTGRES_TEST_DSN")
    +	if dsn == "" {
    +		t.Skip("GREPAI_POSTGRES_TEST_DSN is not set")
    +	}
    +
    +	ctx := context.Background()
    +	victim, err := NewPostgresStore(ctx, dsn, "proj_victim", 3)
    +	if err != nil {
    +		t.Fatalf("failed to create victim store: %v", err)
    +	}
    +	defer victim.Close()
    +
    +	attacker, err := NewPostgresStore(ctx, dsn, "proj_attacker", 3)
    +	if err != nil {
    +		t.Fatalf("failed to create attacker store: %v", err)
    +	}
    +	defer attacker.Close()
    +
    +	if _, err := victim.pool.Exec(ctx, `TRUNCATE TABLE chunks, documents`); err != nil {
    +		t.Fatalf("failed to clean tables: %v", err)
    +	}
    +
    +	contentHash := "sha256:shared-content"
    +	victimVector := []float32{9.9, 8.8, 7.7}
    +	if err := victim.SaveChunks(ctx, []Chunk{{
    +		ID:          "README.md_0",
    +		FilePath:    "README.md",
    +		StartLine:   1,
    +		EndLine:     1,
    +		Content:     "File: README.md\n\nShared content",
    +		Vector:      victimVector,
    +		Hash:        "victim-row",
    +		ContentHash: contentHash,
    +		UpdatedAt:   time.Now().UTC(),
    +	}}); err != nil {
    +		t.Fatalf("failed to save victim chunk: %v", err)
    +	}
    +
    +	if _, found, err := attacker.LookupByContentHash(ctx, contentHash); err != nil {
    +		t.Fatalf("attacker lookup failed: %v", err)
    +	} else if found {
    +		t.Fatal("attacker project should not reuse victim project's cached vector")
    +	}
    +
    +	got, found, err := victim.LookupByContentHash(ctx, contentHash)
    +	if err != nil {
    +		t.Fatalf("victim lookup failed: %v", err)
    +	}
    +	if !found {
    +		t.Fatal("victim project should find its own cached vector")
    +	}
    +	if len(got) != len(victimVector) {
    +		t.Fatalf("victim vector length mismatch: got %d, want %d", len(got), len(victimVector))
    +	}
    +	for i := range got {
    +		if got[i] != victimVector[i] {
    +			t.Fatalf("victim vector mismatch at %d: got %v, want %v", i, got, victimVector)
    +		}
    +	}
    +}
    
  • store/postgres_sql_test.go+19 0 modified
    @@ -48,6 +48,25 @@ func TestBuildEnsureVectorSQL_ContainsExpectedFragments(t *testing.T) {
     	}
     }
     
    +func TestLookupByContentHashSQLScopesByProject(t *testing.T) {
    +	expected := []string{
    +		"SELECT vector FROM chunks",
    +		"project_id = $1",
    +		"content_hash = $2",
    +		"vector IS NOT NULL",
    +	}
    +
    +	for _, frag := range expected {
    +		if !strings.Contains(lookupByContentHashSQL, frag) {
    +			t.Fatalf("expected lookup SQL to contain %q, got: %q", frag, lookupByContentHashSQL)
    +		}
    +	}
    +
    +	if strings.Contains(lookupByContentHashSQL, "WHERE content_hash = $1") {
    +		t.Fatalf("lookup SQL must not use content_hash as the first and only scope: %q", lookupByContentHashSQL)
    +	}
    +}
    +
     // strconvItoa converts an int to string without importing strconv.
     // This keeps the test dependency surface minimal.
     func strconvItoa(n int) string {
    

Vulnerability mechanics

Root cause

"The PostgresStore.LookupByContentHash function did not scope lookups by project_id, allowing cross-project vector reuse."

Attack vector

An attacker must have local access to a shared Postgres backend used by multiple grepai projects. By configuring a second project to use the same Postgres DSN as a victim project, the attacker can then request an embedding cache lookup using a content hash that exists in the victim project. The vulnerable lookup query, which only filters by content_hash, will return the victim's vector to the attacker's project, leading to cache reuse and potential integrity loss [ref_id=1].

Affected code

The vulnerability resides in the `PostgresStore.LookupByContentHash` function within the `store/postgres.go` file. The SQL query used for lookup, `SELECT vector FROM chunks WHERE content_hash = $1 AND vector IS NOT NULL LIMIT 1`, was missing a filter for `project_id` [ref_id=1]. The indexer also calls this function, as shown in `indexer/indexer.go`.

What the fix does

The patch modifies the `lookupByContentHashSQL` query to include `project_id = $1` in the WHERE clause [patch_id=5164436]. This ensures that the `LookupByContentHash` function now correctly scopes its database lookups to the current project, preventing other projects from accessing or reusing cached vectors. The associated test `TestPostgresStoreLookupByContentHashIsProjectScoped` verifies this behavior by confirming that an attacker project cannot retrieve a vector stored by a victim project using the same content hash.

Preconditions

  • configMultiple grepai projects must be configured to share the same Postgres DSN.
  • inputThe attacker must know or be able to determine a content_hash present in the victim project.

Reproduction

The provided reference [ref_id=1] includes a detailed description of the steps to reproduce the vulnerability, including code snippets and observed output from an E2E verification using an AgentScan PoC. The steps involve configuring two projects with the same DSN, indexing a victim chunk, and then requesting a lookup for the same content_hash from the attacker project.

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.