CVE-2026-46764
Description
Apache Airflow event log detail endpoint bypasses per-DAG audit-log permission, allowing authenticated users to read logs for any DAG by ID enumeration.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Apache Airflow event log detail endpoint bypasses per-DAG audit-log permission, allowing authenticated users to read logs for any DAG by ID enumeration.
Vulnerability
The GET /api/v2/eventLogs/{event_log_id} endpoint in Apache Airflow before version 3.2.2 only enforced a generic DagAccessEntity.AUDIT_LOG permission check without scoping to a specific DAG. In contrast, the collection endpoint GET /api/v2/eventLogs applies per-DAG filtering via ReadableEventLogsFilterDep. This inconsistency allows an authenticated user with audit-log read permission for one DAG to retrieve event log entries for any other DAG by guessing or enumerating the numeric event log ID [1][2].
Exploitation
An attacker must be an authenticated Airflow UI or API user who has been granted audit-log read permission for at least one DAG. No additional privileges or user interaction are required. The attacker can then enumerate numeric event log IDs (e.g., sequential integers) by issuing requests to the detail endpoint, thereby reading audit logs for DAGs they are not authorized to access [1][2].
Impact
Successful exploitation results in unauthorized disclosure of audit-log entries for arbitrary DAGs. This violates the intended per-DAG scoping of audit logs, potentially exposing sensitive operational information such as task execution details, user actions, and configuration changes across DAGs [2].
Mitigation
Upgrade to Apache Airflow version 3.2.2 or later, which introduces requires_access_event_log to resolve the DAG ID from the event log row and enforce per-DAG permission checks. No workaround is available for unpatched versions. The fix was merged in pull request #67112 [1] and is included in the 3.2.2 release [2].
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
24498582dd1eeApply per-DAG audit log permission to event log detail endpoint (#67112)
4 files changed · +156 −1
airflow-core/src/airflow/api_fastapi/core_api/routes/public/event_logs.py+2 −1 modified@@ -49,6 +49,7 @@ DagAccessEntity, ReadableEventLogsFilterDep, requires_access_dag, + requires_access_event_log, ) from airflow.models import Log @@ -58,7 +59,7 @@ @event_logs_router.get( "/{event_log_id}", responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]), - dependencies=[Depends(requires_access_dag("GET", DagAccessEntity.AUDIT_LOG))], + dependencies=[Depends(requires_access_event_log("GET"))], ) def get_event_log( event_log_id: int,
airflow-core/src/airflow/api_fastapi/core_api/security.py+29 −0 modified@@ -348,6 +348,35 @@ async def inner( return inner +def requires_access_event_log( + method: ResourceMethod, +) -> Callable[[Request, BaseUser, Session], Coroutine[Any, Any, None]]: + """Wrap ``requires_access_dag`` and extract the dag_id from the event_log_id.""" + + async def inner( + request: Request, + user: GetUserDep, + session: SessionDep, + ) -> None: + dag_id = None + + event_log_id_raw = request.path_params.get("event_log_id") + try: + event_log_id = int(event_log_id_raw) if event_log_id_raw is not None else None + except ValueError: + event_log_id = None + + if event_log_id is not None: + dag_id = session.scalar(select(Log.dag_id).where(Log.id == event_log_id)) + + requires_access_dag(method, DagAccessEntity.AUDIT_LOG, dag_id)( + request, + user, + ) + + return inner + + class PermittedPoolFilter(OrmClause[set[str]]): """A parameter that filters the permitted pools for the user."""
airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_event_logs.py+36 −0 modified@@ -17,9 +17,11 @@ from __future__ import annotations from datetime import datetime, timezone +from unittest import mock import pytest +from airflow.api_fastapi.auth.managers.models.resource_details import DagAccessEntity, DagDetails from airflow.models.log import Log from airflow.utils.session import provide_session @@ -196,6 +198,40 @@ def test_should_raises_403_forbidden(self, unauthorized_test_client, setup): response = unauthorized_test_client.get(f"/eventLogs/{event_log_id}") assert response.status_code == 403 + def test_should_respond_403_when_user_lacks_dag_audit_log_permission(self, test_client, setup): + """The detail endpoint must enforce the per-DAG audit log permission of the event log's dag_id.""" + event_log_id = setup[TASK_INSTANCE_EVENT].id + with mock.patch( + "airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.is_authorized_dag", + return_value=False, + ) as mock_is_authorized_dag: + response = test_client.get(f"/eventLogs/{event_log_id}") + + assert response.status_code == 403 + mock_is_authorized_dag.assert_called_once_with( + method="GET", + access_entity=DagAccessEntity.AUDIT_LOG, + details=DagDetails(id=DAG_ID, team_name=None), + user=mock.ANY, + ) + + def test_should_authorize_with_event_log_dag_id(self, test_client, setup): + """When the event log is bound to a DAG, authorization must scope to that DAG id.""" + event_log_id = setup[TASK_INSTANCE_EVENT].id + with mock.patch( + "airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.is_authorized_dag", + return_value=True, + ) as mock_is_authorized_dag: + response = test_client.get(f"/eventLogs/{event_log_id}") + + assert response.status_code == 200 + mock_is_authorized_dag.assert_called_once_with( + method="GET", + access_entity=DagAccessEntity.AUDIT_LOG, + details=DagDetails(id=DAG_ID, team_name=None), + user=mock.ANY, + ) + class TestGetEventLogs(TestEventLogsEndpoint): @pytest.mark.parametrize(
airflow-core/tests/unit/api_fastapi/core_api/test_security.py+89 −0 modified@@ -43,6 +43,7 @@ requires_access_connection, requires_access_connection_bulk, requires_access_dag, + requires_access_event_log, requires_access_pool, requires_access_pool_bulk, requires_access_variable, @@ -411,6 +412,94 @@ async def test_requires_access_backfill_backfill_not_found_falls_back_to_body( user=user, ) + @pytest.mark.db_test + @pytest.mark.asyncio + @patch.object(DagModel, "get_team_name") + @patch("airflow.api_fastapi.core_api.security.get_auth_manager") + async def test_requires_access_event_log_authorized_from_path( + self, mock_get_auth_manager, mock_get_team_name + ): + """When event_log_id is in path and the Log exists, dag_id from the row is used.""" + auth_manager = Mock() + auth_manager.is_authorized_dag.return_value = True + mock_get_auth_manager.return_value = auth_manager + mock_get_team_name.return_value = "team1" + + session = Mock() + session.scalar.return_value = "event_log_dag_id" + + request = Mock() + request.path_params = {"event_log_id": "42"} + user = Mock() + + inner = requires_access_event_log("GET") + await inner(request, user, session) + + auth_manager.is_authorized_dag.assert_called_once_with( + method="GET", + access_entity=DagAccessEntity.AUDIT_LOG, + details=DagDetails(id="event_log_dag_id", team_name="team1"), + user=user, + ) + + @pytest.mark.db_test + @pytest.mark.asyncio + @patch.object(DagModel, "get_team_name") + @patch("airflow.api_fastapi.core_api.security.get_auth_manager") + async def test_requires_access_event_log_unauthorized(self, mock_get_auth_manager, mock_get_team_name): + """When is_authorized_dag returns False for the event log's dag_id, Forbidden is raised.""" + auth_manager = Mock() + auth_manager.is_authorized_dag.return_value = False + mock_get_auth_manager.return_value = auth_manager + mock_get_team_name.return_value = None + + session = Mock() + session.scalar.return_value = "unauthorized_dag" + + request = Mock() + request.path_params = {"event_log_id": "1"} + user = Mock() + + inner = requires_access_event_log("GET") + with pytest.raises(HTTPException, match="Forbidden"): + await inner(request, user, session) + + auth_manager.is_authorized_dag.assert_called_once_with( + method="GET", + access_entity=DagAccessEntity.AUDIT_LOG, + details=DagDetails(id="unauthorized_dag", team_name=None), + user=user, + ) + + @pytest.mark.db_test + @pytest.mark.asyncio + @patch.object(DagModel, "get_team_name") + @patch("airflow.api_fastapi.core_api.security.get_auth_manager") + async def test_requires_access_event_log_row_not_found(self, mock_get_auth_manager, mock_get_team_name): + """When the Log row does not exist, dag_id is None and the generic AUDIT_LOG check applies.""" + auth_manager = Mock() + auth_manager.is_authorized_dag.return_value = True + mock_get_auth_manager.return_value = auth_manager + + session = Mock() + session.scalar.return_value = None + + request = Mock() + request.path_params = {"event_log_id": "999"} + request.query_params = {} + user = Mock() + + inner = requires_access_event_log("GET") + await inner(request, user, session) + + auth_manager.is_authorized_dag.assert_called_once_with( + method="GET", + access_entity=DagAccessEntity.AUDIT_LOG, + details=DagDetails(id=None, team_name=None), + user=user, + ) + mock_get_team_name.assert_not_called() + @pytest.mark.parametrize( ("url", "expected_is_safe"), [
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
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
3News mentions
0No linked articles in our index yet.