This is an automated email from the ASF dual-hosted git repository.

vatsrahul1001 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new a6265b77cf5 Resolve backfill_id in the access dependency with the type 
the routes declare (#70889)
a6265b77cf5 is described below

commit a6265b77cf57be4d59fd736cdc2554f94db9e2a2
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Aug 4 14:23:26 2026 +0200

    Resolve backfill_id in the access dependency with the type the routes 
declare (#70889)
    
    * Resolve backfill_id in the access dependency with the type the routes 
declare
    
    The backfill routes declare `backfill_id: NonNegativeInt`, but
    `requires_access_backfill` parsed the raw path value with `int()` and
    swallowed the failure. The two parsers do not agree: pydantic's lax mode
    validates "1.0" and "1.00" to 1, while `int()` rejects both.
    
    Dependencies resolve before the endpoint's own parameter validation, so for
    those spellings the dependency left the Dag unresolved on a request the
    handler then served against backfill 1 -- the two disagreed about which Dag
    the request concerned.
    
    Parse with the same TypeAdapter the routes declare so they cannot diverge.
    
    * Use spec'd mocks in the backfill authorization dependency test
    
    An unspecced Mock accepts any attribute, so the test would keep passing if 
the
    dependency started reading something the real Request, Session or Backfill 
does
    not have.
    
    * Point at the tracking issue for the unknown-backfill fallback
    
    A backfill_id that parses but matches no row falls through to the body's 
dag_id,
    so an unknown backfill answers 404 where an unauthorized one answers 403 
and a
    caller can tell which ids exist. That is a separate fix from the parser
    divergence this change closes, and it has to keep the three body-authorized
    routes working, so it is tracked rather than folded in here.
    
    The comment above the adapter also loses the history that led to it; what
    matters going forward is the rule it states.
---
 .../src/airflow/api_fastapi/core_api/security.py   | 21 ++++++++-
 .../unit/api_fastapi/core_api/test_security.py     | 51 ++++++++++++++++++++--
 2 files changed, 67 insertions(+), 5 deletions(-)

diff --git a/airflow-core/src/airflow/api_fastapi/core_api/security.py 
b/airflow-core/src/airflow/api_fastapi/core_api/security.py
index 96478eb5a14..e223dd20736 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py
@@ -27,6 +27,7 @@ from fastapi import Depends, HTTPException, Request, status
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, 
OAuth2PasswordBearer
 from itsdangerous import BadSignature, URLSafeSerializer
 from jwt import ExpiredSignatureError, InvalidTokenError
+from pydantic import NonNegativeInt, TypeAdapter, ValidationError
 from sqlalchemy import or_, select
 from sqlalchemy.orm import Session
 
@@ -390,6 +391,12 @@ ReadableBackfillsFilterDep = Annotated[
 ]
 
 
+# The type the backfill routes declare for the `backfill_id` path parameter. 
Shared with
+# `requires_access_backfill` so the authorization decision parses the id 
exactly as the handler
+# does; see the comment there for why any divergence is a cross-Dag 
authorization bypass.
+_BACKFILL_ID_ADAPTER: TypeAdapter[NonNegativeInt] = TypeAdapter(NonNegativeInt)
+
+
 def requires_access_backfill(
     method: ResourceMethod,
 ) -> Callable[[Request, BaseUser, Session], Coroutine[Any, Any, None]]:
@@ -405,8 +412,14 @@ def requires_access_backfill(
         # Try to retrieve the dag_id from the backfill_id path param
         backfill_id_raw = request.path_params.get("backfill_id")
         try:
-            backfill_id = int(backfill_id_raw) if backfill_id_raw is not None 
else None
-        except ValueError:
+            # Must parse exactly as the handler does (e.g. pydantic's lax mode 
coerces "1.0" to 1
+            # where int() raises), or the two can authorize and act on 
different backfills.
+            backfill_id = (
+                _BACKFILL_ID_ADAPTER.validate_python(backfill_id_raw) if 
backfill_id_raw is not None else None
+            )
+        except ValidationError:
+            # Rejected by the endpoint's parser too, so the handler cannot 
run: FastAPI answers
+            # 422 before it is reached. Left as None, preserving that response.
             backfill_id = None
 
         if backfill_id is not None:
@@ -414,6 +427,10 @@ def requires_access_backfill(
             dag_id = backfill.dag_id if backfill else None
 
         # Try to retrieve the dag_id from the request body (POST backfill)
+        # TODO: a backfill_id that parses but matches no row also lands here, 
so an unknown
+        # backfill is authorized against the body's dag_id and answers 404 
where an unauthorized
+        # one answers 403 - disclosing which ids exist. Not exploitable for a 
cross-Dag action;
+        # tracked at https://github.com/apache/airflow/issues/71080
         if dag_id is None:
             # Not a json body, ignore
             with suppress(JSONDecodeError):
diff --git a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py 
b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
index 37750e30da9..3709d50d358 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/test_security.py
@@ -20,12 +20,14 @@ from json import JSONDecodeError
 from unittest.mock import AsyncMock, Mock, patch
 
 import pytest
-from fastapi import HTTPException
+from fastapi import HTTPException, Request
 from jwt import ExpiredSignatureError, InvalidTokenError
+from sqlalchemy.orm import Session
 
 from airflow import settings
 from airflow.api_fastapi.app import create_app
-from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN
+from airflow.api_fastapi.auth.managers.base_auth_manager import 
COOKIE_NAME_JWT_TOKEN, BaseAuthManager
+from airflow.api_fastapi.auth.managers.models.base_user import BaseUser
 from airflow.api_fastapi.auth.managers.models.resource_details import (
     AccessView,
     ConnectionDetails,
@@ -55,6 +57,7 @@ from airflow.api_fastapi.core_api.security import (
     resolve_user_from_token,
 )
 from airflow.models import Connection, Pool, Variable
+from airflow.models.backfill import Backfill
 from airflow.models.dag import DagModel
 from airflow.models.dagbundle import DagBundleModel
 from airflow.models.team import Team
@@ -367,7 +370,7 @@ class TestFastApiSecurity:
     async def test_requires_access_backfill_authorized_from_body(
         self, mock_get_auth_manager, mock_get_team_name
     ):
-        """When backfill_id is missing or not int, dag_id can come from 
request body (POST backfill)."""
+        """With no backfill_id in the path, dag_id comes from the request body 
(POST backfill)."""
         auth_manager = Mock()
         auth_manager.is_authorized_dag.return_value = True
         mock_get_auth_manager.return_value = auth_manager
@@ -455,6 +458,48 @@ class TestFastApiSecurity:
             user=user,
         )
 
+    @pytest.mark.db_test
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("backfill_id", ["42", "42.0", "42.00"])
+    @patch.object(DagModel, "get_team_name")
+    @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+    async def 
test_requires_access_backfill_authorizes_the_backfill_the_handler_will_act_on(
+        self, mock_get_auth_manager, mock_get_team_name, backfill_id
+    ):
+        """The dependency must resolve the same backfill the handler does, for 
every spelling.
+
+        The endpoints declare ``backfill_id: NonNegativeInt``, and pydantic's 
lax mode coerces
+        ``"42.0"`` and ``"42.00"`` to ``42`` -- both are spellings the handler 
accepts and serves
+        against backfill 42. Parsing with ``int()`` here rejected them and 
left ``dag_id``
+        unresolved, so the two disagreed about which Dag the request concerned.
+        """
+        auth_manager = Mock(spec=BaseAuthManager)
+        auth_manager.is_authorized_dag.return_value = True
+        mock_get_auth_manager.return_value = auth_manager
+        mock_get_team_name.return_value = "team1"
+
+        backfill = Mock(spec=Backfill)
+        backfill.dag_id = "backfill_dag"
+        session = Mock(spec=Session)
+        session.scalars.return_value.one_or_none.return_value = backfill
+
+        request = Mock(spec=Request)
+        request.path_params = {"backfill_id": backfill_id}
+        request.query_params = {"dag_id": "some_other_dag"}
+        request.json = AsyncMock(return_value={"dag_id": "some_other_dag"})
+
+        user = Mock(spec=BaseUser)
+
+        await requires_access_backfill("PUT")(request, user, session)
+
+        # the backfill's own Dag, not the one supplied on the request
+        auth_manager.is_authorized_dag.assert_called_once_with(
+            method="PUT",
+            access_entity=DagAccessEntity.RUN,
+            details=DagDetails(id="backfill_dag", team_name="team1"),
+            user=user,
+        )
+
     @pytest.mark.db_test
     @pytest.mark.asyncio
     @patch.object(DagModel, "get_team_name")

Reply via email to