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

pierrejeambrun 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 944a345e364 Skip stored credentials when a connection test overrides 
host or port (#69957) (#70010)
944a345e364 is described below

commit 944a345e36444a366cf7ba502d5533743b9c8560
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Jul 17 15:26:26 2026 +0200

    Skip stored credentials when a connection test overrides host or port 
(#69957) (#70010)
    
    The POST /connections/test endpoint restores an existing connection's stored
    credentials (password/extra) when the request sends the masked sentinel, so 
a
    saved connection can be tested without re-typing secrets. Connection
    configuration users have write-only access to those credentials and are not
    meant to view them; reusing them while the request points at a 
caller-supplied
    host or port applies them against a destination the caller chose rather than
    the connection's own, which is outside the intent of the retype-free test 
flow.
    
    Scope the credential reuse to requests that keep the stored connection's 
host
    and port. Follows the same hardening as #67620.
    
    (cherry picked from commit a6d87ba5b0157baf744995d47dd5d6a47e798edb)
---
 .../core_api/routes/public/connections.py          | 23 +++++++-
 .../core_api/routes/public/test_connections.py     | 65 ++++++++++++++++++++++
 2 files changed, 86 insertions(+), 2 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py 
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
index a1827253180..2799a49cd41 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/connections.py
@@ -307,11 +307,30 @@ def test_connection(test_body: ConnectionBody) -> 
ConnectionTestResponse:
     try:
         # Try to get existing connection and merge with provided values
         try:
-            existing_conn = 
Connection.get_connection_from_secrets(test_body.connection_id)
+            existing_conn: Connection | None = 
Connection.get_connection_from_secrets(test_body.connection_id)
+        except AirflowNotFoundException:
+            existing_conn = None
+
+        if existing_conn is not None:
+            # Stored credentials are only reused to test the connection's own
+            # host/port; testing a different destination must supply its own.
+            fields_set = test_body.model_fields_set
+            if ("host" in fields_set and test_body.host != existing_conn.host) 
or (
+                "port" in fields_set and test_body.port != existing_conn.port
+            ):
+                if "password" not in fields_set:
+                    raise HTTPException(
+                        status.HTTP_400_BAD_REQUEST,
+                        "The host or port to test differs from the stored 
connection. "
+                        "Include the credentials to test in the request body.",
+                    )
+                existing_conn = None
+
+        if existing_conn is not None:
             existing_conn.conn_id = transient_conn_id
             update_orm_from_pydantic(existing_conn, test_body)
             conn = existing_conn
-        except AirflowNotFoundException:
+        else:
             data = test_body.model_dump(by_alias=True)
             data["conn_id"] = transient_conn_id
             conn = Connection(**data)
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
index 74920b11d51..43a2183359d 100644
--- 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
+++ 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_connections.py
@@ -1221,6 +1221,71 @@ class TestConnection(TestConnectionEndpoint):
         assert json.loads(db_conn.extra) == {"path": "/", "existing_key": 
"existing_value"}
         assert session.scalar(select(func.count()).select_from(Connection)) == 
initial_count
 
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    @pytest.mark.parametrize(
+        "override",
+        [
+            pytest.param({"host": "other_host"}, id="host-changed"),
+            pytest.param({"host": "stored_host", "port": 9999}, 
id="port-changed"),
+        ],
+    )
+    def test_should_reject_test_when_target_overridden_without_credentials(
+        self, test_client, session, override
+    ):
+        session.add(
+            Connection(
+                conn_id=TEST_CONN_ID,
+                conn_type="sqlite",
+                host="stored_host",
+                port=1234,
+                password="existing_password",
+            )
+        )
+        session.commit()
+
+        body = {"connection_id": TEST_CONN_ID, "conn_type": "sqlite", 
**override}
+        response = test_client.post("/connections/test", json=body)
+
+        assert response.status_code == 400
+
+    @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
+    @pytest.mark.parametrize(
+        ("override", "expected_password"),
+        [
+            pytest.param(
+                {"host": "stored_host", "port": 1234}, "existing_password", 
id="same-target-reuses-stored"
+            ),
+            pytest.param(
+                {"host": "other_host", "password": "supplied_password"},
+                "supplied_password",
+                id="overridden-target-uses-supplied-creds",
+            ),
+        ],
+    )
+    def test_stored_secret_reused_only_for_same_target(
+        self, test_client, session, override, expected_password
+    ):
+        session.add(
+            Connection(
+                conn_id=TEST_CONN_ID,
+                conn_type="sqlite",
+                host="stored_host",
+                port=1234,
+                password="existing_password",
+            )
+        )
+        session.commit()
+
+        body = {"connection_id": TEST_CONN_ID, "conn_type": "sqlite", 
**override}
+
+        with mock.patch.object(Connection, "test_connection", autospec=True) 
as mock_test:
+            mock_test.return_value = (True, "mocked")
+            response = test_client.post("/connections/test", json=body)
+
+        assert response.status_code == 200
+        tested_connection = mock_test.call_args.args[0]
+        assert tested_connection.password == expected_password
+
     @mock.patch.dict(os.environ, {"AIRFLOW__CORE__TEST_CONNECTION": "Enabled"})
     def test_should_test_new_connection_without_existing(self, test_client):
         body = {

Reply via email to