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

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

commit a6d87ba5b0157baf744995d47dd5d6a47e798edb
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Jul 16 16:06:26 2026 +0200

    Skip stored credentials when a connection test overrides host or port 
(#69957)
    
    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.
---
 .../core_api/routes/public/connections.py          | 15 +++++
 .../core_api/routes/public/test_connections.py     | 65 ++++++++++++++++++++++
 2 files changed, 80 insertions(+)

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 c1d89c89798..19378dd8aea 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
@@ -349,6 +349,21 @@ def test_connection(
             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)
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 7c5ce03654e..231a7ca7b22 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
@@ -1348,6 +1348,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