CVE-2026-32247
CVSS 8.1 · Élevée CorrigéCVE-2026-32247 : injection Cypher dans Graphiti via les labels des filtres de recherche
Graphiti insérait les labels des filtres de recherche dans du Cypher brut, permettant de contourner le filtre de groupe.
Pour décider
- Impact
- Le filtre de groupe des recherches peut être contourné, mettant en cause l’isolation attendue entre les données.
- Conditions
- L’appelant doit pouvoir choisir les labels du filtre de recherche. L’impact final dépend du routage et des droits de la base.
- Action recommandée
- Mettre graphiti-core à jour en 0.28.2 ou au-delà, puis vérifier les frontières d’accès aux groupes.
Fiche de l’avis
Synthèse de l’enquête
Avant la 0.28.2, graphiti-core joignait les node_labels d’un objet SearchFilters avec | et concaténait le résultat dans la clause WHERE de ses requêtes Neo4j, FalkorDB et Neptune. Un appelant capable de choisir ces labels pouvait modifier la requête et contourner son filtre group_id. La lecture entre groupes découle de la construction de la requête ; les écritures dépendent aussi du routage et des droits de la base. Une exploitation via injection de prompt contre un agent reste un scénario, non une preuve apportée par ce test. Corrigé en 0.28.2.
Le paramètre qui sortait du Cypher
Nous avons commencé par suivre les arguments des outils MCP de Graphiti jusqu’aux requêtes Cypher. La plupart arrivaient dans le driver comme paramètres. entity_types, lui, devenait une expression de labels construite par concaténation. Nous avons remonté ce champ de search_nodes à SearchFilters, puis au WHERE final : un contrôle de type acceptait une liste de chaînes, mais aucun contrôle ne disait ce qu’une chaîne pouvait contenir. La comparaison avec le chemin Kuzu, qui paramétrait les labels, a confirmé que cette construction n’était pas inévitable.
Notre premier payload supposait que le filtre de labels était entouré de parenthèses. En le confrontant au vrai template de la version 0.28.1, nous avons vu que le constructeur n’en ajoutait aucune : ce payload précis n’était pas une preuve backend fiable. Nous avons ramené l’essai à la frontière réelle : une valeur de label pouvait introduire OR true // avant la condition group_id, tout en laissant le reste de la requête intact. Le PoC conservé imprime le Cypher généré ; il ne renvoie pas les lignes d’un autre groupe depuis une base réelle. Les clauses destructrices illustrent ce que les droits de la base pourraient permettre, pas un effet observé. Nous avons signalé le défaut de construction le 10 mars 2026. Le lendemain, les mainteneurs ont ajouté la validation des labels et publié graphiti-core 0.28.2 avec CVE-2026-32247. Le scénario d’un agent guidé par une instruction hostile reste une étape distincte, non testée.
La frontière entre groupes que nous avons suivie
Graphiti stocke la mémoire d’agents dans un graphe. group_id sépare les groupes dans la requête, tandis que ses outils MCP exposent des filtres de recherche fournis par l’appelant.
Deux propriétés justifient un examen attentif de la couche de requêtes. D’abord, Graphiti partitionne les données avec une propriété group_id, que les applications utilisent pour séparer utilisateurs ou clients au sein d’une même base, et cette partition n’existe que dans les requêtes que Graphiti génère. Ensuite, dans le déploiement MCP, les arguments des outils de recherche sont rédigés par un modèle de langage, qui a parfois lu juste avant un texte contrôlé par un tiers (un document, une page web, un message) au moment de choisir quoi appeler.
De entity_types au texte Cypher
En suivant l’argument entity_types de search_nodes dans la 0.28.1, on obtient cette chaîne :
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))
La couche MCP déclare entity_types comme list[str] | None : elle vérifie le type, rien de plus. SearchFilters est un modèle pydantic, mais son champ node_labels n’avait aucun validateur. Le détail le plus parlant se trouve dans la même fonction : sur Kuzu, Graphiti passe les labels en paramètre $labels. La forme sûre existait donc déjà dans le code ; les trois autres backends recouraient à la concaténation.
Cause racine
Le constructeur de filtre de la 0.28.1 se lit ainsi :
# 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() reprend le même motif aux lignes 134 et 135, avec des labels insérés deux fois ('n:' + node_labels + ' AND m:' + node_labels). Les opérations de recherche placent ensuite le fragment devant le filtre de groupe :
# 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)
)
Avec entity_types=["Person", "Organization"] et un groupe défini, la première ligne de la requête devient :
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
Il s’agit de la CWE-943, neutralisation incorrecte d’éléments spéciaux dans la logique d’une requête de données. Trois faits conditionnent l’exploitation : tout ce qui suit n: est du texte de l’attaquant, le filtre de labels précède le filtre de tenant sur la même ligne, et la requête continue sur les lignes suivantes avec WITH n, score. Les opérations de recherche FalkorDB et Neptune construisent leurs requêtes de la même manière.
Exploitation
La preuve de concept publique reproduit la construction de chaîne vulnérable et affiche les appels MCP qui l’atteignent. Ses payloads referment l’expression de labels, ouvrent une nouvelle clause avec WITH n et commentent la fin de la ligne avec // :
{
"tool": "search_nodes",
"arguments": {
"query": "anything",
"entity_types": ["Entity`) WITH n MATCH (x) DETACH DELETE x //"]
}
}
La variante de lecture remplace DETACH DELETE x par RETURN x, et une troisième variante sélectionne les nœuds d’un groupe victime choisi. Les mainteneurs ont repris la chaîne destructrice telle quelle dans leurs tests de non-régression.
La PoC écrit le payload pour un filtre entre parenthèses (WHERE (n:...)). Les constructeurs de la 0.28.1 émettent le filtre de labels sans parenthèses : face à ces templates, ni backtick ni parenthèse fermante ne sont nécessaires. Le fragment le plus court qui supprime l’isolation entre tenants est une tautologie suivie d’un commentaire :
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 ...
Le // ne court que jusqu’à la fin de la ligne, ce qui suffit : il retire n.group_id IN $group_ids et laisse le reste de la requête intact. La même position accepte des clauses entières (WITH, MATCH, SET, DETACH DELETE), à condition que le fragment laisse n et score définis pour le WITH n, score qui suit. L’outil MCP search_nodes exécute la recette NODE_HYBRID_SEARCH_RRF (BM25 et similarité cosinus) : un seul appel injecte donc le fragment dans deux requêtes.
L’avis décrit aussi un scénario avec agent : un texte non fiable pourrait amener un client LLM à appeler search_nodes avec des entity_types forgés. Il faut alors que l’agent suive l’instruction et expose cet outil ; la preuve de construction de requête ne démontre pas, à elle seule, la réussite de l’injection de prompt.
Impact
L’éditeur a noté la faille 8,1 avec AV:N/AC:L/PR:L/UI:N/C:H/I:H/A:N : un appelant qui atteint l’API de recherche ou l’outil MCP exécute du Cypher arbitraire dans la limite des privilèges de la connexion configurée, ce qui peut contourner le filtre de groupe et lire des données hors du périmètre prévu. Les effets d’écriture dépendent du routage du backend et des droits de l’identité de base de données, comme détaillé ci-dessous.
Ce que cela ajoute dépend de l’appelant. Un client connecté directement au serveur MCP de la 0.28.1 dispose déjà de beaucoup : le serveur écoute par défaut sur 0.0.0.0:8000 en HTTP, n’a pas d’authentification propre, laisse l’appelant choisir group_ids et expose clear_graph. L’injection pèse surtout là où l’appelant n’est pas censé détenir ce pouvoir. Premier cas, un workflow d’agent dans lequel un outil de recherche en lecture seule est approuvé automatiquement alors que les outils destructeurs demandent une confirmation : une injection de prompt qui réussit peut transformer une recherche en requête Cypher non prévue. Second cas, une application qui embarque graphiti-core, fixe group_ids d’après l’utilisateur authentifié et transmet à SearchFilters des types d’entités choisis par l’utilisateur : l’injection supprime la seule frontière entre tenants. Graphiti sert de mémoire longue aux agents, et le graphe contient souvent l’historique des conversations et des faits extraits des données des utilisateurs.
Une nuance selon le backend : les chemins de recherche Neo4j demandent un routage en lecture (routing_='r'), et une transaction en lecture peut refuser les clauses d’écriture, notamment en cluster où elle part vers un serveur de lecture. Le driver FalkorDB, que le serveur MCP utilise par défaut, envoie chaque requête via graph.query() sans mode lecture seule. Le volet lecture de l’injection, contournement des groupes compris, s’applique à tous les backends concernés.
Correctif
Le correctif est la PR #1312 de Daniel Chalef, fusionnée dans le commit 7d65d5e et livrée dans graphiti-core 0.28.2. Elle ajoute une liste blanche stricte d’identifiants et l’applique à plusieurs niveaux :
# 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 reçoit un field_validator pydantic sur node_labels : le serveur MCP échoue désormais dès la construction du filtre. Les deux constructeurs rappellent validate_node_labels() avant de concaténer, avec un commentaire qui en donne la raison : les objets créés via model_construct() échappent à la validation pydantic. Le patch couvre aussi le chemin d’écriture. Node.labels est validé à la création et à l’affectation (validate_assignment=True), et les requêtes d’enregistrement d’entités, qui interpolaient les labels dans SET n:{labels} sur FalkorDB et Neptune, les valident avant de construire le Cypher. Enfin, une nouvelle fonction validate_group_ids() protège les helpers de recherche plein texte, qui écrivaient les identifiants de groupe dans des chaînes de requête Lucene et RediSearch comme group_id:"{g}" : c’est le problème plus restreint sur group_ids que l’avis mentionne au titre du signalement initial.
Les labels restent concaténés, mais la liste blanche ne laisse aucune place aux backticks, espaces ou opérateurs, et les contrôles se situent au niveau du modèle, des constructeurs de requêtes et du chemin d’enregistrement. Cela couvre les variantes du même motif dans le code. Le serveur MCP, qui épinglait graphiti-core[falkordb]==0.28.1, a eu sa propre version le même jour : la PR #1317 exige graphiti-core>=0.28.2 et a été publiée sous le tag mcp-v1.0.2.
Détection et mitigation
- Mettre à jour
graphiti-corevers la 0.28.2 ou une version ultérieure, et le serveur MCP Graphiti vers la 1.0.2 ou ultérieure (ou reconstruire son image) afin qu’il n’embarque plus la 0.28.1. - Garder le port HTTP du serveur MCP (8000 par défaut, lié à
0.0.0.0) hors des réseaux non fiables, et placer un reverse proxy authentifiant devant lui quand des clients distants en ont besoin. - Sur les versions non corrigées, ne pas laisser les agents qui traitent du contenu non fiable appeler
search_nodessans contrôle, et ne pas transmettre de valeurs fournies par l’utilisateur àSearchFilters.node_labelsdans les applications construites sur la bibliothèque. - Donner au compte de base de données de Graphiti les privilèges minimaux nécessaires et sauvegarder le graphe, puisque le Cypher injecté s’exécute avec les droits de ce compte.
- Journaliser les appels d’outils MCP et signaler toute valeur de
entity_typesqui ne correspond pas à^[A-Za-z_][A-Za-z0-9_]*$. Lorsque la base enregistre les requêtes (le journal de requêtes de Neo4j, par exemple), rechercher les requêtes plein texte ou vectorielles contenant//,OR true,MATCH (x)ouDETACH DELETEaprès la clauseYIELD node AS n, score.
Chronologie
| Date | Événement |
|---|---|
| 10/03/2026 | Signalement privé via l’avis de sécurité GitHub GHSA-gg5m-55jj-8m5g |
| 10/03/2026 | Ouverture de la PR #1312 avec la validation des labels et des identifiants de groupe |
| 11/03/2026 | Fusion de la PR #1312 (7d65d5e), publication de graphiti-core 0.28.2 |
| 11/03/2026 | Publication de l’avis GHSA-gg5m-55jj-8m5g, attribution de CVE-2026-32247 |
| 11/03/2026 | Publication du serveur MCP 1.0.2 avec graphiti-core>=0.28.2 |
| 12/03/2026 | Ajout de l’avis à la GitHub Advisory Database |
| 29/04/2026 | Création du dépôt de la preuve de concept sur GitHub |
Références
- Avis de l’éditeur · CVE-2026-32247 github.com/getzep/graphiti/security/advisories/GHSA-gg5m-55jj-8m5g
- Fiche NVD · CVE-2026-32247 nvd.nist.gov/vuln/detail/CVE-2026-32247
- Fiche CVE.org · CVE-2026-32247 www.cve.org/CVERecord?id=CVE-2026-32247
- MITRE CWE-943 cwe.mitre.org/data/definitions/943.html
- Dépôt du projet github.com/getzep/graphiti
- Preuve de concept et notes de reproduction github.com/romain-deperne/CVE-2026-32247
Citer cet avis
rootzero. « CVE-2026-32247 : injection Cypher dans Graphiti via les labels des filtres de recherche ». Recherche de vulnérabilités, rootzero, 11 mars 2026. https://rootzero.eu/research/cve/cve-2026-32247-graphiti-cypher-injection/
#Recherche de vulnérabilités
Faites auditer votre code avant qu’un autre ne le fasse.
Votre équipe doit décider quoi corriger avant une livraison ou évaluer une dépendance critique. Nous définissons une mission autour de cette décision, avec des preuves et des priorités de correction expliquées.