CVE-2026-40963
Description
Airflow UI's structure_data endpoint leaks linked DAG IDs and dependency metadata for any DAG, even without read permission, enabling enumeration of unauthorized DAG topology.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Airflow UI's `structure_data` endpoint leaks linked DAG IDs and dependency metadata for any DAG, even without read permission, enabling enumeration of unauthorized DAG topology.
Vulnerability
The structure_data endpoint in the Airflow UI returns external dependency graph nodes for linked DAGs without verifying the caller has read permission on those linked DAGs [1]. Affected deployments are those using per-Dag read scoping to keep DAG dependency topology private across teams. Versions prior to apache-airflow 3.2.2 are vulnerable.
Exploitation
An authenticated UI/API user who has read access to at least one DAG can query the structure_data endpoint. The endpoint returns dependency metadata including linked DAG IDs for any other DAG, regardless of the user's authorization [1]. No additional privileges or user interaction beyond authentication to the Airflow instance is required.
Impact
An attacker can enumerate linked DAG IDs and dependency metadata for DAGs they are not authorized to read, violating confidentiality of DAG dependency topology [1]. This undermines per-Dag read scoping and can expose team-internal workflow structures.
Mitigation
Upgrade to apache-airflow 3.2.2 or later [1]. The fix adds the ReadableDagsFilterDep filter, which skips dependency entries that reference DAGs outside the caller's readable set [1]. No workaround is described for versions prior to 3.2.2; users are advised to upgrade.
AI Insight generated on Jun 1, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
201888df3ae9bFilter external dependency nodes by readable DAGs in structure_data endpoint (#65342)
2 files changed · +37 −6
airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py+13 −1 modified@@ -26,7 +26,7 @@ from airflow.api_fastapi.common.router import AirflowRouter from airflow.api_fastapi.core_api.datamodels.ui.structure import StructureDataResponse from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc -from airflow.api_fastapi.core_api.security import requires_access_dag +from airflow.api_fastapi.core_api.security import ReadableDagsFilterDep, requires_access_dag from airflow.api_fastapi.core_api.services.ui.structure import ( bind_output_assets_to_tasks, get_upstream_assets, @@ -52,6 +52,7 @@ def structure_data( session: SessionDep, dag_id: str, + readable_dags_filter: ReadableDagsFilterDep, include_upstream: QueryIncludeUpstream = False, include_downstream: QueryIncludeDownstream = False, depth: int | None = None, @@ -105,11 +106,22 @@ def structure_data( start_edges: list[dict] = [] end_edges: list[dict] = [] + readable_dag_ids = readable_dags_filter.value for dependency_dag_id, dependencies in sorted(SerializedDagModel.get_dag_dependencies().items()): + if readable_dag_ids is not None and dependency_dag_id not in readable_dag_ids: + continue for dependency in dependencies: # Dependencies not related to `dag_id` are ignored if dependency_dag_id != dag_id and dependency.target != dag_id: continue + # When target is a real DAG ID (not a type label), hide it + # if the caller cannot read that DAG. + if ( + readable_dag_ids is not None + and dependency.target != dependency.dependency_type + and dependency.target not in readable_dag_ids + ): + continue # upstream assets are handled by the `get_upstream_assets` function. if dependency.target != dependency.dependency_type and dependency.dependency_type in [
airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_structure.py+24 −5 modified@@ -18,6 +18,7 @@ from __future__ import annotations import copy +from unittest import mock import pendulum import pytest @@ -303,15 +304,15 @@ class TestStructureDataEndpoint: }, ], }, - 6, + 7, ), ( { "dag_id": DAG_ID, "root": "unknown_task", }, {"edges": [], "nodes": []}, - 6, + 7, ), ( { @@ -336,7 +337,7 @@ class TestStructureDataEndpoint: }, ], }, - 6, + 7, ), ( {"dag_id": DAG_ID_EXTERNAL_TRIGGER, "external_dependencies": True}, @@ -375,7 +376,7 @@ class TestStructureDataEndpoint: }, ], }, - 13, + 14, ), ], ) @@ -572,7 +573,7 @@ def test_should_return_200_with_asset(self, test_client, asset1_id, asset2_id, a ], } - with assert_queries_count(13): + with assert_queries_count(14): response = test_client.get("/structure/structure_data", params=params) assert response.status_code == 200 assert response.json() == expected @@ -685,6 +686,24 @@ def test_delete_dag_should_response_403(self, unauthorized_test_client): response = unauthorized_test_client.get("/structure/structure_data", params={"dag_id": DAG_ID}) assert response.status_code == 403 + @mock.patch( + "airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_dag_ids", + return_value={DAG_ID_EXTERNAL_TRIGGER}, + ) + @pytest.mark.usefixtures("make_dags") + def test_external_deps_filters_unreadable_dags(self, _, test_client): + response = test_client.get( + "/structure/structure_data", + params={"dag_id": DAG_ID_EXTERNAL_TRIGGER, "external_dependencies": True}, + ) + assert response.status_code == 200 + result = response.json() + node_ids = {node["id"] for node in result["nodes"]} + assert "trigger_dag_run_operator" in node_ids + assert not any(DAG_ID in nid for nid in node_ids if nid != "trigger_dag_run_operator") + edge_targets = {edge["target_id"] for edge in result["edges"]} + assert not any(DAG_ID in tid for tid in edge_targets) + def test_should_return_404(self, test_client): response = test_client.get("/structure/structure_data", params={"dag_id": "not_existing"}) assert response.status_code == 404
cde4885818beUpdating release notes for 3.2.2rc3
2 files changed · +5 −4
RELEASE_NOTES.rst+3 −2 modified@@ -24,7 +24,7 @@ .. towncrier release notes start -Airflow 3.2.2 (2026-05-27) +Airflow 3.2.2 (2026-05-29) -------------------------- Significant Changes @@ -81,7 +81,8 @@ Significant Changes Bug Fixes ^^^^^^^^^ - +- Fix ``Callback.handle_event`` triggerer crash when OpenTelemetry metrics receive dict typed tag values (#67527) (#67529) +- UI: Rewrite ``modulepreload hrefs`` to the api-server static path (#67548) (#67556) - Correctly pre-allocate ``external_executor_id`` with multiple executors on PostgreSQL (#67388) (#67458) - Return raw import-error stacktrace when a Dag file has no registered Dag (#67465) (#67478) - UI: Fix Expand/Collapse All on XComs and Audit Log JSON cells (#67316) (#67361)
reproducible_build.yaml+2 −2 modified@@ -1,2 +1,2 @@ -release-notes-hash: 6407b48d1054fe3ce68c09bf4435d91d -source-date-epoch: 1779745327 +release-notes-hash: 504288db9a9dc13a0db859232fab98d0 +source-date-epoch: 1779811737
Vulnerability mechanics
Root cause
"Missing authorization check in the structure_data endpoint allows an authenticated user to read external dependency graph nodes for DAGs they are not permitted to access."
Attack vector
An authenticated UI/API user who is authorized for one DAG can enumerate linked DAG IDs and dependency metadata for other DAGs they are not authorized to read. The attacker triggers the bug by calling the `structure_data` endpoint with `external_dependencies=True` for a DAG they can access; the endpoint returns dependency nodes for linked DAGs without verifying read permissions on those linked DAGs [CWE-285]. This affects deployments that rely on per-DAG read scoping to keep DAG dependency topology private across teams.
Affected code
The vulnerability is in the `structure_data` endpoint at `airflow-core/src/airflow/api_fastapi/core_api/routes/ui/structure.py`. The endpoint returned external dependency graph nodes for linked DAGs without checking whether the caller had read permission on those linked DAGs. The patch adds a `ReadableDagsFilterDep` dependency and filters out dependency entries that reference DAGs outside the caller's readable set.
What the fix does
The patch adds a `ReadableDagsFilterDep` dependency to the `structure_data` endpoint and uses it to skip dependency entries whose `dependency_dag_id` or `dependency.target` is not in the caller's readable DAG set. Specifically, it checks `readable_dag_ids is not None and dependency_dag_id not in readable_dag_ids` to skip entire dependency groups, and a second check to skip individual dependency entries whose target is a real DAG ID (not a type label) that the caller cannot read. This ensures that external dependency graph nodes are only returned for DAGs the caller has permission to view.
Preconditions
- authThe attacker must be an authenticated user of the Airflow UI/API with read access to at least one DAG.
- configThe deployment must use per-DAG read scoping (i.e., not all DAGs are readable by all users).
- inputThe attacker sends a GET request to the /structure/structure_data endpoint with external_dependencies=True for a DAG they can access.
Generated on Jun 1, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.