VYPR
High severityNVD Advisory· Published Jan 24, 2024· Updated Jun 11, 2025

Apache Airflow: Bypass permission verification to read code of other dags

CVE-2023-50944

Description

Apache Airflow, versions before 2.8.1, have a vulnerability that allows an authenticated user to access the source code of a DAG to which they don't have access. This vulnerability is considered low since it requires an authenticated user to exploit it. Users are recommended to upgrade to version 2.8.1, which fixes this issue.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Authenticated users can bypass DAG-level access controls in Apache Airflow before 2.8.1 to read source code of DAGs they lack permission to view.

Vulnerability

Analysis

Apache Airflow versions prior to 2.8.1 contain an authorization bypass vulnerability in the DAG code access endpoint. An authenticated user with any valid session could request the source code of a DAG (Directed Acyclic Graph) even when the user lacked explicit read permission on that specific DAG [2]. The root cause was the absence of a permission check before serving the DAG's file contents via the API endpoint that returns DAG source code [4].

Exploitation

Conditions

Exploitation requires only that the attacker is an authenticated user of the Airflow instance. No special privileges or administrative access are needed [2]. The vulnerability affects the /api/v1/dagSources/ endpoint, which serializes the DAG file location and returns the raw source code. The missing authorization check meant that any authenticated user could enumerate and retrieve DAG source code, even for DAGs they should not be able to access [3][4].

Impact

The vulnerability exposes the business logic, credentials, and embedded connections inside DAG definitions to unauthorized users. While the CVSS score is low due to the authentication requirement, the disclosure of proprietary workflow logic and potential secrets (passwords, API keys, database URIs) within DAG code represents a significant confidentiality risk [1][2].

Mitigation

The issue is fixed in Airflow 2.8.1. The fix, implemented in commit 8d76538 [3], adds a permission check that verifies the user has READ action on the DAG Code resource for the specific DAG before returning the source. Users are strongly recommended to upgrade to version 2.8.1 or later [2]. No workarounds are documented for earlier versions.

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
apache-airflowPyPI
< 2.8.1rc12.8.1rc1

Affected products

3

Patches

1
8d76538d6e10

