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 = {