CVE-2026-32247

CVSS 8.1 · High Patched
Type
Vulnerability research
Published
Reading time
9 min read
Author
rootzero
Lire en français
Lire en français →

CVE-2026-32247: Cypher injection in Graphiti through search filter node labels

Graphiti pasted search filter node labels into raw Cypher, allowing a caller to bypass group filtering; writes depend on the database route and permissions.

For your decision

Impact
The search group filter can be bypassed, undermining expected isolation between data.
Conditions
The caller must control search filter labels. Final impact depends on routing and database permissions.
Recommended action
Update graphiti-core to 0.28.2 or later and review group access boundaries.

Advisory facts

Product
Graphiti
Package
pypi · graphiti-core
Affected versions
≤ 0.28.1
Fixed in
0.28.2
CVSS
8.1 High
Weakness
CWE-943
Access needed
Account required
Advisory published
Credit
rootzero

Investigation summary

Before 0.28.2, graphiti-core joined the node_labels of a SearchFilters object with | and concatenated the result into the WHERE clause of its Neo4j, FalkorDB and Neptune queries. A caller who could choose those labels could alter the query and bypass its group_id filter. Read access across groups follows from the query construction; write clauses also depend on the backend route and database permissions. A prompt-injection route through an agent is possible but was not demonstrated by the query proof. Fixed in 0.28.2.

The argument that escaped the Cypher filter

We began by tracing Graphiti’s MCP tool arguments into Cypher. Most reached the driver as parameters. entity_types instead became a label expression assembled as a string. We followed it from search_nodes through SearchFilters to the final WHERE clause: type checking required a list of strings, but nothing constrained the contents of each string. The Kuzu path, which parameterised labels, showed that this construction was not unavoidable.

Our first payload assumed a parenthesised label filter. When we compared it with the actual 0.28.1 query template, we saw that the constructor emitted no parentheses; that exact payload was not a reliable backend proof. We reduced the test to the real boundary: a label value could introduce OR true // before the group_id condition, leaving the following query intact. The retained PoC prints generated Cypher rather than returning another group’s rows from a live database. Destructive clauses illustrate what database rights might permit, not an effect we observed. We reported the construction flaw on 10 March 2026. The next day the maintainers merged label validation and released graphiti-core 0.28.2 with CVE-2026-32247. An agent following a malicious instruction to choose entity_types is a separate, untested step.

The tenant boundary we traced

Graphiti stores agent memory in a graph. group_id separates tenants in the query, while its MCP tools expose search filters supplied by the caller.

Two properties make the query layer worth a close look. First, Graphiti partitions data with a group_id property, which applications use to keep users or tenants apart inside a single database, and that partition exists only in the queries Graphiti generates. Second, in the MCP deployment the arguments of the search tools are written by a language model, and that model may have read text controlled by someone else (a document, a web page, a chat message) just before it decides what to call.

From entity_types to query text

Following the entity_types argument of search_nodes in 0.28.1 gives this chain:

mcp_server/src/graphiti_mcp_server.py:440   SearchFilters(node_labels=entity_types)
mcp_server/src/graphiti_mcp_server.py:447   client.search_(..., search_filter=search_filters)
graphiti_core/search/search_filters.py:91   node_labels = '|'.join(filters.node_labels)
graphiti_core/search/search_filters.py:92   node_label_filter = 'n:' + node_labels
driver/neo4j/operations/search_ops.py:99    filter_query = ' WHERE ' + (' AND '.join(filter_queries))

The MCP layer declares entity_types as list[str] | None, so it checks the type and nothing else. SearchFilters is a pydantic model, but its node_labels field had no validator. The most telling detail sits in the same function: on Kuzu, Graphiti passes the labels as a $labels parameter. The safe form already existed in the codebase; the other three backends used concatenation.

Root cause

The filter constructor in 0.28.1 reads:

# graphiti_core/search/search_filters.py (0.28.1)
def node_search_filter_query_constructor(filters, provider):
    filter_queries: list[str] = []
    filter_params: dict[str, Any] = {}

    if filters.node_labels is not None:
        if provider == GraphProvider.KUZU:
            node_label_filter = 'list_has_all(n.labels, $labels)'
            filter_params['labels'] = filters.node_labels
        else:
            node_labels = '|'.join(filters.node_labels)
            node_label_filter = 'n:' + node_labels
        filter_queries.append(node_label_filter)

    return filter_queries, filter_params

edge_search_filter_query_constructor() repeats the pattern at lines 134 and 135, with the labels inserted twice ('n:' + node_labels + ' AND m:' + node_labels). The search operations then place the fragment in front of the group filter:

# graphiti_core/driver/neo4j/operations/search_ops.py (0.28.1), node_fulltext_search()
if group_ids is not None:
    filter_queries.append('n.group_id IN $group_ids')
    filter_params['group_ids'] = group_ids

filter_query = ''
if filter_queries:
    filter_query = ' WHERE ' + (' AND '.join(filter_queries))

cypher = (
    get_nodes_query('node_name_and_summary', '$query', limit=limit, provider=GraphProvider.NEO4J)
    + 'YIELD node AS n, score'
    + filter_query
    + """
    WITH n, score
    ORDER BY score DESC
    LIMIT $limit
    RETURN
    """
    + get_entity_node_return_query(GraphProvider.NEO4J)
)

With entity_types=["Person", "Organization"] and a group set, the first line of the query becomes:

CALL db.index.fulltext.queryNodes("node_name_and_summary", $query, {limit: $limit})YIELD node AS n, score WHERE n:Person|Organization AND n.group_id IN $group_ids

This is CWE-943, improper neutralisation of special elements in data query logic. Three facts shape the exploitation: everything after n: is attacker text, the label filter comes before the tenant filter on the same line, and the query continues on the next lines with WITH n, score. The FalkorDB and Neptune search operations build their queries the same way.

Exploitation

The public proof of concept reproduces the vulnerable string construction and prints the MCP calls that reach it. Its payloads close the label expression, open a new clause with WITH n, and comment out the rest of the line with //:

{
  "tool": "search_nodes",
  "arguments": {
    "query": "anything",
    "entity_types": ["Entity`) WITH n MATCH (x) DETACH DELETE x //"]
  }
}

The read variant replaces DETACH DELETE x with RETURN x, and a third variant selects the nodes of a chosen victim group. The maintainers reused the destructive string verbatim in their regression tests.

The PoC writes the payload for a parenthesised filter (WHERE (n:...)). The 0.28.1 constructors emit the label filter without parentheses, so against those templates no closing backtick or parenthesis is needed. The shortest fragment that removes tenant isolation is a tautology followed by a comment:

CALL db.index.fulltext.queryNodes("node_name_and_summary", $query, {limit: $limit})YIELD node AS n, score WHERE n:Entity OR true // AND n.group_id IN $group_ids
WITH n, score
ORDER BY score DESC
LIMIT $limit
RETURN ...

The // only runs to the end of the line, which is enough: it removes n.group_id IN $group_ids and leaves the rest of the query intact. The same position accepts whole clauses (WITH, MATCH, SET, DETACH DELETE), as long as the fragment leaves n and score defined for the WITH n, score that follows. The MCP search_nodes tool runs the NODE_HYBRID_SEARCH_RRF recipe (BM25 and cosine similarity), so one call injects the fragment into two queries.

The advisory also describes an agent scenario: untrusted text could steer an LLM client into calling search_nodes with crafted entity_types. That requires a particular agent to follow the instruction and expose the tool; the query-construction proof alone does not demonstrate prompt-injection success.

Impact

The vendor scored the flaw 8.1 with AV:N/AC:L/PR:L/UI:N/C:H/I:H/A:N: a caller who can reach the search API or the MCP tool runs arbitrary Cypher within the privileges of the configured database connection, which can bypass the group filter and read data outside the intended scope. Write effects depend on the backend routing and the database identity, as described below.

What that adds depends on who the caller is. A client connected directly to the MCP server of 0.28.1 already has a lot: the server listens on 0.0.0.0:8000 over HTTP by default, has no authentication of its own, lets the caller choose group_ids and exposes clear_graph. The injection matters most where the caller is not meant to hold that power. The first case is an agent workflow in which a read-only search tool is approved automatically and destructive tools require confirmation: prompt injection turns an innocuous search into arbitrary Cypher. The second is an application that embeds graphiti-core, sets group_ids from the authenticated user and forwards user-chosen entity types to SearchFilters: the injection removes the only tenant boundary. Graphiti serves as long-term memory for agents, so the graph often holds conversation history and facts extracted from users’ data.

One backend nuance: the Neo4j search paths request read routing (routing_='r'), and a read transaction can refuse write clauses, notably in a cluster where it is sent to a reader. The FalkorDB driver, which the MCP server uses by default, sends every query through graph.query() with no read-only mode. The read side of the injection, including the group bypass, applies to every affected backend.

The fix

The fix is PR #1312 by Daniel Chalef, merged as commit 7d65d5e and shipped in graphiti-core 0.28.2. It adds a strict identifier allowlist and applies it at several layers:

# graphiti_core/helpers.py (0.28.2)
SAFE_CYPHER_IDENTIFIER_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')

def validate_node_labels(node_labels: list[str] | None) -> bool:
    """Validate that node labels are safe to interpolate into Cypher label expressions."""
    if not node_labels:
        return True
    invalid_labels = [
        label for label in node_labels if not SAFE_CYPHER_IDENTIFIER_PATTERN.match(label)
    ]
    if invalid_labels:
        raise NodeLabelValidationError(invalid_labels)
    return True

SearchFilters gains a pydantic field_validator on node_labels, so the MCP server now fails when it builds the filter. Both constructors call validate_node_labels() again before concatenating, with a comment that names the reason: objects created through model_construct() skip pydantic validation. The patch also covers the write path. Node.labels is validated on creation and on assignment (validate_assignment=True), and the entity save queries, which interpolated labels into SET n:{labels} on FalkorDB and Neptune, validate them before building Cypher. Finally, a new validate_group_ids() guards the fulltext helpers, which wrote group ids into Lucene and RediSearch query strings such as group_id:"{g}": this is the narrower group_ids issue that the advisory mentions as part of the original report.

The labels are still concatenated, but the allowlist leaves no room for backticks, spaces or operators, and the checks sit at the model, at the query builders and on the save path. That covers the variants of the same pattern in the codebase. The MCP server, which pinned graphiti-core[falkordb]==0.28.1, received its own release the same day: PR #1317 requires graphiti-core>=0.28.2 and shipped as mcp-v1.0.2.

Detection and mitigation

  1. Upgrade graphiti-core to 0.28.2 or later, and update the Graphiti MCP server to 1.0.2 or later (or rebuild its image) so that it no longer bundles 0.28.1.
  2. Keep the MCP server’s HTTP port (8000 by default, bound to 0.0.0.0) off untrusted networks, and put an authenticating reverse proxy in front of it when remote clients need it.
  3. On unpatched versions, do not let agents that process untrusted content call search_nodes without review, and do not pass user-supplied values into SearchFilters.node_labels in applications built on the library.
  4. Give the Graphiti database account the minimum privileges it needs and back up the graph, since the injected Cypher runs with that account’s rights.
  5. Log MCP tool calls and flag any entity_types value that does not match ^[A-Za-z_][A-Za-z0-9_]*$. Where the database records queries (the Neo4j query log, for instance), look for fulltext or vector search queries that contain //, OR true, MATCH (x) or DETACH DELETE after the YIELD node AS n, score clause.

Timeline

Date Event
2026-03-10 Private report through GitHub security advisory GHSA-gg5m-55jj-8m5g
2026-03-10 PR #1312 opened with the label and group id validation
2026-03-11 PR #1312 merged (7d65d5e), graphiti-core 0.28.2 released
2026-03-11 Advisory GHSA-gg5m-55jj-8m5g published, CVE-2026-32247 assigned
2026-03-11 MCP server 1.0.2 released with graphiti-core>=0.28.2
2026-03-12 Advisory added to the GitHub Advisory Database
2026-04-29 Proof of concept repository created on GitHub

References

  1. Vendor advisory · CVE-2026-32247 github.com/getzep/graphiti/security/advisories/GHSA-gg5m-55jj-8m5g
  2. NVD entry · CVE-2026-32247 nvd.nist.gov/vuln/detail/CVE-2026-32247
  3. CVE.org record · CVE-2026-32247 www.cve.org/CVERecord?id=CVE-2026-32247
  4. MITRE CWE-943 cwe.mitre.org/data/definitions/943.html
  5. Project repository github.com/getzep/graphiti
  6. Proof of concept and reproduction notes github.com/romain-deperne/CVE-2026-32247

Cite this advisory

rootzero. “CVE-2026-32247: Cypher injection in Graphiti through search filter node labels”. Vulnerability research, rootzero, 11 March 2026. https://rootzero.eu/en/research/cve/cve-2026-32247-graphiti-cypher-injection/

#Vulnerability research

Have your code audited before someone else does.

Your team needs to decide what to fix before a release or assess a critical dependency. We scope an engagement around that decision, with evidence and explained remediation priorities.