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

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 60ca5766fb1 [v3-3-test] Reject reserved XCom serialization keys 
submitted as JSON string literals (#69378) (#69462)
60ca5766fb1 is described below

commit 60ca5766fb1b48fb574b5876608cdfbf8e66f2a3
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Jul 6 23:09:55 2026 +0200

    [v3-3-test] Reject reserved XCom serialization keys submitted as JSON 
string literals (#69378) (#69462)
    
    The XCom create/update payload validator _check_forbidden_xcom_keys
    recursively rejects reserved serialization keys (__classname__, __type,
    __var, ...) in dict/list values. Its _walk helper descended dicts, lists
    and tuples but not str, so a value submitted as a JSON string literal
    (e.g. json.dumps({"__classname__": ...})) was stored verbatim and re-parsed
    into a dict on a ?deserialize=true read, slipping past the filter.
    
    Extend _walk to json.loads a string value and inspect the decoded dict/list
    the same way the read path does. Strings that are not JSON, or that decode
    to non-container values, are unchanged and still accepted.
    (cherry picked from commit aae7c8f42789965940c0bf0fe9da25299d03521a)
    
    
    Generated-by: Claude Opus 4.8 (1M context) following the guidelines at
    https: 
//github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../api_fastapi/core_api/datamodels/xcom.py        | 14 +++++
 .../core_api/routes/public/test_xcom.py            | 59 ++++++++++++++++++++++
 2 files changed, 73 insertions(+)

diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/xcom.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/xcom.py
index b42cc176f01..e6987502ad3 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/xcom.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/xcom.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import json
 from collections.abc import Iterable
 from datetime import datetime
 from typing import Any
@@ -88,6 +89,19 @@ def _check_forbidden_xcom_keys(value: Any) -> Any:
     from airflow._shared.serialization import FORBIDDEN_XCOM_KEYS
 
     def _walk(obj: Any, path: str = "value") -> None:
+        if isinstance(obj, str):
+            # A value submitted as a JSON string literal (e.g. 
``json.dumps({...})``)
+            # is stored verbatim and re-parsed into a dict/list on a
+            # ``deserialize=true`` read, which would otherwise smuggle 
reserved keys
+            # past the dict/list checks below. Re-parse and inspect the decoded
+            # structure the same way the read path does.
+            try:
+                decoded = json.loads(obj)
+            except (ValueError, TypeError):
+                return
+            if isinstance(decoded, (dict, list)):
+                _walk(decoded, path)
+            return
         if isinstance(obj, dict):
             found = FORBIDDEN_XCOM_KEYS & obj.keys()
             if found:
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py
index 81723e05a8e..64ce784775c 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_xcom.py
@@ -746,6 +746,52 @@ class TestCreateXComEntry(TestXComEndpoint):
         assert "reserved serialization keys" in detail
         assert key in detail
 
+    @pytest.mark.parametrize(
+        "value",
+        [
+            pytest.param(
+                json.dumps({"__classname__": 
"airflow.sdk.definitions.connection.Connection"}),
+                id="classname-in-json-string",
+            ),
+            pytest.param(
+                json.dumps(
+                    {"nested": {"__type": 
"airflow.sdk.definitions.connection.Connection", "__var": {}}}
+                ),
+                id="nested-forbidden-in-json-string",
+            ),
+        ],
+    )
+    def test_create_xcom_entry_blocks_forbidden_keys_in_json_string(self, 
test_client, value):
+        """A forbidden payload submitted as a JSON string literal is blocked 
too.
+
+        ``_check_forbidden_xcom_keys._walk`` previously descended 
dict/list/tuple but not
+        ``str``, so a value like ``json.dumps({"__classname__": ...})`` 
slipped past the
+        filter and was reconstructed into a dict on a ``deserialize=true`` 
read.
+        """
+        response = test_client.post(
+            
f"/dags/{TEST_DAG_ID}/dagRuns/{run_id}/taskInstances/{TEST_TASK_ID}/xcomEntries",
+            json={"key": "test_key", "value": value, "map_index": -1},
+        )
+        assert response.status_code == 422
+        assert "reserved serialization keys" in str(response.json()["detail"])
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            pytest.param("just a plain string", id="plain-string"),
+            pytest.param(json.dumps({"safe": "data", "count": 3}), 
id="benign-json-object-string"),
+            pytest.param(json.dumps(["a", "b"]), 
id="benign-json-array-string"),
+            pytest.param('{"not valid json', id="not-json"),
+        ],
+    )
+    def test_create_xcom_entry_allows_benign_string_values(self, test_client, 
value):
+        """String values that do not decode to a reserved-key structure stay 
accepted."""
+        response = test_client.post(
+            
f"/dags/{TEST_DAG_ID}/dagRuns/{run_id}/taskInstances/{TEST_TASK_ID}/xcomEntries",
+            json={"key": "test_key", "value": value, "map_index": -1},
+        )
+        assert response.status_code != 422
+
 
 class TestDeleteXComEntry(TestXComEndpoint):
     def test_delete_xcom_entry(self, test_client, session):
@@ -894,6 +940,19 @@ class TestPatchXComEntry(TestXComEndpoint):
         assert "reserved serialization keys" in detail
         assert key in detail
 
+    def test_patch_xcom_entry_blocks_forbidden_keys_in_json_string(self, 
test_client):
+        """A forbidden payload submitted as a JSON string literal is blocked 
on PATCH too."""
+        self._create_xcom(TEST_XCOM_KEY, TEST_XCOM_VALUE)
+        response = test_client.patch(
+            
f"/dags/{TEST_DAG_ID}/dagRuns/{run_id}/taskInstances/{TEST_TASK_ID}/xcomEntries/{TEST_XCOM_KEY}",
+            json={
+                "value": json.dumps({"__classname__": 
"airflow.sdk.definitions.connection.Connection"}),
+                "map_index": -1,
+            },
+        )
+        assert response.status_code == 422
+        assert "reserved serialization keys" in str(response.json()["detail"])
+
     def test_patch_xcom_preserves_int_type(self, test_client, session):
         """Test scenario described in #59032: if existing XCom value type is 
int,
         after patching with different value, it should still be int in the API 
response.

Reply via email to