This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 33c6df77c99 fix(database): don't reattach stored connection details
when the effective destination changes (#44003)
33c6df77c99 is described below
commit 33c6df77c99fa83991271b9fe1f95d691ba3fa9f
Author: Evan Rusackas <[email protected]>
AuthorDate: Tue Sep 8 11:02:42 2026 -0700
fix(database): don't reattach stored connection details when the effective
destination changes (#44003)
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Shaitan <[email protected]>
---
superset/commands/database/exceptions.py | 29 ++
superset/commands/database/importers/v1/utils.py | 60 ++---
superset/commands/database/test_connection.py | 41 ++-
superset/commands/database/update.py | 87 ++++++
superset/commands/database/utils.py | 96 +++++++
superset/commands/database/validate.py | 47 +++-
superset/daos/database.py | 17 +-
.../commands/databases/test_connection_test.py | 60 +++++
tests/unit_tests/commands/databases/update_test.py | 297 +++++++++++++++++++++
.../unit_tests/commands/databases/validate_test.py | 205 ++++++++++++++
.../databases/commands/importers/v1/import_test.py | 43 +++
11 files changed, 936 insertions(+), 46 deletions(-)
diff --git a/superset/commands/database/exceptions.py
b/superset/commands/database/exceptions.py
index b8877270fd5..3002696a42e 100644
--- a/superset/commands/database/exceptions.py
+++ b/superset/commands/database/exceptions.py
@@ -44,6 +44,26 @@ class DatabaseExistsValidationError(ValidationError):
)
+class DatabaseUpdateUnsafeRebindError(ValidationError):
+ """
+ Marshmallow validation error for an update that would change a
+ database's effective connection destination while leaving the stored
+ password/encrypted_extra/SSH tunnel credential masked.
+ """
+
+ def __init__(self, field_name: str = "sqlalchemy_uri") -> None:
+ super().__init__(
+ _(
+ "This update would change the connection's effective "
+ "destination (host/port, engine parameters, or SSH tunnel "
+ "endpoint) while reusing the stored credential. Provide "
+ "the real password (or SSH tunnel credential) to confirm "
+ "a connection move."
+ ),
+ field_name=field_name,
+ )
+
+
class DatabaseRequiredFieldValidationError(ValidationError):
def __init__(self, field_name: str) -> None:
super().__init__(
@@ -174,6 +194,15 @@ class DatabaseSecurityUnsafeError(CommandInvalidError):
message = _("Stopped an unsafe database connection")
+class DatabaseTestConnectionUnsafeRebindError(CommandInvalidError):
+ message = _(
+ "Testing this connection would change its effective destination "
+ "(engine parameters or SSH tunnel endpoint) while reusing the stored "
+ "password. Provide the real password to test a connection whose "
+ "destination has changed."
+ )
+
+
class DatabaseTestConnectionDriverError(CommandInvalidError):
message = _("Could not load database driver")
diff --git a/superset/commands/database/importers/v1/utils.py
b/superset/commands/database/importers/v1/utils.py
index 973aaad8b80..5357500c197 100644
--- a/superset/commands/database/importers/v1/utils.py
+++ b/superset/commands/database/importers/v1/utils.py
@@ -22,7 +22,12 @@ from flask import current_app as app
from superset import db, security_manager
from superset.commands.database.exceptions import DatabaseInvalidError
-from superset.commands.database.utils import add_permissions
+from superset.commands.database.utils import (
+ add_permissions,
+ engine_params_changed,
+ ssh_tunnel_rebind_unsafe,
+ uri_identity_changed,
+)
from superset.commands.exceptions import ImportFailedError
from superset.constants import PASSWORD_MASK
from superset.databases.ssh_tunnel.models import SSHTunnel
@@ -41,14 +46,19 @@ logger = logging.getLogger(__name__)
def _connection_identity_changed(existing: Database, config: dict[str, Any])
-> bool:
"""Whether the import points the database at a different endpoint."""
- try:
- stored = make_url_safe(existing.sqlalchemy_uri)._replace(password=None)
- incoming =
make_url_safe(config["sqlalchemy_uri"])._replace(password=None)
- except DatabaseInvalidError:
- # An unparseable URI cannot be compared: treat it as a change so
- # stored secrets never survive onto it.
+ if uri_identity_changed(existing.sqlalchemy_uri,
config.get("sqlalchemy_uri")):
return True
- return stored != incoming
+
+ # The URI's host/port aren't the whole story: `extra.engine_params`
+ # (e.g. `connect_args.host`/`port`) is merged into the actual DBAPI
+ # connect kwargs and can override them. An import that opens a live
+ # connection (`add_permissions` -> `get_all_catalog_names`) with a
+ # rehydrated stored password must not do so against a destination this
+ # field silently redirected.
+ submitted_extra = config.get("extra")
+ if isinstance(submitted_extra, dict):
+ submitted_extra = json.dumps(submitted_extra)
+ return engine_params_changed(existing.extra, submitted_extra)
def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) ->
None:
@@ -77,33 +87,13 @@ def _refuse_stored_secret_reuse(existing: Database, config:
dict[str, Any]) -> N
"connection to confirm the change."
)
- if ssh_tunnel := config.get("ssh_tunnel"):
- existing_tunnel = existing.ssh_tunnel
- if existing_tunnel and (
- ssh_tunnel.get("server_address") != existing_tunnel.server_address
- or ssh_tunnel.get("server_port") != existing_tunnel.server_port
- ):
- has_fresh_credential = any(
- ssh_tunnel.get(field) not in (None, PASSWORD_MASK)
- for field in ("password", "private_key")
- )
- # A passphrase-protected private key's stored passphrase is a
- # secret in its own right: if the existing tunnel had one, a
- # repoint that supplies a fresh private_key but leaves
- # private_key_password masked/absent would keep the old
- # passphrase attached to the new key rather than requiring the
- # importer to confirm it too.
- stale_private_key_password = (
- existing_tunnel.private_key_password is not None
- and ssh_tunnel.get("private_key_password") in (None,
PASSWORD_MASK)
- )
- if not has_fresh_credential or stale_private_key_password:
- raise ImportFailedError(
- f"Import would change the SSH tunnel endpoint of database "
- f"'{existing.database_name}' without providing new tunnel "
- "credentials. Re-enter the SSH tunnel credentials to "
- "confirm the change."
- )
+ if ssh_tunnel_rebind_unsafe(existing.ssh_tunnel, config.get("ssh_tunnel")):
+ raise ImportFailedError(
+ f"Import would change the SSH tunnel endpoint of database "
+ f"'{existing.database_name}' without providing new tunnel "
+ "credentials. Re-enter the SSH tunnel credentials to "
+ "confirm the change."
+ )
def import_database( # noqa: C901
diff --git a/superset/commands/database/test_connection.py
b/superset/commands/database/test_connection.py
index 1247a2dee5a..a128afc272d 100644
--- a/superset/commands/database/test_connection.py
+++ b/superset/commands/database/test_connection.py
@@ -26,13 +26,18 @@ from superset.commands.database.exceptions import (
DatabaseSecurityUnsafeError,
DatabaseTestConnectionDriverError,
DatabaseTestConnectionUnexpectedError,
+ DatabaseTestConnectionUnsafeRebindError,
)
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
SSHTunnelingNotEnabledError,
)
-from superset.commands.database.utils import ping
+from superset.commands.database.utils import (
+ engine_params_changed,
+ ping,
+ ssh_tunnel_endpoint_changed,
+)
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.errors import ErrorLevel, SupersetErrorType
@@ -65,6 +70,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
_model: Optional[Database] = None
_context: dict[str, Any]
_uri: str
+ _identity_changed: bool
+ _ssh_tunnel_endpoint_changed: bool
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
@@ -73,8 +80,27 @@ class TestConnectionDatabaseCommand(BaseCommand):
self._model = DatabaseDAO.get_database_by_name(database_name)
uri = self._properties.get("sqlalchemy_uri", "")
- if self._model and uri == self._model.safe_sqlalchemy_uri():
- uri = self._model.sqlalchemy_uri_decrypted
+ self._identity_changed = False
+ self._ssh_tunnel_endpoint_changed = False
+ if (model := self._model) is not None:
+ # A stored password (and, below, encrypted_extra / SSH tunnel
+ # credentials) must never be rehydrated onto a connection whose
+ # final effective destination the requester can change. The
+ # visible `sqlalchemy_uri` is only one part of that destination:
+ # `extra.engine_params` (merged into the DBAPI connect kwargs,
+ # e.g. `connect_args.host`/`port`) and the SSH tunnel endpoint
+ # can both override it after this decision is made.
+ self._ssh_tunnel_endpoint_changed = ssh_tunnel_endpoint_changed(
+ model.ssh_tunnel, self._properties.get("ssh_tunnel")
+ )
+ self._identity_changed = (
+ engine_params_changed(model.extra,
self._properties.get("extra", "{}"))
+ or self._ssh_tunnel_endpoint_changed
+ )
+ if uri == model.safe_sqlalchemy_uri():
+ if self._identity_changed:
+ raise DatabaseTestConnectionUnsafeRebindError()
+ uri = model.sqlalchemy_uri_decrypted
url = make_url_safe(uri)
@@ -102,7 +128,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
"masked_encrypted_extra",
"{}",
)
- if self._model:
+ if self._model and not self._identity_changed:
serialized_encrypted_extra = (
self._model.db_engine_spec.unmask_encrypted_extra(
self._model.encrypted_extra,
@@ -112,7 +138,12 @@ class TestConnectionDatabaseCommand(BaseCommand):
# collect SSH tunnel info
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
- if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
+ if (
+ ssh_tunnel_properties
+ and self._model
+ and self._model.ssh_tunnel
+ and not self._ssh_tunnel_endpoint_changed
+ ):
# unmask password while allowing for updated values
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
diff --git a/superset/commands/database/update.py
b/superset/commands/database/update.py
index 124bf11c87b..f0d079b9121 100644
--- a/superset/commands/database/update.py
+++ b/superset/commands/database/update.py
@@ -30,10 +30,18 @@ from superset.commands.database.exceptions import (
DatabaseInvalidError,
DatabaseNotFoundError,
DatabaseUpdateFailedError,
+ DatabaseUpdateUnsafeRebindError,
MissingOAuth2TokenError,
)
from superset.commands.database.sync_permissions import SyncPermissionsCommand
+from superset.commands.database.utils import (
+ engine_params_changed,
+ ssh_tunnel_rebind_unsafe,
+ uri_identity_changed,
+)
+from superset.constants import PASSWORD_MASK
from superset.daos.database import DatabaseDAO
+from superset.databases.utils import make_url_safe
from superset.exceptions import OAuth2RedirectError
from superset.models.core import Database
from superset.utils import json
@@ -180,3 +188,82 @@ class UpdateDatabaseCommand(BaseCommand):
database_name,
):
raise
DatabaseInvalidError(exceptions=[DatabaseExistsValidationError()])
+
+ if self._model:
+ self._check_no_unsafe_secret_rebind()
+
+ def _check_no_unsafe_secret_rebind(self) -> None:
+ """
+ Refuse an update that changes the connection's effective destination
+ (URI host/port, `extra.engine_params`, or the SSH tunnel endpoint)
+ while leaving the corresponding stored secret masked.
+
+ Without this, an editor could silently redirect the real stored
+ password/encrypted_extra/SSH tunnel credential to a different
+ destination -- and since an update persists, every subsequent use of
+ the database (by any user) would send the real secret there, not
+ just the editor's own request.
+ """
+ model = self._model
+ assert model is not None
+
+ connection_identity_changed = False
+ submitted_password: str | None = None
+
+ if "sqlalchemy_uri" in self._properties:
+ submitted_uri = self._properties["sqlalchemy_uri"] or ""
+ connection_identity_changed = uri_identity_changed(
+ model.sqlalchemy_uri, submitted_uri
+ )
+ try:
+ submitted_password = make_url_safe(submitted_uri).password
+ except DatabaseInvalidError:
+ submitted_password = None
+
+ if "extra" in self._properties and engine_params_changed(
+ model.extra, self._properties["extra"]
+ ):
+ connection_identity_changed = True
+
+ if connection_identity_changed:
+ # The URI password is only one of the secrets that can silently
+ # carry over onto a changed destination. `encrypted_extra` (e.g.
+ # a service-account key or OAuth2 client secret) is reattached
+ # unconditionally in `run()` via `unmask_encrypted_extra` unless
+ # we catch it here -- gating on the URI password alone would
+ # both miss that reuse when a fresh URI password is supplied,
+ # and wrongly block engines that keep credentials entirely in
+ # `encrypted_extra` and carry no URI password at all (BigQuery,
+ # GSheets), since those never have a "fresh" URI password to
+ # give.
+ uri_password_reused = model.password is not None and
submitted_password in (
+ None,
+ PASSWORD_MASK,
+ )
+ # encrypted_extra is a blob with per-field masks, so "reused"
+ # means unmasking the submission against the stored value
+ # changes nothing -- including not submitting it at all, which
+ # leaves the old (real) value attached unchanged.
+ encrypted_extra_reused = model.encrypted_extra not in (
+ None,
+ "",
+ "{}",
+ ) and (
+ "masked_encrypted_extra" not in self._properties
+ or model.db_engine_spec.unmask_encrypted_extra(
+ model.encrypted_extra,
+ self._properties["masked_encrypted_extra"],
+ )
+ == model.encrypted_extra
+ )
+ if uri_password_reused or encrypted_extra_reused:
+ raise DatabaseInvalidError(
+ exceptions=[DatabaseUpdateUnsafeRebindError()]
+ )
+
+ if "ssh_tunnel" in self._properties and ssh_tunnel_rebind_unsafe(
+ model.ssh_tunnel, self._properties["ssh_tunnel"]
+ ):
+ raise DatabaseInvalidError(
+
exceptions=[DatabaseUpdateUnsafeRebindError(field_name="ssh_tunnel")]
+ )
diff --git a/superset/commands/database/utils.py
b/superset/commands/database/utils.py
index 0c25f8173c2..0d40516419c 100644
--- a/superset/commands/database/utils.py
+++ b/superset/commands/database/utils.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import sqlite3
from contextlib import closing
+from typing import Any
from flask import current_app as app
from flask_appbuilder.security.sqla.models import (
@@ -30,14 +31,109 @@ from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from superset import security_manager
+from superset.commands.database.exceptions import DatabaseInvalidError
+from superset.constants import PASSWORD_MASK
+from superset.databases.ssh_tunnel.models import SSHTunnel
+from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import GenericDBException
from superset.models.core import Database
from superset.security.manager import SupersetSecurityManager
+from superset.utils import json
from superset.utils.core import timeout
logger = logging.getLogger(__name__)
+def uri_identity_changed(existing_uri: str | None, submitted_uri: str | None)
-> bool:
+ """
+ Whether two SQLAlchemy URIs differ once their password is stripped --
+ i.e. whether the effective connection destination (driver, host, port,
+ database, username, query params) changed.
+ """
+ try:
+ stored = make_url_safe(existing_uri or "")._replace(password=None)
+ incoming = make_url_safe(submitted_uri or "")._replace(password=None)
+ except DatabaseInvalidError:
+ # An unparseable URI cannot be compared: treat it as a change so a
+ # stored secret never survives onto it.
+ return True
+ return stored != incoming
+
+
+def engine_params_changed(
+ existing_extra: str | None, submitted_extra: str | None
+) -> bool:
+ """
+ Whether ``submitted_extra`` carries different ``engine_params`` than
+ ``existing_extra``.
+
+ ``engine_params`` (in particular ``engine_params.connect_args``) is
+ merged into the actual DBAPI connect kwargs, so it can override the
+ host/port/etc. carried in the SQLAlchemy URI itself. Any caller that
+ conditionally reattaches a stored secret (password, encrypted_extra, SSH
+ tunnel credentials) based on the URI being unchanged must also check
+ this, or the destination can be silently redirected while the real
+ secret rides along.
+ """
+
+ def _engine_params(serialized_extra: str | None) -> dict[str, Any]:
+ try:
+ return json.loads(serialized_extra or "{}").get("engine_params",
{})
+ except (json.JSONDecodeError, AttributeError):
+ # Unparseable/non-dict `extra` cannot be compared: treat it as a
+ # change so a stored secret never rides along with input that
+ # can't be verified to leave the connection identity untouched.
+ return {"__unparseable__": True}
+
+ return _engine_params(submitted_extra) != _engine_params(existing_extra)
+
+
+def ssh_tunnel_endpoint_changed(
+ existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
+) -> bool:
+ """
+ Whether a submitted SSH tunnel config points at a different endpoint
+ than the stored tunnel it would otherwise inherit credentials from.
+ """
+ if not submitted_tunnel or not existing_tunnel:
+ return False
+ return bool(
+ submitted_tunnel.get("server_address") !=
existing_tunnel.server_address
+ or submitted_tunnel.get("server_port") != existing_tunnel.server_port
+ )
+
+
+def ssh_tunnel_rebind_unsafe(
+ existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
+) -> bool:
+ """
+ Whether a submitted SSH tunnel config repoints the tunnel at a
+ different endpoint without supplying credentials fresh enough to
+ justify it -- i.e. whether carrying the stored tunnel secrets over
+ onto this submission would be unsafe.
+ """
+ if not ssh_tunnel_endpoint_changed(existing_tunnel, submitted_tunnel):
+ return False
+
+ assert submitted_tunnel is not None
+ assert existing_tunnel is not None
+
+ has_fresh_credential = any(
+ submitted_tunnel.get(field) not in (None, PASSWORD_MASK)
+ for field in ("password", "private_key")
+ )
+ # A passphrase-protected private key's stored passphrase is a secret in
+ # its own right: if the existing tunnel had one, a repoint that
+ # supplies a fresh private_key but leaves private_key_password
+ # masked/absent would keep the old passphrase attached to the new key
+ # rather than requiring the caller to confirm it too.
+ stale_private_key_password = (
+ existing_tunnel.private_key_password is not None
+ and submitted_tunnel.get("private_key_password") in (None,
PASSWORD_MASK)
+ )
+ return not has_fresh_credential or stale_private_key_password
+
+
def ping(engine: Engine) -> bool:
try:
time_delta = app.config["TEST_DATABASE_CONNECTION_TIMEOUT"]
diff --git a/superset/commands/database/validate.py
b/superset/commands/database/validate.py
index df7b4c0f363..6edf192d842 100644
--- a/superset/commands/database/validate.py
+++ b/superset/commands/database/validate.py
@@ -27,6 +27,10 @@ from superset.commands.database.exceptions import (
InvalidEngineError,
InvalidParametersError,
)
+from superset.commands.database.utils import (
+ engine_params_changed,
+ ssh_tunnel_endpoint_changed,
+)
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.db_engine_specs import get_engine_spec
@@ -90,11 +94,28 @@ class ValidateDatabaseParametersCommand(BaseCommand):
event_logger.log_with_context(action="validation_error",
engine=engine)
raise InvalidParametersError(errors)
+ # A stored password/encrypted_extra/SSH tunnel credential must never
+ # be rehydrated onto a connection whose final effective destination
+ # the caller can change. `parameters` only covers what feeds into
+ # `sqlalchemy_uri` here -- `extra.engine_params` (merged into the
+ # actual DBAPI connect kwargs, e.g. `connect_args.host`/`port`) and
+ # the SSH tunnel endpoint can both override it independently.
+ identity_changed = False
+ ssh_tunnel_changed = False
+ if (model := self._model) is not None:
+ ssh_tunnel_changed = ssh_tunnel_endpoint_changed(
+ model.ssh_tunnel, self._properties.get("ssh_tunnel")
+ )
+ identity_changed = (
+ engine_params_changed(model.extra,
self._properties.get("extra", "{}"))
+ or ssh_tunnel_changed
+ )
+
serialized_encrypted_extra = self._properties.get(
"masked_encrypted_extra",
"{}",
)
- if self._model:
+ if self._model and not identity_changed:
serialized_encrypted_extra = engine_spec.unmask_encrypted_extra(
self._model.encrypted_extra,
serialized_encrypted_extra,
@@ -110,13 +131,35 @@ class ValidateDatabaseParametersCommand(BaseCommand):
encrypted_extra,
)
if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
+ if identity_changed:
+ raise InvalidParametersError(
+ [
+ SupersetError(
+ message=__(
+ "Testing this connection would change its "
+ "effective destination (engine parameters "
+ "or SSH tunnel endpoint) while reusing the "
+ "stored password. Provide the real "
+ "password to test a connection whose "
+ "destination has changed."
+ ),
+
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ ]
+ )
sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
# Forward the SSH tunnel into the connection test so that
# tunnel-only databases are reached through the tunnel rather
# than directly, mirroring the existing test_connection flow.
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
- if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
+ if (
+ ssh_tunnel_properties
+ and self._model
+ and self._model.ssh_tunnel
+ and not ssh_tunnel_changed
+ ):
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
self._model.ssh_tunnel,
diff --git a/superset/daos/database.py b/superset/daos/database.py
index 6d84a695138..4f0f2bc3cd4 100644
--- a/superset/daos/database.py
+++ b/superset/daos/database.py
@@ -179,11 +179,20 @@ class DatabaseDAO(BaseDAO[Database]):
@staticmethod
def get_database_by_name(database_name: str) -> Database | None:
- return (
- db.session.query(Database)
- .filter(Database.database_name == database_name)
- .one_or_none()
+ """
+ Look up a database by name, scoped to the requesting user's
object-level
+ visibility (the same ``DatabaseFilter`` boundary ``find_by_id``/
+ ``get_connection`` already apply). An unfiltered lookup would let any
+ principal with class-level ``can_write`` reference an arbitrary
+ existing database by name -- including one they have no catalog,
+ schema, datasource, or database access to -- and ride along with
+ whatever secret-rehydration behavior callers apply to the result.
+ """
+ query = db.session.query(Database).filter(
+ Database.database_name == database_name
)
+ query = DatabaseDAO._apply_base_filter(query)
+ return query.one_or_none()
@staticmethod
def build_db_for_connection_test(
diff --git a/tests/unit_tests/commands/databases/test_connection_test.py
b/tests/unit_tests/commands/databases/test_connection_test.py
index 59ca89df276..3f3197d90d8 100644
--- a/tests/unit_tests/commands/databases/test_connection_test.py
+++ b/tests/unit_tests/commands/databases/test_connection_test.py
@@ -19,10 +19,70 @@ import pytest
from pytest_mock import MockerFixture
from superset.commands.database.test_connection import
TestConnectionDatabaseCommand
+from superset.constants import PASSWORD_MASK
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2RedirectError
+def test_ssh_tunnel_unmasked_when_only_engine_params_changed(
+ mocker: MockerFixture,
+) -> None:
+ """
+ An unrelated `extra.engine_params` change must not block reattaching
+ the stored SSH tunnel password when the tunnel's own endpoint is
+ unchanged -- gating the tunnel unmask on the combined identity flag
+ (rather than the tunnel's own endpoint check) would spuriously break a
+ perfectly legitimate connection test.
+ """
+ mocker.patch(
+ "superset.commands.database.test_connection.is_feature_enabled",
+ return_value=True,
+ )
+
+ tunnel = mocker.MagicMock()
+ tunnel.server_address = "ssh.example.com"
+ tunnel.server_port = 22
+ tunnel.password = "real-tunnel-secret" # noqa: S105
+
+ existing = mocker.MagicMock()
+ existing.safe_sqlalchemy_uri.return_value =
"postgresql://u:XXXXXXXXXX@host1:5432/d"
+ existing.extra = "{}"
+ existing.ssh_tunnel = tunnel
+
+ database = mocker.MagicMock()
+ with database.get_sqla_engine() as engine:
+ engine.dialect.do_ping.return_value = True
+
+ DatabaseDAO = mocker.patch( # noqa: N806
+ "superset.commands.database.test_connection.DatabaseDAO"
+ )
+ DatabaseDAO.get_database_by_name.return_value = existing
+ DatabaseDAO.build_db_for_connection_test.return_value = database
+
+ properties = {
+ "database_name": "victim",
+ # a fresh (non-masked) URI password, as if the user supplied the
+ # real one -- this is the legitimate path that gets past the main
+ # URI-identity check
+ "sqlalchemy_uri": "postgresql://u:realpass@host1:5432/d",
+ # unrelated to the tunnel, but still an identity-affecting change
+ "extra": '{"engine_params": {"connect_args": {"connect_timeout":
30}}}',
+ "ssh_tunnel": {
+ "server_address": "ssh.example.com",
+ "server_port": 22,
+ "username": "tunnel_user",
+ "password": PASSWORD_MASK,
+ },
+ }
+ command = TestConnectionDatabaseCommand(properties)
+ command.run()
+
+ forwarded_tunnel =
DatabaseDAO.build_db_for_connection_test.call_args.kwargs[
+ "ssh_tunnel"
+ ]
+ assert forwarded_tunnel["password"] == "real-tunnel-secret" # noqa: S105
+
+
def test_command(mocker: MockerFixture) -> None:
"""
Test the happy path of the command.
diff --git a/tests/unit_tests/commands/databases/update_test.py
b/tests/unit_tests/commands/databases/update_test.py
index 07663df2450..ced1ab2271c 100644
--- a/tests/unit_tests/commands/databases/update_test.py
+++ b/tests/unit_tests/commands/databases/update_test.py
@@ -17,10 +17,13 @@
from unittest.mock import MagicMock
+import pytest
from pytest_mock import MockerFixture
from superset import db
+from superset.commands.database.exceptions import DatabaseInvalidError
from superset.commands.database.update import UpdateDatabaseCommand
+from superset.constants import PASSWORD_MASK
from superset.extensions import security_manager
from superset.utils import json
from tests.conftest import with_config
@@ -667,3 +670,297 @@ def test_update_broken_connection(mocker: MockerFixture)
-> None:
UpdateDatabaseCommand(1, {}).run()
update_catalog_attribute.assert_called_once_with(1, "main")
+
+
+def test_update_host_change_requires_new_credentials(mocker: MockerFixture) ->
None:
+ """
+ An update that repoints an existing database at a different host while
+ the URI's password stays masked must not silently reuse the stored
+ password: the update persists, so every subsequent use of the database
+ (by any user) would send the real credential to the new host.
+ """
+ existing = mocker.MagicMock()
+ existing.sqlalchemy_uri = "postgresql://user:XXXXXXXXXX@host1"
+ existing.extra = "{}"
+ existing.ssh_tunnel = None
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = existing
+
+ with pytest.raises(DatabaseInvalidError):
+ UpdateDatabaseCommand(
+ 1,
+ {
+ "sqlalchemy_uri": (
+
"postgresql://user:[email protected]:5432/prod"
+ )
+ },
+ ).run()
+
+ database_dao.update.assert_not_called()
+
+
+def test_update_engine_params_change_requires_new_credentials(
+ mocker: MockerFixture,
+) -> None:
+ """
+ An update that changes `extra.engine_params` (e.g.
+ `connect_args.host`/`port`, merged into the actual DBAPI connect kwargs
+ ahead of anything in `sqlalchemy_uri`) while the URI's password stays
+ masked must not silently reuse the stored password.
+ """
+ existing = mocker.MagicMock()
+ existing.sqlalchemy_uri = "postgresql://user:XXXXXXXXXX@host1"
+ existing.extra = "{}"
+ existing.ssh_tunnel = None
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = existing
+
+ with pytest.raises(DatabaseInvalidError):
+ UpdateDatabaseCommand(
+ 1,
+ {
+ "extra": json.dumps(
+ {
+ "engine_params": {
+ "connect_args": {
+ "host": "attacker.example.com",
+ "port": 15432,
+ }
+ }
+ }
+ )
+ },
+ ).run()
+
+ database_dao.update.assert_not_called()
+
+
+def test_update_ssh_tunnel_host_change_requires_new_credentials(
+ mocker: MockerFixture,
+) -> None:
+ """
+ An update that repoints an existing database's SSH tunnel at a
+ different server must not silently reuse the stored tunnel password.
+ """
+ existing = mocker.MagicMock()
+ existing.sqlalchemy_uri = "postgresql://user:XXXXXXXXXX@host1"
+ existing.extra = "{}"
+ tunnel = mocker.MagicMock()
+ tunnel.server_address = "10.0.0.1"
+ tunnel.server_port = 22
+ existing.ssh_tunnel = tunnel
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = existing
+
+ with pytest.raises(DatabaseInvalidError):
+ UpdateDatabaseCommand(
+ 1,
+ {
+ "ssh_tunnel": {
+ "server_address": "attacker.example.com",
+ "server_port": 22,
+ "username": "tunnel_user",
+ "password": PASSWORD_MASK,
+ }
+ },
+ ).run()
+
+ database_dao.update.assert_not_called()
+
+
+def test_update_ssh_tunnel_private_key_password_not_carried_over(
+ mocker: MockerFixture,
+) -> None:
+ """
+ An update that repoints the SSH tunnel and supplies a fresh
+ private_key but omits private_key_password must not silently keep the
+ old, real passphrase attached to the new key.
+ """
+ tunnel = mocker.MagicMock()
+ tunnel.server_address = "10.0.0.1"
+ tunnel.server_port = 22
+ tunnel.private_key_password = "original-passphrase" # noqa: S105
+
+ existing = mocker.MagicMock()
+ existing.sqlalchemy_uri = "postgresql://user:XXXXXXXXXX@host1"
+ existing.extra = "{}"
+ existing.ssh_tunnel = tunnel
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = existing
+
+ with pytest.raises(DatabaseInvalidError):
+ UpdateDatabaseCommand(
+ 1,
+ {
+ "ssh_tunnel": {
+ "server_address": "attacker.example.com",
+ "server_port": 22,
+ "username": "tunnel_user",
+ "private_key": "-----BEGIN PRIVATE
KEY-----\nNew\n-----END-----",
+ # private_key_password omitted entirely
+ }
+ },
+ ).run()
+
+ database_dao.update.assert_not_called()
+
+
+def
test_update_encrypted_extra_reused_when_uri_password_fresh_requires_new_credentials(
+ mocker: MockerFixture,
+) -> None:
+ """
+ A fresh URI password alone isn't enough: if `encrypted_extra` carries a
+ real secret and the submission leaves it masked, the destination change
+ must still be refused, or that secret silently rides along to the new
+ destination too.
+ """
+
+ def _unmask(old: str, new: str) -> str:
+ old_config = json.loads(old)
+ new_config = json.loads(new)
+ for key, value in new_config.items():
+ if value == PASSWORD_MASK and key in old_config:
+ new_config[key] = old_config[key]
+ return json.dumps(new_config)
+
+ old_database = mocker.MagicMock()
+ old_database.sqlalchemy_uri = "postgresql://user:XXXXXXXXXX@host1"
+ old_database.password = "oldpass" # noqa: S105
+ old_database.extra = "{}"
+ old_database.encrypted_extra = json.dumps({"client_secret": "real-secret"})
+ old_database.ssh_tunnel = None
+ old_database.db_engine_spec.unmask_encrypted_extra.side_effect = _unmask
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = old_database
+
+ with pytest.raises(DatabaseInvalidError):
+ UpdateDatabaseCommand(
+ 1,
+ {
+ "sqlalchemy_uri": (
+ "postgresql://user:[email protected]:5432/prod"
+ ),
+ "masked_encrypted_extra": json.dumps({"client_secret":
PASSWORD_MASK}),
+ },
+ ).run()
+
+ database_dao.update.assert_not_called()
+
+
+def
test_update_destination_change_with_fresh_encrypted_extra_and_no_uri_password(
+ mocker: MockerFixture,
+) -> None:
+ """
+ Engines that store credentials entirely in `encrypted_extra` and carry
+ no URI password at all (BigQuery, GSheets) must still be able to move
+ destinations when a genuinely fresh credential is supplied -- gating
+ solely on URI-password freshness would block them unconditionally,
+ since they never have one to give.
+ """
+
+ def _unmask(old: str, new: str) -> str:
+ old_config = json.loads(old)
+ new_config = json.loads(new)
+ for key, value in new_config.items():
+ if value == PASSWORD_MASK and key in old_config:
+ new_config[key] = old_config[key]
+ return json.dumps(new_config)
+
+ old_database = mocker.MagicMock(allow_multi_catalog=False)
+ old_database.sqlalchemy_uri = "bigquery://old-project"
+ old_database.password = None
+ old_database.extra = "{}"
+ old_database.encrypted_extra = json.dumps({"credentials_info":
"old-creds"})
+ old_database.ssh_tunnel = None
+ old_database.db_engine_spec.unmask_encrypted_extra.side_effect = _unmask
+ old_database.get_default_catalog.return_value = "old-project"
+ old_database.id = 1
+
+ new_database = mocker.MagicMock(allow_multi_catalog=False)
+ new_database.get_default_catalog.return_value = "new-project"
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = old_database
+ database_dao.update.return_value = new_database
+
+ mocker.patch("superset.commands.database.update.SyncPermissionsCommand")
+ mocker.patch.object(UpdateDatabaseCommand, "_update_catalog_attribute")
+
+ UpdateDatabaseCommand(
+ 1,
+ {
+ "sqlalchemy_uri": "bigquery://old-project",
+ "extra": json.dumps(
+ {"engine_params": {"connect_args": {"host": "new-host"}}}
+ ),
+ "masked_encrypted_extra": json.dumps(
+ {"credentials_info": "brand-new-creds"}
+ ),
+ },
+ ).run()
+
+ database_dao.update.assert_called_once()
+
+
+def test_update_host_change_with_new_credentials(mocker: MockerFixture) ->
None:
+ """
+ A deliberate connection move is still possible when the update supplies
+ a fresh password for the new destination.
+ """
+ old_database = mocker.MagicMock(allow_multi_catalog=False)
+ old_database.sqlalchemy_uri = "postgresql://user:XXXXXXXXXX@host1"
+ old_database.password = "oldpass" # noqa: S105
+ old_database.extra = "{}"
+ old_database.encrypted_extra = "{}"
+ old_database.ssh_tunnel = None
+ old_database.get_default_catalog.return_value = "prod"
+ old_database.id = 1
+
+ new_database = mocker.MagicMock(allow_multi_catalog=False)
+ new_database.get_default_catalog.return_value = "prod"
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = old_database
+ database_dao.update.return_value = new_database
+
+ mocker.patch("superset.commands.database.update.SyncPermissionsCommand")
+ mocker.patch.object(UpdateDatabaseCommand, "_update_catalog_attribute")
+
+ UpdateDatabaseCommand(
+ 1,
+ {"sqlalchemy_uri": "postgresql://user:newpass@host2:5432/prod"},
+ ).run()
+
+ database_dao.update.assert_called_once()
+
+
+def test_update_unrelated_fields_still_work(mocker: MockerFixture) -> None:
+ """
+ An update that doesn't touch `sqlalchemy_uri`, `extra`, or `ssh_tunnel`
+ at all must not be blocked by the new destination-change check.
+ """
+ old_database = mocker.MagicMock(allow_multi_catalog=False)
+ old_database.sqlalchemy_uri = "postgresql://user:XXXXXXXXXX@host1"
+ old_database.extra = "{}"
+ old_database.ssh_tunnel = None
+ old_database.get_default_catalog.return_value = "prod"
+ old_database.id = 1
+
+ new_database = mocker.MagicMock(allow_multi_catalog=False)
+ new_database.get_default_catalog.return_value = "prod"
+
+ database_dao =
mocker.patch("superset.commands.database.update.DatabaseDAO")
+ database_dao.find_by_id.return_value = old_database
+ database_dao.update.return_value = new_database
+
+ mocker.patch("superset.commands.database.update.SyncPermissionsCommand")
+ mocker.patch.object(UpdateDatabaseCommand, "_update_catalog_attribute")
+
+ UpdateDatabaseCommand(1, {"expose_in_sqllab": False}).run()
+
+ database_dao.update.assert_called_once()
diff --git a/tests/unit_tests/commands/databases/validate_test.py
b/tests/unit_tests/commands/databases/validate_test.py
index 1d507bd944a..85d4cf9268f 100644
--- a/tests/unit_tests/commands/databases/validate_test.py
+++ b/tests/unit_tests/commands/databases/validate_test.py
@@ -24,7 +24,9 @@ from superset.commands.database.exceptions import (
InvalidParametersError,
)
from superset.commands.database.validate import
ValidateDatabaseParametersCommand
+from superset.constants import PASSWORD_MASK
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.utils import json
def test_command(mocker: MockerFixture) -> None:
@@ -508,6 +510,88 @@ def test_ssh_tunnel_forwarded_to_connection_test(
)
+def test_ssh_tunnel_unmasked_when_only_engine_params_changed(
+ mocker: MockerFixture,
+) -> None:
+ """
+ An unrelated `extra.engine_params` change must not block reattaching
+ the stored SSH tunnel password when the tunnel's own endpoint is
+ unchanged -- gating the tunnel unmask on the combined identity flag
+ (rather than the tunnel's own endpoint check) would spuriously fail a
+ perfectly legitimate connection test.
+ """
+ mocker.patch(
+ "superset.commands.database.validate.is_feature_enabled",
+ return_value=True,
+ )
+
+ tunnel = mocker.MagicMock()
+ tunnel.server_address = "ssh.example.com"
+ tunnel.server_port = 22
+ tunnel.password = "real-tunnel-secret" # noqa: S105
+
+ existing = mocker.MagicMock()
+ existing.safe_sqlalchemy_uri.return_value =
"postgresql://u:XXXXXXXXXX@host1/d"
+ existing.sqlalchemy_uri_decrypted = "postgresql://u:realpass@host1/d"
+ existing.encrypted_extra = "{}"
+ existing.extra = "{}"
+ existing.ssh_tunnel = tunnel
+
+ database = mocker.MagicMock()
+ with database.get_sqla_engine() as engine:
+ engine.dialect.do_ping.return_value = True
+
+ DatabaseDAO = mocker.patch( # noqa: N806
+ "superset.commands.database.validate.DatabaseDAO"
+ )
+ DatabaseDAO.find_by_id.return_value = existing
+ DatabaseDAO.validate_update_uniqueness.return_value = True
+ DatabaseDAO.build_db_for_connection_test.return_value = database
+
+ mocker.patch(
+ "superset.commands.database.validate.get_engine_spec",
+ return_value=mocker.MagicMock(
+ validate_parameters=mocker.MagicMock(return_value=[]),
+ # a fresh (non-masked) password, as if the user supplied the
+ # real one for the main connection -- this is the legitimate
+ # path that gets past the main URI-identity check below
+ build_sqlalchemy_uri=mocker.MagicMock(
+ return_value="postgresql://u:realpass@host1/d"
+ ),
+ unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
+ ),
+ )
+
+ properties = {
+ "id": 1,
+ "engine": "postgresql",
+ "parameters": {
+ "host": "host1",
+ "port": 5432,
+ "username": "u",
+ "password": "realpass",
+ "database": "d",
+ },
+ # unrelated to the tunnel, but still an identity-affecting change
+ "extra": json.dumps(
+ {"engine_params": {"connect_args": {"connect_timeout": 30}}}
+ ),
+ "ssh_tunnel": {
+ "server_address": "ssh.example.com",
+ "server_port": 22,
+ "username": "tunnel_user",
+ "password": PASSWORD_MASK,
+ },
+ }
+ command = ValidateDatabaseParametersCommand(properties)
+ command.run()
+
+ forwarded_tunnel =
DatabaseDAO.build_db_for_connection_test.call_args.kwargs[
+ "ssh_tunnel"
+ ]
+ assert forwarded_tunnel["password"] == "real-tunnel-secret" # noqa: S105
+
+
def test_get_ssh_tunnel_errors_skipped_when_parameters_ssh_false(
mocker: MockerFixture,
) -> None:
@@ -648,6 +732,127 @@ def
test_validate_ssh_tunnel_feature_disabled_via_parameters_ssh(
)
+def test_validate_engine_params_change_requires_new_credentials(
+ mocker: MockerFixture,
+) -> None:
+ """
+ Validating an existing database's parameters (unchanged host/port/etc,
+ so `sqlalchemy_uri` matches the stored masked URI) must not silently
+ reuse the stored password when `extra.engine_params` would redirect the
+ actual DBAPI connect kwargs elsewhere.
+ """
+ existing = mocker.MagicMock()
+ existing.safe_sqlalchemy_uri.return_value =
"postgresql://u:XXXXXXXXXX@host1/d"
+ existing.sqlalchemy_uri_decrypted = "postgresql://u:realpass@host1/d"
+ existing.encrypted_extra = "{}"
+ existing.extra = "{}"
+ existing.ssh_tunnel = None
+
+ DatabaseDAO = mocker.patch( # noqa: N806
+ "superset.commands.database.validate.DatabaseDAO"
+ )
+ DatabaseDAO.find_by_id.return_value = existing
+ DatabaseDAO.validate_update_uniqueness.return_value = True
+
+ mocker.patch(
+ "superset.commands.database.validate.get_engine_spec",
+ return_value=mocker.MagicMock(
+ validate_parameters=mocker.MagicMock(return_value=[]),
+ build_sqlalchemy_uri=mocker.MagicMock(
+ return_value="postgresql://u:XXXXXXXXXX@host1/d"
+ ),
+ unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
+ ),
+ )
+
+ properties = {
+ "id": 1,
+ "engine": "postgresql",
+ "parameters": {
+ "host": "host1",
+ "port": 5432,
+ "username": "u",
+ "database": "d",
+ },
+ "extra": json.dumps(
+ {
+ "engine_params": {
+ "connect_args": {"host": "attacker.example.com", "port":
15432}
+ }
+ }
+ ),
+ }
+ command = ValidateDatabaseParametersCommand(properties)
+ with pytest.raises(InvalidParametersError):
+ command.run()
+
+ DatabaseDAO.build_db_for_connection_test.assert_not_called()
+
+
+def test_validate_ssh_tunnel_host_change_requires_new_credentials(
+ mocker: MockerFixture,
+) -> None:
+ """
+ Validating with an unchanged host/parameters set must not silently
+ reuse the stored SSH tunnel password when the tunnel endpoint itself
+ is being redirected.
+ """
+ mocker.patch(
+ "superset.commands.database.validate.is_feature_enabled",
+ return_value=True,
+ )
+
+ tunnel = mocker.MagicMock()
+ tunnel.server_address = "10.0.0.1"
+ tunnel.server_port = 22
+
+ existing = mocker.MagicMock()
+ existing.safe_sqlalchemy_uri.return_value =
"postgresql://u:XXXXXXXXXX@host1/d"
+ existing.sqlalchemy_uri_decrypted = "postgresql://u:realpass@host1/d"
+ existing.encrypted_extra = "{}"
+ existing.extra = "{}"
+ existing.ssh_tunnel = tunnel
+
+ DatabaseDAO = mocker.patch( # noqa: N806
+ "superset.commands.database.validate.DatabaseDAO"
+ )
+ DatabaseDAO.find_by_id.return_value = existing
+ DatabaseDAO.validate_update_uniqueness.return_value = True
+
+ mocker.patch(
+ "superset.commands.database.validate.get_engine_spec",
+ return_value=mocker.MagicMock(
+ validate_parameters=mocker.MagicMock(return_value=[]),
+ build_sqlalchemy_uri=mocker.MagicMock(
+ return_value="postgresql://u:XXXXXXXXXX@host1/d"
+ ),
+ unmask_encrypted_extra=mocker.MagicMock(return_value="{}"),
+ ),
+ )
+
+ properties = {
+ "id": 1,
+ "engine": "postgresql",
+ "parameters": {
+ "host": "host1",
+ "port": 5432,
+ "username": "u",
+ "database": "d",
+ },
+ "ssh_tunnel": {
+ "server_address": "attacker.example.com",
+ "server_port": 22,
+ "username": "tunnel_user",
+ "password": PASSWORD_MASK,
+ },
+ }
+ command = ValidateDatabaseParametersCommand(properties)
+ with pytest.raises(InvalidParametersError):
+ command.run()
+
+ DatabaseDAO.build_db_for_connection_test.assert_not_called()
+
+
def test_ssh_tunnel_missing_message_is_interpolated(
mocker: MockerFixture,
) -> None:
diff --git a/tests/unit_tests/databases/commands/importers/v1/import_test.py
b/tests/unit_tests/databases/commands/importers/v1/import_test.py
index 2ccf0cc1fb2..27c2e4c6f69 100644
--- a/tests/unit_tests/databases/commands/importers/v1/import_test.py
+++ b/tests/unit_tests/databases/commands/importers/v1/import_test.py
@@ -593,6 +593,49 @@ def test_import_database_host_change_with_new_credentials(
assert database.password == "newpass" # noqa: S105
+def test_import_database_engine_params_change_requires_new_credentials(
+ mocker: MockerFixture, session: Session
+) -> None:
+ """
+ An overwrite that changes `extra.engine_params` (e.g.
+ `connect_args.host`/`port`, which the DBAPI merges into the actual
+ connection target ahead of anything carried in `sqlalchemy_uri`) must
+ not silently reuse the stored password: the submitted URI can still
+ match the stored host, while the DBAPI actually connects elsewhere.
+ """
+ from superset import security_manager
+ from superset.commands.database.importers.v1.utils import import_database
+ from superset.models.core import Database
+ from tests.integration_tests.fixtures.importexport import database_config
+
+ mocker.patch.object(security_manager, "can_access", return_value=True)
+
mocker.patch("superset.commands.database.importers.v1.utils.add_permissions")
+
+ engine = db.session.get_bind()
+ Database.metadata.create_all(engine) # pylint: disable=no-member
+
+ config = copy.deepcopy(database_config)
+ database = import_database(config)
+ assert database.password == "pass" # noqa: S105
+
+ hostile = copy.deepcopy(database_config)
+ hostile["sqlalchemy_uri"] = f"postgresql://user:{PASSWORD_MASK}@host1"
+ hostile["extra"] = {
+ "engine_params": {
+ "connect_args": {"host": "attacker.example.com", "port": 15432}
+ }
+ }
+ with pytest.raises(ImportFailedError):
+ import_database(hostile, overwrite=True)
+
+ # the same engine_params (a normal re-import of an exported bundle) still
works
+ unchanged = copy.deepcopy(database_config)
+ unchanged["sqlalchemy_uri"] = f"postgresql://user:{PASSWORD_MASK}@host1"
+ unchanged["password"] = "pass" # noqa: S105
+ database = import_database(unchanged, overwrite=True)
+ assert database.password == "pass" # noqa: S105
+
+
def test_import_database_unparseable_uri_treated_as_change(
mocker: MockerFixture, session: Session
) -> None: