This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 63504d29972 [v3-4-test] Reject blank pool names and variable keys in
the REST API (#74896) (#75258)
63504d29972 is described below
commit 63504d299729f4ded251fea026859fbe0137c2ba
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Oct 21 09:10:29 2026 -0400
[v3-4-test] Reject blank pool names and variable keys in the REST API
(#74896) (#75258)
* Reject blank pool names and variable keys in the REST API
* Add newsfragment
* Add blank name and key tests for the PATCH and bulk endpoints
(cherry picked from commit ca6499d83b8e5b8c3d14cc768745c6b6b4fd49a5)
Co-authored-by: Pushkal Gupta
<[email protected]>
---
airflow-core/newsfragments/74896.bugfix.rst | 1 +
.../api_fastapi/core_api/datamodels/pools.py | 6 ++-
.../api_fastapi/core_api/datamodels/variables.py | 7 +++-
.../core_api/openapi/v2-rest-api-generated.yaml | 2 +
.../api_fastapi/core_api/routes/public/pools.py | 4 +-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 2 +
.../core_api/routes/public/test_pools.py | 48 ++++++++++++++++++++++
.../core_api/routes/public/test_variables.py | 44 +++++++++++++++++++-
.../src/airflowctl/api/datamodels/generated.py | 4 +-
9 files changed, 110 insertions(+), 8 deletions(-)
diff --git a/airflow-core/newsfragments/74896.bugfix.rst
b/airflow-core/newsfragments/74896.bugfix.rst
new file mode 100644
index 00000000000..0ea1420f44e
--- /dev/null
+++ b/airflow-core/newsfragments/74896.bugfix.rst
@@ -0,0 +1 @@
+The REST API now rejects a pool name or variable key that is empty or only
whitespace. ``POST /api/v2/pools`` and ``POST /api/v2/variables`` return 422
for such a value, and ``PATCH /api/v2/pools/{pool_name}`` returns 400 instead
of renaming the pool to the empty string. Pools and variables that already have
a blank name can still be read and deleted.
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/pools.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/pools.py
index 8fef6a094ba..d6021a85a7c 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/pools.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/pools.py
@@ -20,7 +20,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Annotated
-from pydantic import BeforeValidator, Field, model_validator
+from pydantic import BeforeValidator, Field, StringConstraints, model_validator
from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
from airflow.configuration import conf
@@ -98,7 +98,9 @@ class PoolPatchBody(StrictBaseModel):
class PoolBody(BasePool, StrictBaseModel):
"""Pool serializer for post bodies."""
- pool: str = Field(alias="name", max_length=256)
+ # pattern (not strip_whitespace) so the name is stored exactly as sent,
and `\S` rather
+ # than a stricter charset because `/{pool_name:path}` deliberately lets
names contain slashes.
+ pool: Annotated[str, StringConstraints(pattern=r"\S", max_length=256)] =
Field(alias="name")
description: str | None = None
include_deferred: bool = False
team_name: str | None = Field(max_length=50, default=None)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
index 16c98c8588a..87e2db81767 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/variables.py
@@ -19,8 +19,9 @@ from __future__ import annotations
import json
from collections.abc import Iterable
+from typing import Annotated
-from pydantic import Field, JsonValue, field_validator, model_validator
+from pydantic import Field, JsonValue, StringConstraints, field_validator,
model_validator
from airflow._shared.secrets_masker import redact
from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel,
make_partial_model
@@ -56,7 +57,9 @@ class VariableResponse(BaseModel):
class VariableBody(StrictBaseModel):
"""Variable serializer for bodies."""
- key: str = Field(max_length=ID_LEN)
+ # pattern (not strip_whitespace) so the key is stored exactly as sent, and
`\S` rather than a
+ # stricter charset because `/{variable_key:path}` deliberately lets keys
contain slashes.
+ key: Annotated[str, StringConstraints(pattern=r"\S", max_length=ID_LEN)]
value: JsonValue = Field(serialization_alias="val")
description: str | None = Field(default=None)
team_name: str | None = Field(max_length=50, default=None)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 640faa0942a..d7f5e852357 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -17295,6 +17295,7 @@ components:
name:
type: string
maxLength: 256
+ pattern: \S
title: Name
slots:
type: integer
@@ -18913,6 +18914,7 @@ components:
key:
type: string
maxLength: 250
+ pattern: \S
title: Key
value:
$ref: '#/components/schemas/JsonValue'
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py
index 5b8e2098a37..551c1ecbe6a 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/pools.py
@@ -144,7 +144,9 @@ def patch_pool(
update_mask: list[str] | None = Query(None),
) -> PoolResponse:
"""Update a Pool."""
- if patch_body.name and patch_body.name != pool_name:
+ # Tested against None, not truthiness: a blank name is a mismatch like any
other, and
+ # treating it as "no name supplied" let it fall through and rename the
pool to "".
+ if patch_body.name is not None and patch_body.name != pool_name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Invalid body, pool name from request body doesn't match uri
parameter",
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 2eb5f596b63..e3e6d461263 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -7124,6 +7124,7 @@ export const $PoolBody = {
name: {
type: 'string',
maxLength: 256,
+ pattern: '\\S',
title: 'Name'
},
slots: {
@@ -9614,6 +9615,7 @@ export const $VariableBody = {
key: {
type: 'string',
maxLength: 250,
+ pattern: '\\S',
title: 'Key'
},
value: {
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py
index 70744d6680e..c1477b88948 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_pools.py
@@ -486,6 +486,20 @@ class TestPatchPool(TestPoolsEndpoint):
in response.json()["detail"][0]["msg"]
)
+ def test_patch_pool_rejects_blank_name_instead_of_renaming(self,
test_client, session):
+ # The guard tested the body name for truthiness, so a blank one was
read as "no name
+ # supplied", skipped the mismatch check and renamed the pool to ""
instead.
+ self.create_pools()
+ response = test_client.patch(
+ f"/pools/{POOL1_NAME}",
+ json={"name": "", "slots": POOL1_SLOT, "include_deferred":
POOL1_INCLUDE_DEFERRED},
+ )
+ assert response.status_code == 400
+ assert response.json()["detail"] == (
+ "Invalid body, pool name from request body doesn't match uri
parameter"
+ )
+ assert session.scalar(select(Pool.pool).where(Pool.pool ==
POOL1_NAME)) == POOL1_NAME
+
class TestPostPool(TestPoolsEndpoint):
@conf_vars({("core", "multi_team"): "True"})
@@ -595,6 +609,16 @@ class TestPostPool(TestPoolsEndpoint):
)
assert response.status_code == 422
+ @pytest.mark.parametrize("name", ["", " ", " ", "\t", "\n"])
+ def test_post_pool_rejects_blank_name(self, test_client, session, name):
+ n_pools = session.scalar(select(func.count()).select_from(Pool))
+ response = test_client.post(
+ "/pools",
+ json={"name": name, "slots": 1, "include_deferred": False},
+ )
+ assert response.status_code == 422
+ assert session.scalar(select(func.count()).select_from(Pool)) ==
n_pools
+
@conf_vars({("core", "multi_team"): "False"})
def test_post_pool_rejects_team_name_when_multi_team_disabled(self,
test_client):
response = test_client.post(
@@ -1325,6 +1349,30 @@ class TestBulkPools(TestPoolsEndpoint):
expected_error_names = {err["input"]["name"] for err in detail}
assert sorted(expected_error_names) == ["pool_2", "pool_3"]
+ @pytest.mark.parametrize("action", ["create", "update"])
+ @pytest.mark.parametrize("name", ["", " ", "\t"])
+ def test_bulk_rejects_blank_name(self, test_client, session, action, name):
+ # The bulk endpoint validates its entities as PoolBody, so it picks up
the same
+ # constraint as POST. One bad entity rejects the whole request before
any action runs.
+ self.create_pools()
+ n_pools = session.scalar(select(func.count()).select_from(Pool))
+ response = test_client.patch(
+ "/pools",
+ json={
+ "actions": [
+ {
+ "action": action,
+ "entities": [
+ {"name": POOL1_NAME, "slots": 1,
"include_deferred": False},
+ {"name": name, "slots": 1, "include_deferred":
False},
+ ],
+ }
+ ]
+ },
+ )
+ assert response.status_code == 422
+ assert session.scalar(select(func.count()).select_from(Pool)) ==
n_pools
+
@conf_vars({("core", "multi_team"): "True"})
def test_bulk_create_overwrite_preserves_unset_team_name(self,
test_client, session):
"""A bulk create+overwrite that omits ``team_name`` must NOT reset an
existing pool's
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
index 9a61130e3c1..29c1f4a87d5 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_variables.py
@@ -22,7 +22,7 @@ from unittest import mock
from unittest.mock import ANY
import pytest
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.orm import Session
from airflow.api_fastapi.core_api.datamodels.common import BulkBody
@@ -616,6 +616,18 @@ class TestPatchVariable(TestVariableEndpoint):
in response.json()["detail"][0]["msg"]
)
+ # Only values a uri can carry: the body key has to match the uri parameter
to get past the
+ # mismatch check, and httpx refuses to put a raw tab or newline in a url.
+ @pytest.mark.parametrize("key", ["", " "])
+ def test_patch_variable_rejects_blank_key(self, test_client, session, key):
+ # PATCH takes the full VariableBody, so it picks up the same
constraint as POST. A blank
+ # key matched the equally blank `:path` uri parameter, so the update
went ahead.
+ Variable.set(key=key, value="old value", session=session)
+ session.commit()
+ response = test_client.patch(f"/variables/{key}", json={"key": key,
"value": "new value"})
+ assert response.status_code == 422
+ assert Variable.get(key, session=session) == "old value"
+
@pytest.mark.enable_redact
def test_patch_with_update_mask_description_only(self, test_client,
session):
"""PATCH with update_mask=['description'] should only update
description, keeping value unchanged."""
@@ -838,6 +850,12 @@ class TestPostVariable(TestVariableEndpoint):
in response.json()["detail"][0]["msg"]
)
+ @pytest.mark.parametrize("key", ["", " ", " ", "\t", "\n"])
+ def test_post_variable_rejects_blank_key(self, test_client, session, key):
+ response = test_client.post("/variables", json={"key": key, "value":
"some value"})
+ assert response.status_code == 422
+ assert session.scalar(select(Variable).filter_by(key=key)) is None
+
@pytest.mark.parametrize(
"body",
[
@@ -1601,6 +1619,30 @@ class TestBulkVariables(TestVariableEndpoint):
expected_error_keys = {err["input"]["key"] for err in detail}
assert sorted(expected_error_keys) == ["var_2", "var_3"]
+ @pytest.mark.parametrize("action", ["create", "update"])
+ @pytest.mark.parametrize("key", ["", " ", "\t"])
+ def test_bulk_rejects_blank_key(self, test_client, session, action, key):
+ # The bulk endpoint validates its entities as VariableBody, so one
blank key rejects the
+ # whole request before any action runs.
+ self.create_variables()
+ n_variables =
session.scalar(select(func.count()).select_from(Variable))
+ response = test_client.patch(
+ "/variables",
+ json={
+ "actions": [
+ {
+ "action": action,
+ "entities": [
+ {"key": TEST_VARIABLE_KEY, "value": "some value"},
+ {"key": key, "value": "some value"},
+ ],
+ }
+ ]
+ },
+ )
+ assert response.status_code == 422
+ assert session.scalar(select(func.count()).select_from(Variable)) ==
n_variables
+
@pytest.mark.parametrize("num_variables", [1, 25])
def test_bulk_delete_resolves_existence_in_single_query(self, session,
num_variables):
"""Bulk delete looks up all targeted variables in one query, not one
per key (no N+1)."""
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index e192f3a1d86..4777eb0a3d2 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -1170,7 +1170,7 @@ class PoolBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
- name: Annotated[str, Field(max_length=256, title="Name")]
+ name: Annotated[str, Field(max_length=256, pattern="\\S", title="Name")]
slots: Annotated[int, Field(description="Number of slots. Use -1 for
unlimited.", ge=-1, title="Slots")]
description: Annotated[str | None, Field(title="Description")] = None
include_deferred: Annotated[bool | None, Field(title="Include Deferred")]
= False
@@ -1542,7 +1542,7 @@ class VariableBody(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
- key: Annotated[str, Field(max_length=250, title="Key")]
+ key: Annotated[str, Field(max_length=250, pattern="\\S", title="Key")]
value: JsonValue
description: Annotated[str | None, Field(title="Description")] = None
team_name: Annotated[TeamName | None, Field(title="Team Name")] = None