Check DAG read permission before accessing DAG code (#36257)

https://github.com/apache/airflowHussein AwalaDec 16, 2023via ghsa
3 files changed · +78 13
  • airflow/api_connexion/endpoints/dag_source_endpoint.py+16 3 modified
    @@ -17,25 +17,38 @@
     from __future__ import annotations
     
     from http import HTTPStatus
    +from typing import TYPE_CHECKING
     
     from flask import Response, current_app, request
     from itsdangerous import BadSignature, URLSafeSerializer
     
     from airflow.api_connexion import security
    -from airflow.api_connexion.exceptions import NotFound
    +from airflow.api_connexion.exceptions import NotFound, PermissionDenied
     from airflow.api_connexion.schemas.dag_source_schema import dag_source_schema
    +from airflow.api_connexion.security import get_readable_dags
     from airflow.auth.managers.models.resource_details import DagAccessEntity
    +from airflow.models.dag import DagModel
     from airflow.models.dagcode import DagCode
    +from airflow.utils.session import NEW_SESSION, provide_session
    +
    +if TYPE_CHECKING:
    +    from sqlalchemy.orm import Session
     
     
     @security.requires_access_dag("GET", DagAccessEntity.CODE)
    -def get_dag_source(*, file_token: str) -> Response:
    +@provide_session
    +def get_dag_source(*, file_token: str, session: Session = NEW_SESSION) -> Response:
         """Get source code using file token."""
         secret_key = current_app.config["SECRET_KEY"]
         auth_s = URLSafeSerializer(secret_key)
         try:
             path = auth_s.loads(file_token)
    -        dag_source = DagCode.code(path)
    +        dag_ids = session.query(DagModel.dag_id).filter(DagModel.fileloc == path).all()
    +        readable_dags = get_readable_dags()
    +        # Check if user has read access to all the DAGs defined in the file
    +        if any(dag_id[0] not in readable_dags for dag_id in dag_ids):
    +            raise PermissionDenied()
    +        dag_source = DagCode.code(path, session=session)
         except (BadSignature, FileNotFoundError):
             raise NotFound("Dag source not found")
     
    
  • airflow/models/dagcode.py+3 2 modified
    @@ -177,12 +177,13 @@ def get_code_by_fileloc(cls, fileloc: str) -> str:
             return cls.code(fileloc)
     
         @classmethod
    -    def code(cls, fileloc) -> str:
    +    @provide_session
    +    def code(cls, fileloc, session: Session = NEW_SESSION) -> str:
             """Return source code for this DagCode object.
     
             :return: source code as string
             """
    -        return cls._get_code_from_db(fileloc)
    +        return cls._get_code_from_db(fileloc, session)
     
         @staticmethod
         def _get_code_from_file(fileloc):
    
  • tests/api_connexion/endpoints/test_dag_source_endpoint.py+59 8 modified
    @@ -34,6 +34,10 @@
     
     ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
     EXAMPLE_DAG_FILE = os.path.join("airflow", "example_dags", "example_bash_operator.py")
    +EXAMPLE_DAG_ID = "example_bash_operator"
    +TEST_DAG_ID = "latest_only"
    +NOT_READABLE_DAG_ID = "latest_only_with_trigger"
    +TEST_MULTIPLE_DAGS_ID = "dataset_produces_1"
     
     
     @pytest.fixture(scope="module")
    @@ -45,6 +49,18 @@ def configured_app(minimal_app_for_api):
             role_name="Test",
             permissions=[(permissions.ACTION_CAN_READ, permissions.RESOURCE_DAG_CODE)],  # type: ignore
         )
    +    app.appbuilder.sm.sync_perm_for_dag(  # type: ignore
    +        TEST_DAG_ID,
    +        access_control={"Test": [permissions.ACTION_CAN_READ]},
    +    )
    +    app.appbuilder.sm.sync_perm_for_dag(  # type: ignore
    +        EXAMPLE_DAG_ID,
    +        access_control={"Test": [permissions.ACTION_CAN_READ]},
    +    )
    +    app.appbuilder.sm.sync_perm_for_dag(  # type: ignore
    +        TEST_MULTIPLE_DAGS_ID,
    +        access_control={"Test": [permissions.ACTION_CAN_READ]},
    +    )
         create_user(app, username="test_no_permissions", role_name="TestNoPermissions")  # type: ignore
     
         yield app
    @@ -80,10 +96,10 @@ def _get_dag_file_docstring(fileloc: str) -> str | None:
         def test_should_respond_200_text(self, url_safe_serializer):
             dagbag = DagBag(dag_folder=EXAMPLE_DAG_FILE)
             dagbag.sync_to_db()
    -        first_dag: DAG = next(iter(dagbag.dags.values()))
    -        dag_docstring = self._get_dag_file_docstring(first_dag.fileloc)
    +        test_dag: DAG = dagbag.dags[TEST_DAG_ID]
    +        dag_docstring = self._get_dag_file_docstring(test_dag.fileloc)
     
    -        url = f"/api/v1/dagSources/{url_safe_serializer.dumps(first_dag.fileloc)}"
    +        url = f"/api/v1/dagSources/{url_safe_serializer.dumps(test_dag.fileloc)}"
             response = self.client.get(
                 url, headers={"Accept": "text/plain"}, environ_overrides={"REMOTE_USER": "test"}
             )
    @@ -95,10 +111,10 @@ def test_should_respond_200_text(self, url_safe_serializer):
         def test_should_respond_200_json(self, url_safe_serializer):
             dagbag = DagBag(dag_folder=EXAMPLE_DAG_FILE)
             dagbag.sync_to_db()
    -        first_dag: DAG = next(iter(dagbag.dags.values()))
    -        dag_docstring = self._get_dag_file_docstring(first_dag.fileloc)
    +        test_dag: DAG = dagbag.dags[TEST_DAG_ID]
    +        dag_docstring = self._get_dag_file_docstring(test_dag.fileloc)
     
    -        url = f"/api/v1/dagSources/{url_safe_serializer.dumps(first_dag.fileloc)}"
    +        url = f"/api/v1/dagSources/{url_safe_serializer.dumps(test_dag.fileloc)}"
             response = self.client.get(
                 url, headers={"Accept": "application/json"}, environ_overrides={"REMOTE_USER": "test"}
             )
    @@ -110,9 +126,9 @@ def test_should_respond_200_json(self, url_safe_serializer):
         def test_should_respond_406(self, url_safe_serializer):
             dagbag = DagBag(dag_folder=EXAMPLE_DAG_FILE)
             dagbag.sync_to_db()
    -        first_dag: DAG = next(iter(dagbag.dags.values()))
    +        test_dag: DAG = dagbag.dags[TEST_DAG_ID]
     
    -        url = f"/api/v1/dagSources/{url_safe_serializer.dumps(first_dag.fileloc)}"
    +        url = f"/api/v1/dagSources/{url_safe_serializer.dumps(test_dag.fileloc)}"
             response = self.client.get(
                 url, headers={"Accept": "image/webp"}, environ_overrides={"REMOTE_USER": "test"}
             )
    @@ -151,3 +167,38 @@ def test_should_raise_403_forbidden(self, url_safe_serializer):
                 environ_overrides={"REMOTE_USER": "test_no_permissions"},
             )
             assert response.status_code == 403
    +
    +    def test_should_respond_403_not_readable(self, url_safe_serializer):
    +        dagbag = DagBag(dag_folder=EXAMPLE_DAG_FILE)
    +        dagbag.sync_to_db()
    +        dag: DAG = dagbag.dags[NOT_READABLE_DAG_ID]
    +
    +        response = self.client.get(
    +            f"/api/v1/dagSources/{url_safe_serializer.dumps(dag.fileloc)}",
    +            headers={"Accept": "text/plain"},
    +            environ_overrides={"REMOTE_USER": "test"},
    +        )
    +        read_dag = self.client.get(
    +            f"/api/v1/dags/{NOT_READABLE_DAG_ID}",
    +            environ_overrides={"REMOTE_USER": "test"},
    +        )
    +        assert response.status_code == 403
    +        assert read_dag.status_code == 403
    +
    +    def test_should_respond_403_some_dags_not_readable_in_the_file(self, url_safe_serializer):
    +        dagbag = DagBag(dag_folder=EXAMPLE_DAG_FILE)
    +        dagbag.sync_to_db()
    +        dag: DAG = dagbag.dags[TEST_MULTIPLE_DAGS_ID]
    +
    +        response = self.client.get(
    +            f"/api/v1/dagSources/{url_safe_serializer.dumps(dag.fileloc)}",
    +            headers={"Accept": "text/plain"},
    +            environ_overrides={"REMOTE_USER": "test"},
    +        )
    +
    +        read_dag = self.client.get(
    +            f"/api/v1/dags/{TEST_MULTIPLE_DAGS_ID}",
    +            environ_overrides={"REMOTE_USER": "test"},
    +        )
    +        assert response.status_code == 403
    +        assert read_dag.status_code == 200
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.