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

shahar1 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 3bd5f761f5c Stop Task SDK client identifiers from reaching other API 
endpoints (#68617)
3bd5f761f5c is described below

commit 3bd5f761f5c0e0b38cc44ff4642ea9970041dd10
Author: Nayyar <[email protected]>
AuthorDate: Sat Nov 7 15:29:10 2026 +0530

    Stop Task SDK client identifiers from reaching other API endpoints (#68617)
    
    An identifier with "/", "." or ".." could send the request to a different 
Execution API endpoint.
    The server decodes "%2F" before it routes the request, so the client 
rejects "/" in a Dag, run or task ID.
    
    Co-authored-by: Shahar Epstein <[email protected]>
---
 task-sdk/src/airflow/sdk/api/client.py     |  67 ++++++++++-------
 task-sdk/tests/task_sdk/api/test_client.py | 114 +++++++++++++++++++++++++++++
 2 files changed, 156 insertions(+), 25 deletions(-)

diff --git a/task-sdk/src/airflow/sdk/api/client.py 
b/task-sdk/src/airflow/sdk/api/client.py
index 2e8a2753424..0eb04302736 100644
--- a/task-sdk/src/airflow/sdk/api/client.py
+++ b/task-sdk/src/airflow/sdk/api/client.py
@@ -250,6 +250,22 @@ def _log_and_trace_retry(retry_state) -> None:
         )
 
 
+def _path(prefix: str, *identifiers: str | uuid.UUID, tail: str | None = None) 
-> str:
+    """
+    Build a request path, quoting each identifier so it stays a single segment.
+
+    ``prefix`` is the fixed, trusted portion of the path. The server decodes 
``%2F`` before
+    routing, so a ``/`` in a positional identifier would shift it onto another 
route and is
+    rejected. ``tail`` is the last segment (a ``:path`` key or a final id) and 
may contain ``/``.
+    """
+    if any("/" in str(identifier) for identifier in identifiers):
+        raise ValueError(f"Path identifier must not contain '/': 
{identifiers!r}")
+    segments = [*identifiers] if tail is None else [*identifiers, tail]
+    quoted = (quote(str(segment), safe="") for segment in segments)
+    # quote() leaves a bare "." or ".." as is, and httpx would still collapse 
it as a dot segment.
+    return "/".join([prefix, *("%2E" * len(q) if q in (".", "..") else q for q 
in quoted)])
+
+
 class TaskInstanceOperations:
     __slots__ = ("client",)
 
@@ -460,7 +476,7 @@ class TaskInstanceOperations:
         if state:
             params["state"] = state.value if isinstance(state, 
TaskInstanceState) else state
 
-        resp = self.client.get(f"task-instances/previous/{dag_id}/{task_id}", 
params=params)
+        resp = self.client.get(_path("task-instances/previous", dag_id, 
task_id), params=params)
         return PreviousTIResult(task_instance=resp.json())
 
     def get_task_states(
@@ -515,7 +531,7 @@ class ConnectionOperations:
     def get(self, conn_id: str) -> ConnectionResponse | ErrorResponse:
         """Get a connection from the API server."""
         try:
-            resp = self.client.get(f"connections/{quote(conn_id, safe='')}")
+            resp = self.client.get(_path("connections", tail=conn_id))
         except ServerResponseError as e:
             if e.response.status_code == HTTPStatus.NOT_FOUND:
                 log.debug(
@@ -553,7 +569,7 @@ class VariableOperations:
     def get(self, key: str) -> VariableResponse | ErrorResponse:
         """Get a variable from the API server."""
         try:
-            resp = self.client.get(f"variables/{key}")
+            resp = self.client.get(_path("variables", tail=key))
         except ServerResponseError as e:
             if e.response.status_code == HTTPStatus.NOT_FOUND:
                 log.debug(
@@ -582,7 +598,7 @@ class VariableOperations:
     def set(self, key: str, value: str, description: str | None = None) -> 
OKResponse:
         """Set an Airflow Variable via the API server."""
         body = VariablePostBody(val=value, description=description)
-        self.client.put(f"variables/{key}", content=body.model_dump_json())
+        self.client.put(_path("variables", tail=key), 
content=body.model_dump_json())
         # Any error from the server will anyway be propagated down to the 
supervisor,
         # so we choose to send a generic response to the supervisor over the 
server response to
         # decouple from the server response string
@@ -593,7 +609,7 @@ class VariableOperations:
         key: str,
     ) -> OKResponse:
         """Delete a variable with given key via the API server."""
-        self.client.delete(f"variables/{key}")
+        self.client.delete(_path("variables", tail=key))
         # Any error from the server will anyway be propagated down to the 
supervisor,
         # so we choose to send a generic response to the supervisor over the 
server response to
         # decouple from the server response string
@@ -627,7 +643,7 @@ class XComOperations:
         params: dict[str, str | int] = {}
         if previous_iteration:
             params["previous_iteration"] = True
-        resp = self.client.head(f"xcoms/{dag_id}/{run_id}/{task_id}/{key}", 
params=params)
+        resp = self.client.head(_path("xcoms", dag_id, run_id, task_id, 
tail=key), params=params)
 
         # content_range: str | None
         if not (content_range := resp.headers["Content-Range"]) or not 
content_range.startswith(
@@ -656,7 +672,7 @@ class XComOperations:
         if include_prior_dates:
             params.update({"include_prior_dates": include_prior_dates})
         try:
-            resp = self.client.get(f"xcoms/{dag_id}/{run_id}/{task_id}/{key}", 
params=params)
+            resp = self.client.get(_path("xcoms", dag_id, run_id, task_id, 
tail=key), params=params)
         except ServerResponseError as e:
             if e.response.status_code == HTTPStatus.NOT_FOUND:
                 log.debug(
@@ -696,7 +712,7 @@ class XComOperations:
             params["map_index"] = map_index
         if mapped_length is not None and mapped_length >= 0:
             params["mapped_length"] = mapped_length
-        self.client.post(f"xcoms/{dag_id}/{run_id}/{task_id}/{key}", 
params=params, json=value)
+        self.client.post(_path("xcoms", dag_id, run_id, task_id, tail=key), 
params=params, json=value)
         # Any error from the server will anyway be propagated down to the 
supervisor,
         # so we choose to send a generic response to the supervisor over the 
server response to
         # decouple from the server response string
@@ -714,7 +730,7 @@ class XComOperations:
         params: dict[str, str | int] = {}
         if map_index is not None and map_index >= 0:
             params["map_index"] = map_index
-        self.client.delete(f"xcoms/{dag_id}/{run_id}/{task_id}/{key}", 
params=params)
+        self.client.delete(_path("xcoms", dag_id, run_id, task_id, tail=key), 
params=params)
         # Any error from the server will anyway be propagated down to the 
supervisor,
         # so we choose to send a generic response to the supervisor over the 
server response to
         # decouple from the server response string
@@ -735,7 +751,7 @@ class XComOperations:
             params["previous_iteration"] = True
         try:
             resp = self.client.get(
-                f"xcoms/{dag_id}/{run_id}/{task_id}/{key}/item/{offset}",
+                f"{_path('xcoms', dag_id, run_id, task_id, 
tail=key)}/item/{offset}",
                 params=params,
             )
         except ServerResponseError as e:
@@ -787,7 +803,7 @@ class XComOperations:
             params["step"] = step
         if include_prior_dates:
             params["include_prior_dates"] = include_prior_dates
-        resp = 
self.client.get(f"xcoms/{dag_id}/{run_id}/{task_id}/{key}/slice", params=params)
+        resp = self.client.get(f"{_path('xcoms', dag_id, run_id, task_id, 
tail=key)}/slice", params=params)
         return XComSequenceSliceResponse.model_validate_json(resp.read())
 
 
@@ -800,7 +816,7 @@ class TaskStateStoreOperations:
     def get(self, ti_id: uuid.UUID, key: str) -> TaskStateStoreResponse | 
ErrorResponse:
         """Get a task store value from the API server."""
         try:
-            resp = self.client.get(f"store/ti/{ti_id}/{quote(key, safe='')}")
+            resp = self.client.get(_path("store/ti", ti_id, tail=key))
         except ServerResponseError as e:
             if e.response.status_code == HTTPStatus.NOT_FOUND:
                 log.debug("Task store key not found", ti_id=ti_id, key=key)
@@ -811,17 +827,17 @@ class TaskStateStoreOperations:
     def set(self, ti_id: uuid.UUID, key: str, value: JsonValue, expires_at: 
datetime | None) -> OKResponse:
         """Set a task store value via the API server."""
         body = TaskStateStorePutBody(value=value, expires_at=expires_at)
-        self.client.put(f"store/ti/{ti_id}/{quote(key, safe='')}", 
content=body.model_dump_json())
+        self.client.put(_path("store/ti", ti_id, tail=key), 
content=body.model_dump_json())
         return OKResponse(ok=True)
 
     def delete(self, ti_id: uuid.UUID, key: str) -> OKResponse:
         """Delete a single task store key via the API server."""
-        self.client.delete(f"store/ti/{ti_id}/{quote(key, safe='')}")
+        self.client.delete(_path("store/ti", ti_id, tail=key))
         return OKResponse(ok=True)
 
     def clear(self, ti_id: uuid.UUID) -> OKResponse:
         """Clear all task store keys for a task instance via the API server."""
-        self.client.delete(f"store/ti/{ti_id}")
+        self.client.delete(_path("store/ti", ti_id))
         return OKResponse(ok=True)
 
 
@@ -1001,7 +1017,8 @@ class DagRunOperations:
 
         try:
             self.client.post(
-                f"dag-runs/{dag_id}/{run_id}", 
content=body.model_dump_json(exclude_defaults=True)
+                _path("dag-runs", dag_id, run_id),
+                content=body.model_dump_json(exclude_defaults=True),
             )
         except ServerResponseError as e:
             if e.response.status_code == HTTPStatus.CONFLICT:
@@ -1017,18 +1034,18 @@ class DagRunOperations:
 
     def clear(self, dag_id: str, run_id: str) -> OKResponse:
         """Clear a Dag run via the API server."""
-        self.client.post(f"dag-runs/{dag_id}/{run_id}/clear")
+        self.client.post(f"{_path('dag-runs', dag_id, run_id)}/clear")
         # TODO: Error handling
         return OKResponse(ok=True)
 
     def get_detail(self, dag_id: str, run_id: str) -> DagRun:
         """Get detail of a dag run."""
-        resp = self.client.get(f"dag-runs/{dag_id}/{run_id}")
+        resp = self.client.get(_path("dag-runs", dag_id, run_id))
         return DagRun.model_validate_json(resp.read())
 
     def get_state(self, dag_id: str, run_id: str) -> DagRunStateResponse:
         """Get the state of a Dag run via the API server."""
-        resp = self.client.get(f"dag-runs/{dag_id}/{run_id}/state")
+        resp = self.client.get(f"{_path('dag-runs', dag_id, run_id)}/state")
         return DagRunStateResponse.model_validate_json(resp.read())
 
     def get_count(
@@ -1077,7 +1094,7 @@ class DagsOperations:
 
     def get(self, dag_id: str) -> DagResponse:
         """Get a DAG via the API server."""
-        resp = self.client.get(f"dags/{quote(dag_id, safe='')}")
+        resp = self.client.get(_path("dags", tail=dag_id))
         return DagResponse.model_validate_json(resp.read())
 
 
@@ -1117,7 +1134,7 @@ class HITLOperations:
             assigned_users=assigned_users,
         )
         resp = self.client.post(
-            f"/hitlDetails/{ti_id}",
+            _path("/hitlDetails", ti_id),
             content=payload.model_dump_json(),
             # One key per request, reused by the client's retries, so a 
retried POST cannot replace
             # the request (and discard an answer) after the first attempt was 
stored.
@@ -1139,14 +1156,14 @@ class HITLOperations:
             params_input=params_input,
         )
         resp = self.client.patch(
-            f"/hitlDetails/{ti_id}",
+            _path("/hitlDetails", ti_id),
             content=payload.model_dump_json(),
         )
         return HITLDetailResponse.model_validate_json(resp.read())
 
     def get_detail_response(self, ti_id: uuid.UUID) -> HITLDetailResponse:
         """Get content part of a Human-in-the-loop response for a specific 
Task Instance."""
-        resp = self.client.get(f"/hitlDetails/{ti_id}")
+        resp = self.client.get(_path("/hitlDetails", ti_id))
         return HITLDetailResponse.model_validate_json(resp.read())
 
 
@@ -1158,7 +1175,7 @@ class ConnectionTestOperations:
 
     def get_connection(self, connection_test_id: uuid.UUID) -> 
ConnectionTestConnectionResponse:
         """Fetch connection data for a test request from the API server."""
-        resp = 
self.client.get(f"connection-tests/{connection_test_id}/connection")
+        resp = self.client.get(f"{_path('connection-tests', 
connection_test_id)}/connection")
         return 
ConnectionTestConnectionResponse.model_validate_json(resp.read())
 
     def update_state(
@@ -1171,7 +1188,7 @@ class ConnectionTestOperations:
             state=state,
             result_message=ResultMessage(result_message) if result_message is 
not None else None,
         )
-        self.client.patch(f"connection-tests/{id}", 
content=body.model_dump_json())
+        self.client.patch(_path("connection-tests", id), 
content=body.model_dump_json())
 
 
 class CallbackOperations:
diff --git a/task-sdk/tests/task_sdk/api/test_client.py 
b/task-sdk/tests/task_sdk/api/test_client.py
index c9b862fb336..ca0c2793a52 100644
--- a/task-sdk/tests/task_sdk/api/test_client.py
+++ b/task-sdk/tests/task_sdk/api/test_client.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import contextlib
 import json
 import pickle
 from datetime import UTC, datetime
@@ -382,6 +383,119 @@ class TestClient:
         with pytest.raises(ValueError, match="Both client_ssl_cert and 
client_ssl_key must be set"):
             make_client(httpx2.MockTransport(handle_request))
 
+    @pytest.mark.parametrize(
+        ("call", "expected_path"),
+        [
+            pytest.param(
+                lambda c: c.variables.get("../connections/aws_default"),
+                b"/variables/..%2Fconnections%2Faws_default",
+                id="variables-get",
+            ),
+            pytest.param(
+                lambda c: c.variables.set("../connections/aws_default", "v"),
+                b"/variables/..%2Fconnections%2Faws_default",
+                id="variables-set",
+            ),
+            pytest.param(
+                lambda c: c.variables.delete("../connections/aws_default"),
+                b"/variables/..%2Fconnections%2Faws_default",
+                id="variables-delete",
+            ),
+            pytest.param(
+                lambda c: c.xcoms.head("d", "r", "t", "x/../../variables/k"),
+                b"/xcoms/d/r/t/x%2F..%2F..%2Fvariables%2Fk",
+                id="xcoms-head",
+            ),
+            pytest.param(
+                lambda c: c.xcoms.get("d", "r", "t", "x/../../variables/k"),
+                b"/xcoms/d/r/t/x%2F..%2F..%2Fvariables%2Fk",
+                id="xcoms-get",
+            ),
+            pytest.param(
+                lambda c: c.xcoms.set("d", "r", "t", "x/../../variables/k", 
"v"),
+                b"/xcoms/d/r/t/x%2F..%2F..%2Fvariables%2Fk",
+                id="xcoms-set",
+            ),
+            pytest.param(
+                lambda c: c.xcoms.delete("d", "r", "t", "x/../../variables/k"),
+                b"/xcoms/d/r/t/x%2F..%2F..%2Fvariables%2Fk",
+                id="xcoms-delete",
+            ),
+            pytest.param(
+                lambda c: c.xcoms.get_sequence_item("d", "r", "t", 
"x/../../variables/k", 0),
+                b"/xcoms/d/r/t/x%2F..%2F..%2Fvariables%2Fk/item/0",
+                id="xcoms-item",
+            ),
+            pytest.param(
+                lambda c: c.xcoms.get_sequence_slice("d", "r", "t", 
"x/../../variables/k", None, None, None),
+                b"/xcoms/d/r/t/x%2F..%2F..%2Fvariables%2Fk/slice",
+                id="xcoms-slice",
+            ),
+            pytest.param(
+                lambda c: c.dag_runs.trigger("..", "r"), 
b"/dag-runs/%2E%2E/r", id="dag-runs-trigger"
+            ),
+            pytest.param(
+                lambda c: c.dag_runs.clear("d", ".."), 
b"/dag-runs/d/%2E%2E/clear", id="dag-runs-clear"
+            ),
+            pytest.param(
+                lambda c: c.dag_runs.get_detail("..", "r"), 
b"/dag-runs/%2E%2E/r", id="dag-runs-detail"
+            ),
+            pytest.param(
+                lambda c: c.dag_runs.get_state("d", ".."), 
b"/dag-runs/d/%2E%2E/state", id="dag-runs-state"
+            ),
+            pytest.param(
+                lambda c: c.task_instances.get_previous("..", "t"),
+                b"/task-instances/previous/%2E%2E/t",
+                id="get-previous",
+            ),
+            pytest.param(lambda c: c.connections.get(".."), 
b"/connections/%2E%2E", id="connection-dotdot"),
+            pytest.param(lambda c: c.connections.get("."), 
b"/connections/%2E", id="connection-dot"),
+            pytest.param(
+                lambda c: c.xcoms.get("..", "..", "variables", "secret"),
+                b"/xcoms/%2E%2E/%2E%2E/variables/secret",
+                id="xcoms-dots",
+            ),
+        ],
+    )
+    def test_path_identifiers_stay_in_their_segment(self, call, expected_path):
+        """Caller-supplied identifiers cannot reach another endpoint, whatever 
they contain."""
+        requests_seen = []
+
+        def handle_request(request: httpx2.Request) -> httpx2.Response:
+            requests_seen.append(request)
+            return httpx2.Response(
+                status_code=200, content=b"null", headers={"Content-Range": 
"map_indexes 1"}
+            )
+
+        with contextlib.suppress(Exception):  # the stub body need not parse; 
only the URL matters
+            call(make_client(transport=httpx2.MockTransport(handle_request)))
+
+        assert requests_seen[0].url.raw_path.split(b"?")[0] == expected_path
+
+    @pytest.mark.parametrize(
+        "call",
+        [
+            pytest.param(lambda c: c.dag_runs.trigger("d", "x/clear"), 
id="trigger-run-id"),
+            pytest.param(lambda c: c.dag_runs.trigger("a/b", "r"), 
id="trigger-dag-id"),
+            pytest.param(lambda c: c.xcoms.get("a/b", "r", "t", "k"), 
id="xcoms-dag-id"),
+            pytest.param(lambda c: c.xcoms.get("d", "a/b", "t", "k"), 
id="xcoms-run-id"),
+            pytest.param(lambda c: c.xcoms.get("d", "r", "a/b", "k"), 
id="xcoms-task-id"),
+            pytest.param(lambda c: c.task_instances.get_previous("a/b", "t"), 
id="previous-dag-id"),
+            pytest.param(lambda c: c.task_instances.get_previous("d", "a/b"), 
id="previous-task-id"),
+        ],
+    )
+    def test_path_rejects_slash_in_positional_identifier(self, call):
+        requests_seen = []
+
+        def handle_request(request: httpx2.Request) -> httpx2.Response:
+            requests_seen.append(request)
+            return httpx2.Response(status_code=200, content=b"null")
+
+        with pytest.raises(ValueError, match="must not contain '/'"):
+            call(make_client(transport=httpx2.MockTransport(handle_request)))
+
+        assert requests_seen == []
+
 
 class TestTaskInstanceOperations:
     """

Reply via email to