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 448f8462919 Harden _collect_teams_to_check / requires_access_backfill
against malformed bodies (#66504)
448f8462919 is described below
commit 448f84629192d640147cb5825b9f5e5d874dd2e4
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue May 19 15:38:49 2026 +0200
Harden _collect_teams_to_check / requires_access_backfill against malformed
bodies (#66504)
* Fail closed in _collect_teams_to_check on body parse failure
For POST/PUT in multi-team mode, the helper used `with
suppress(JSONDecodeError)`
around `await request.json()`. If the body was unparseable, the suppress
swallowed the exception, `teams.add(raw)` never ran, and the calling
`requires_access_*` dependency iterated over an empty set — silently
skipping the authorization callback entirely.
Today this is unreachable because every POST/PUT route in core_api uses a
Pydantic body model, so FastAPI returns 422 before the auth dependency
runs. But the pattern would silently bypass team-scoped authz if a future
route used a raw `Request` instead. Replace the bare suppress with an
explicit try/except that adds `None` to `teams` on parse failure, so the
auth callback always runs at least once.
* Reject malformed bodies in core_api authz helpers with 400
Builds on the previous fail-closed change in _collect_teams_to_check.
Two follow-ups from review:
* On JSONDecodeError, raise HTTP 400 directly instead of falling through
to a team=None auth call — clearer failure mode and removes any
ambiguity about whether authz ran.
* Reject non-string `team_name` (in _collect_teams_to_check) and
non-string `dag_id` (in requires_access_backfill) from the raw body
with HTTP 400 before any authz decision or DB lookup. Without this,
a list / dict / int / bool would flow into Team.get_name_if_exists,
requires_access_dag, or the existence lookup with undefined behaviour
or type-confused authz decisions.
Both helpers still read the raw body before Pydantic body validation
runs on the endpoint handler, so this is defense-in-depth: every current
POST/PUT route uses a Pydantic body model and FastAPI returns 422 before
the auth dependency runs on a malformed body.
Tests: existing parse-failure test renamed and updated to assert 400;
new parametrised tests cover integer / list / dict / bool inputs for
both team_name and dag_id.
---
.../src/airflow/api_fastapi/core_api/security.py | 39 ++++++++---
.../unit/api_fastapi/core_api/test_security.py | 79 ++++++++++++++++++++++
2 files changed, 109 insertions(+), 9 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 cf4018b01ae..5ea8821ccea 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/security.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/security.py
@@ -338,7 +338,15 @@ def requires_access_backfill(
if dag_id is None:
# Not a json body, ignore
with suppress(JSONDecodeError):
- dag_id = (await request.json()).get("dag_id")
+ body = await request.json()
+ if isinstance(body, dict):
+ dag_id = body.get("dag_id")
+ if dag_id is not None and not isinstance(dag_id, str):
+ # Fail closed: reject non-string dag_id before authz decision.
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="'dag_id' must be a string",
+ )
requires_access_dag(method, DagAccessEntity.RUN, dag_id)(
request,
@@ -786,14 +794,27 @@ async def _collect_teams_to_check(
if method != "POST":
teams.add(get_existing_team(resource_id) if resource_id else None)
if method in ("POST", "PUT"):
- with suppress(JSONDecodeError):
- raw = (await request.json()).get("team_name")
- if raw and not Team.get_name_if_exists(raw):
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Team {raw!r} does not exist",
- )
- teams.add(raw)
+ try:
+ body = await request.json()
+ except JSONDecodeError:
+ # Fail closed: reject unparsable bodies before any authz decision.
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Request body is not valid JSON",
+ )
+ raw = body.get("team_name") if isinstance(body, dict) else None
+ if raw is not None and not isinstance(raw, str):
+ # Fail closed: reject non-string team_name before authz / DB
lookup.
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="'team_name' must be a string",
+ )
+ if raw and not Team.get_name_if_exists(raw):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Team {raw!r} does not exist",
+ )
+ teams.add(raw)
return teams
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 7fff7260854..b19f774f28a 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
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+from json import JSONDecodeError
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -676,6 +677,84 @@ class TestFastApiSecurity:
assert exc_info.value.status_code == 400
assert "nonexistent" in exc_info.value.detail
+ @pytest.mark.db_test
+ @pytest.mark.parametrize("method", ["POST", "PUT"])
+ @patch.object(Team, "get_name_if_exists")
+ @patch.object(Connection, "get_team_name")
+ @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+ async def test_requires_access_connection_body_parse_failure_returns_400(
+ self, mock_get_auth_manager, mock_get_team_name,
mock_get_name_if_exists, method
+ ):
+ """If the request body cannot be parsed, fail closed with 400 before
any authz check."""
+ auth_manager = Mock()
+ auth_manager.is_authorized_connection.return_value = False
+ mock_get_auth_manager.return_value = auth_manager
+ mock_get_team_name.return_value = None
+ fastapi_request = Mock()
+ fastapi_request.path_params = {"connection_id": "conn_id"}
+ fastapi_request.json =
AsyncMock(side_effect=JSONDecodeError("expecting value", "", 0))
+ user = Mock()
+
+ with conf_vars({("core", "multi_team"): "True"}):
+ with pytest.raises(HTTPException) as exc_info:
+ await requires_access_connection(method)(fastapi_request, user)
+
+ assert exc_info.value.status_code == 400
+ auth_manager.is_authorized_connection.assert_not_called()
+
+ @pytest.mark.db_test
+ @pytest.mark.parametrize("method", ["POST", "PUT"])
+ @pytest.mark.parametrize("bad_team_name", [123, ["x"], {"name": "x"},
True])
+ @patch.object(Team, "get_name_if_exists")
+ @patch.object(Connection, "get_team_name")
+ @patch("airflow.api_fastapi.core_api.security.get_auth_manager")
+ async def test_requires_access_connection_non_string_team_name_returns_400(
+ self,
+ mock_get_auth_manager,
+ mock_get_team_name,
+ mock_get_name_if_exists,
+ bad_team_name,
+ method,
+ ):
+ """Non-string team_name in the body must be rejected with 400 before
authz / DB lookup."""
+ auth_manager = Mock()
+ auth_manager.is_authorized_connection.return_value = False
+ mock_get_auth_manager.return_value = auth_manager
+ mock_get_team_name.return_value = None
+ fastapi_request = Mock()
+ fastapi_request.path_params = {"connection_id": "conn_id"}
+ fastapi_request.json = AsyncMock(return_value={"team_name":
bad_team_name})
+ user = Mock()
+
+ with conf_vars({("core", "multi_team"): "True"}):
+ with pytest.raises(HTTPException) as exc_info:
+ await requires_access_connection(method)(fastapi_request, user)
+
+ assert exc_info.value.status_code == 400
+ assert "team_name" in exc_info.value.detail
+ auth_manager.is_authorized_connection.assert_not_called()
+ mock_get_name_if_exists.assert_not_called()
+
+ @pytest.mark.db_test
+ @pytest.mark.parametrize("bad_dag_id", [123, ["x"], {"name": "x"}, True])
+ @patch("airflow.api_fastapi.core_api.security.requires_access_dag")
+ async def test_requires_access_backfill_non_string_dag_id_returns_400(
+ self, mock_requires_access_dag, bad_dag_id
+ ):
+ """Non-string dag_id in the body must be rejected with 400 before
authz."""
+ fastapi_request = Mock()
+ fastapi_request.path_params = {}
+ fastapi_request.json = AsyncMock(return_value={"dag_id": bad_dag_id})
+ user = Mock()
+ session = Mock()
+
+ with pytest.raises(HTTPException) as exc_info:
+ await requires_access_backfill("POST")(fastapi_request, user,
session)
+
+ assert exc_info.value.status_code == 400
+ assert "dag_id" in exc_info.value.detail
+ mock_requires_access_dag.assert_not_called()
+
@patch.object(Connection, "get_conn_id_to_team_name_mapping")
@patch("airflow.api_fastapi.core_api.security.get_auth_manager")
def test_requires_access_connection_bulk(