You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/09/30 05:16:43 UTC
(superset) branch master updated: fix(mysql): require TLS when SSL is requested (#44723)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi 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 48a3b02d129 fix(mysql): require TLS when SSL is requested (#44723)
48a3b02d129 is described below
commit 48a3b02d129acf51b0c1928f07f00f9813d0c0b3
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Sep 30 15:16:35 2026 +1000
fix(mysql): require TLS when SSL is requested (#44723)
---
UPDATING.md | 42 ++++
superset/db_engine_specs/mysql.py | 150 ++++++++++++
tests/unit_tests/db_engine_specs/test_mysql.py | 307 +++++++++++++++++++++++++
3 files changed, 499 insertions(+)
diff --git a/UPDATING.md b/UPDATING.md
index 2550e68eba9..9331ccc0784 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -36,6 +36,48 @@ assists people when migrating to a new version.
able to provision a user regardless of `AUTH_USER_REGISTRATION`; it now
requires the same gate as `/register/`.
+### MySQL SSL requests require TLS
+
+The SSL toggle (or ssl=1 in the URI) requires TLS. With mysqlclient, Oracle
+libmysqlclient 5.7/8.x/9.x uses ssl_mode=REQUIRED; MariaDB Connector/C and
+unrecognized client versions use VERIFY_CA to prevent cleartext fallback.
+Explicit VERIFY_CA and VERIFY_IDENTITY are retained.
+
+Existing saved connections with the toggle on are affected at upgrade, without a
+feature flag. Verification can fail for self-signed/default server certificates
+or missing trust roots. MySQL does not use the connection form's Root
+certificate field (server_cert). Set ssl_ca to a trusted CA file path available
+on every web and worker node, for example in the URI
+(?ssl=1&ssl_ca=/path/to/ca.pem).
+
+Connector/Python and PyMySQL enable ssl_verify_cert=True. PyMySQL requires
+version 1.2 or newer; use individual ssl_ca, ssl_cert and ssl_key options
+instead of a nested ssl dictionary with the toggle. Options that disable TLS or
+required verification are rejected.
+
+Standard Aurora MySQL connections intentionally follow the same rules, including
+IAM connections. For certificate verification, install the Amazon RDS CA bundle
+on every web and worker node and set ssl_ca to that file. IAM authentication
+does not supply a CA. The Aurora Data API uses HTTPS and needs no MySQL TLS
+arguments.
+
+SSH tunnels rewrite the connection host to the local bind address (typically
+127.0.0.1). MariaDB Connector/C also checks hostname identity with VERIFY_CA, so
+a certificate for the remote database hostname will fail. For SSH-only
+transport, turn off the SSL toggle and remove ssl=1; this removes the TLS
+guarantee on the SSH endpoint-to-database leg. If end-to-end TLS is required,
+use a driver/native TLS configuration compatible with the tunnel and validate it
+separately.
+
+Operators using native TLS settings can turn off the toggle, remove ssl=1 and
+configure extra.engine_params.connect_args (for example a driver-supported
+native ssl dictionary). Superset passes those settings through without enforcing
+TLS; ensure the chosen driver configuration does not silently fall back to
+cleartext.
+
+Connections using the separate MariaDB engine (mariadb:// URIs) and other
+MySQL-compatible engines keep their existing SSL handling.
+
### Version history retention setting
Use `VERSION_HISTORY_RETENTION_DAYS` for both the application setting and
diff --git a/superset/db_engine_specs/mysql.py b/superset/db_engine_specs/mysql.py
index fca8f7d03d6..0b1eb32c329 100644
--- a/superset/db_engine_specs/mysql.py
+++ b/superset/db_engine_specs/mysql.py
@@ -21,6 +21,7 @@ import logging
import re
from datetime import datetime
from decimal import Decimal
+from importlib import import_module
from re import Pattern
from typing import Any, Callable, Optional, TYPE_CHECKING
from urllib import parse
@@ -42,6 +43,7 @@ from sqlalchemy.dialects.mysql import (
)
from sqlalchemy.engine.url import URL
from sqlalchemy.sql.elements import ColumnElement
+from sqlalchemy.util import asbool
from superset.constants import TimeGrain
from superset.db_engine_specs.base import (
@@ -78,6 +80,117 @@ SYNTAX_ERROR_REGEX = re.compile(
)
+def _mysql_bool_option(options: dict[str, Any], key: str) -> Optional[bool]:
+ """Parse a boolean driver option, treating a blank value as unset."""
+ value = options.get(key)
+ if value is None or value == "":
+ return None
+ return asbool(value) if isinstance(value, str) else bool(value)
+
+
+def _require_pymysql_tls(query: dict[str, Any], args: dict[str, Any]) -> None:
+ """Keep PyMySQL TLS options native so verification applies to them."""
+ pymysql = import_module("pymysql")
+
+ # Older releases silently fall back even with explicit SSL options.
+ if pymysql.VERSION[:2] < (1, 2):
+ raise ValueError("The MySQL SSL toggle requires PyMySQL >= 1.2")
+ # SQLAlchemy folds URL ssl_ca/cert/key into an ssl dictionary,
+ # but PyMySQL ignores that dictionary when ssl_verify_cert is set.
+ # Keep these as native connect_args so the CA is not discarded.
+ for key in ("ssl_ca", "ssl_cert", "ssl_key"):
+ if key in query:
+ args.setdefault(key, query.pop(key))
+ check_hostname = _mysql_bool_option(query, "ssl_check_hostname")
+ query.pop("ssl_check_hostname", None)
+ if check_hostname is not None:
+ verify_identity = _mysql_bool_option(args, "ssl_verify_identity")
+ if verify_identity not in (None, check_hostname):
+ raise ValueError("MySQL SSL request conflicts with ssl_verify_identity")
+ args["ssl_verify_identity"] = check_hostname
+ if query.keys() & {"ssl_capath", "ssl_cipher"}:
+ raise ValueError("Unsupported PyMySQL SSL option with the SSL toggle")
+ if "ssl" in args:
+ raise ValueError(
+ "Use individual ssl_ca/ssl_cert/ssl_key options with the SSL toggle"
+ )
+
+
+def _require_mysql_verified_tls(
+ driver: str, query: dict[str, Any], args: dict[str, Any]
+) -> None:
+ """Use required verification on drivers without an encryption-only mode."""
+ # The drivers test ssl_disabled for truthiness, so a URL string such as
+ # "false" would disable TLS. Parse it here and drop non-disabling values.
+ for source in (query, args):
+ if _mysql_bool_option(source, "ssl_disabled"):
+ raise ValueError("MySQL SSL request conflicts with ssl_disabled")
+ source.pop("ssl_disabled", None)
+ # Connector/Python has no REQUIRED mode: certificate verification is
+ # necessary to prevent its opportunistic fallback to cleartext.
+ if _mysql_bool_option({**query, **args}, "ssl_verify_cert") is False:
+ raise ValueError("MySQL SSL request requires ssl_verify_cert")
+ if driver == "pymysql":
+ _require_pymysql_tls(query, args)
+ args["ssl_verify_cert"] = True
+
+
+def _mysql_ssl_requested(value: Any) -> bool:
+ """Parse scalar requests; leave native SSL dictionaries to the driver."""
+ if isinstance(value, (str, bool, int)):
+ return asbool(value)
+ if value is not None and not isinstance(value, dict):
+ raise ValueError("Invalid MySQL ssl option")
+ return False
+
+
+def _mysqlclient_ssl_mode(options: dict[str, Any]) -> str:
+ """Select fail-closed TLS semantics for the linked client library."""
+ # mysqlclient maps REQUIRED to opportunistic TLS with MariaDB
+ # Connector/C. Verification modes fail closed on both client libraries.
+ mode = options.get("ssl_mode", "REQUIRED")
+ if mode not in ("REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"):
+ raise ValueError("MySQL SSL request conflicts with ssl_mode")
+ if mode == "REQUIRED":
+ client_info = import_module("MySQLdb").get_client_info()
+ # Only recognized Oracle clients have fail-closed REQUIRED semantics.
+ # MariaDB Connector/C (3.x) and unknown clients require verification.
+ if not re.match(r"^(?:5\.7|8\.\d+|9\.\d+)\.", client_info):
+ mode = "VERIFY_CA"
+ return mode
+
+
+def require_mysql_tls(
+ uri: URL, connect_args: dict[str, Any]
+) -> tuple[URL, dict[str, Any]]:
+ """Consume scalar ``ssl`` requests without weakening native TLS settings."""
+ if uri.get_backend_name() != "mysql":
+ return uri, connect_args
+ query = dict(uri.query)
+ args = dict(connect_args)
+ # A true URL request cannot be cancelled by an advanced connect argument.
+ requested = _mysql_ssl_requested(query.get("ssl"))
+ requested = _mysql_ssl_requested(args.get("ssl")) or requested
+ if not requested:
+ return uri, connect_args
+
+ for options in (query, args):
+ if isinstance(options.get("ssl"), (str, bool, int)):
+ options.pop("ssl")
+ driver = uri.get_driver_name()
+ options = {**query, **args}
+ if driver == "mysqldb":
+ args["ssl_mode"] = _mysqlclient_ssl_mode(options)
+ elif driver in ("mysqlconnector", "pymysql"):
+ _require_mysql_verified_tls(driver, query, args)
+ elif driver == "auroradataapi":
+ # The Data API is only reachable over HTTPS and takes no ssl argument.
+ pass
+ else:
+ raise ValueError("Unsupported driver for the MySQL SSL toggle")
+ return uri.set(query=query), args
+
+
class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
engine = "mysql"
engine_name = "MySQL"
@@ -123,6 +236,41 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
"pypi_packages": ["mysqlclient"],
"connection_string": "mysql://{username}:{password}@{host}/{database}",
"default_port": 3306,
+ "notes": (
+ "The SSL toggle (or ssl=1 in the URI) requires TLS. With mysqlclient, "
+ "Oracle libmysqlclient 5.7/8.x/9.x uses ssl_mode=REQUIRED; MariaDB "
+ "Connector/C and unrecognized client versions use VERIFY_CA to prevent "
+ "cleartext fallback. Explicit VERIFY_CA and VERIFY_IDENTITY are retained. "
+ "Existing saved connections with the toggle on are affected at upgrade, "
+ "without a feature flag. Verification can fail for self-signed/default "
+ "server certificates or missing trust roots. MySQL does not use the "
+ "connection form's Root certificate field (server_cert). Set ssl_ca to a "
+ "trusted CA file path available on every web and worker node, for example "
+ "in the URI (?ssl=1&ssl_ca=/path/to/ca.pem). "
+ "Connector/Python and PyMySQL enable ssl_verify_cert=True. PyMySQL "
+ "requires version 1.2 or newer; use individual ssl_ca, ssl_cert and "
+ "ssl_key options instead of a nested ssl dictionary with the toggle. "
+ "Options that disable TLS or required verification are rejected. "
+ "Standard Aurora MySQL connections intentionally follow the same rules, "
+ "including IAM connections. For certificate verification, install the "
+ "Amazon RDS CA bundle on every web and worker node and set ssl_ca to that "
+ "file. IAM authentication does not supply a CA. The Aurora Data API uses "
+ "HTTPS and needs no MySQL TLS arguments. "
+ "SSH tunnels rewrite the connection host to the local bind address "
+ "(typically 127.0.0.1). MariaDB Connector/C also checks hostname identity "
+ "with VERIFY_CA, so a certificate for the remote database hostname will "
+ "fail. For SSH-only transport, turn off the SSL toggle and remove ssl=1; "
+ "this removes the TLS guarantee on the SSH endpoint-to-database leg. If "
+ "end-to-end TLS is required, use a driver/native TLS configuration "
+ "compatible with the tunnel and validate it separately. "
+ "Operators using native TLS settings can turn off the toggle, remove "
+ "ssl=1 and configure extra.engine_params.connect_args (for example a "
+ "driver-supported native ssl dictionary). Superset passes those settings "
+ "through without enforcing TLS; ensure the chosen driver configuration "
+ "does not silently fall back to cleartext. "
+ "Connections using the separate MariaDB engine (mariadb:// URIs) and "
+ "other MySQL-compatible engines keep their existing SSL handling."
+ ),
"parameters": {
"username": "Database username",
"password": "Database password",
@@ -445,6 +593,8 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
if schema:
uri = uri.set(database=parse.quote(schema, safe=""))
+ if cls.engine == "mysql":
+ return require_mysql_tls(uri, new_connect_args)
return uri, new_connect_args
@classmethod
diff --git a/tests/unit_tests/db_engine_specs/test_mysql.py b/tests/unit_tests/db_engine_specs/test_mysql.py
index e5a1befb85e..d6dc20722b8 100644
--- a/tests/unit_tests/db_engine_specs/test_mysql.py
+++ b/tests/unit_tests/db_engine_specs/test_mysql.py
@@ -510,3 +510,310 @@ def test_extended_aggregation_func_median_unsupported() -> None:
from superset.db_engine_specs.mysql import MySQLEngineSpec as spec # noqa: N813
assert spec.get_extended_aggregation_func("MEDIAN") is None
+
+
+@pytest.mark.parametrize(
+ "client_info,expected_mode",
+ [
+ ("3.3.17", "VERIFY_CA"),
+ ("5.7.44", "REQUIRED"),
+ ("8.4.6", "REQUIRED"),
+ ("9.4.0", "REQUIRED"),
+ ("unknown", "VERIFY_CA"),
+ ],
+)
+@pytest.mark.parametrize("driver", ["mysql", "mysql+mysqldb"])
+@pytest.mark.parametrize("source", ["uri", "toggle", "connect_args"])
+def test_ssl_request_requires_tls(
+ driver: str, source: str, client_info: str, expected_mode: str
+) -> None:
+ """The SSL toggle and legacy URI flag must require, not prefer, TLS."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ uri = make_url(f"{driver}://user:pass@localhost/db")
+ args: dict[str, Any] = {}
+ if source == "toggle":
+ uri = make_url(
+ MySQLEngineSpec.build_sqlalchemy_uri(
+ {
+ "username": "user",
+ "password": "pass",
+ "host": "localhost",
+ "port": 3306,
+ "database": "db",
+ "encryption": True,
+ },
+ {},
+ )
+ ).set(drivername=driver)
+ elif source == "uri":
+ uri = uri.update_query_dict({"ssl": "1"})
+ else:
+ args = {"ssl": True}
+ with patch("superset.db_engine_specs.mysql.import_module") as module:
+ module.return_value.get_client_info.return_value = client_info
+ url, connect_args = MySQLEngineSpec.adjust_engine_params(uri, args)
+ options = dict(url.query, **connect_args)
+ assert options["ssl_mode"] == expected_mode
+ assert "ssl" not in options
+
+
+@pytest.mark.parametrize("driver", ["mysql+mysqlconnector", "mysql+pymysql"])
+def test_ssl_request_requires_verification(driver: str) -> None:
+ """The alternate drivers must not use opportunistic TLS."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ uri = make_url(f"{driver}://localhost/db?ssl=1&ssl_ca=/ca.pem")
+ url, args = MySQLEngineSpec.adjust_engine_params(uri, {})
+ assert args["ssl_verify_cert"] is True
+ assert "ssl" not in url.query
+ _, options = url.get_dialect()().create_connect_args(url)
+ options.update(args)
+ assert options["ssl_ca"] == "/ca.pem"
+
+
+@pytest.mark.parametrize("mode", ["VERIFY_CA", "VERIFY_IDENTITY"])
+def test_ssl_request_preserves_stronger_mode(mode: str) -> None:
+ """Do not weaken an explicit verification mode or lose the selected schema."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ uri = make_url(f"mysql://localhost/db?ssl=1&ssl_mode={mode}")
+ url, args = MySQLEngineSpec.adjust_engine_params(uri, {}, schema="other")
+ assert args["ssl_mode"] == mode
+ assert url.database == "other"
+
+
+@pytest.mark.parametrize(
+ "driver, options",
+ [
+ ("mysql", {"ssl_mode": "DISABLED"}),
+ ("mysql+mysqldb", {"ssl_mode": "PREFERRED"}),
+ ("mysql+mysqlconnector", {"ssl_disabled": True}),
+ ("mysql+pymysql", {"ssl_verify_cert": False}),
+ ],
+)
+def test_ssl_request_rejects_conflicting_options(
+ driver: str, options: dict[str, Any]
+) -> None:
+ """Advanced options must not silently cancel the SSL request."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ with pytest.raises(ValueError, match="MySQL SSL request"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url(f"{driver}://localhost/db?ssl=1"), options
+ )
+
+
+def test_ssl_request_does_not_change_other_engines() -> None:
+ """MySQL-compatible engines keep their own transport handling."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ class OtherEngineSpec(MySQLEngineSpec):
+ engine = "other"
+
+ uri = make_url("mysql://localhost/db?ssl=1")
+ url, args = OtherEngineSpec.adjust_engine_params(uri, {})
+ assert url == uri
+ assert "ssl_mode" not in args
+
+
+@pytest.mark.parametrize(
+ "client_info,expected_mode",
+ [("3.3.17", "VERIFY_CA"), ("8.4.6", "REQUIRED")],
+)
+@pytest.mark.parametrize("source", ["uri", "connect_args"])
+def test_ssl_request_upgrades_required_mode(
+ client_info: str, expected_mode: str, source: str
+) -> None:
+ """MariaDB Connector/C maps mysqlclient REQUIRED to opportunistic TLS."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ uri = make_url("mysql://localhost/db?ssl=1")
+ options = {}
+ if source == "uri":
+ uri = uri.update_query_dict({"ssl_mode": "REQUIRED"})
+ else:
+ options["ssl_mode"] = "REQUIRED"
+ with patch("superset.db_engine_specs.mysql.import_module") as module:
+ module.return_value.get_client_info.return_value = client_info
+ _, args = MySQLEngineSpec.adjust_engine_params(uri, options)
+ assert args["ssl_mode"] == expected_mode
+
+
+def test_pymysql_hostname_verification_survives() -> None:
+ """A URL ssl_check_hostname becomes PyMySQL's ssl_verify_identity."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ url, args = MySQLEngineSpec.adjust_engine_params(
+ make_url("mysql+pymysql://localhost/db?ssl=1&ssl_check_hostname=true"), {}
+ )
+ assert args["ssl_verify_identity"] is True
+ assert "ssl_check_hostname" not in url.query
+
+
+@pytest.mark.parametrize("option", ["ssl_capath", "ssl_cipher"])
+def test_pymysql_unsupported_ssl_options_fail_closed(option: str) -> None:
+ """SSL options PyMySQL cannot honor are rejected instead of ignored."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ with pytest.raises(ValueError, match="Unsupported PyMySQL SSL option"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url(f"mysql+pymysql://localhost/db?ssl=1&{option}=test"), {}
+ )
+
+
+@pytest.mark.parametrize("driver", ["mysql+mysqlconnector", "mysql+pymysql"])
+@pytest.mark.parametrize("value", ["false", "0", False])
+def test_ssl_request_drops_non_disabling_ssl_disabled(
+ driver: str, value: str | bool
+) -> None:
+ """A false ssl_disabled must not reach drivers that test it for truthiness."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ uri = make_url(f"{driver}://localhost/db?ssl=1")
+ if isinstance(value, str):
+ uri = uri.update_query_dict({"ssl_disabled": value})
+ args: dict[str, Any] = {}
+ else:
+ args = {"ssl_disabled": value}
+ url, connect_args = MySQLEngineSpec.adjust_engine_params(uri, args)
+ assert "ssl_disabled" not in url.query
+ assert "ssl_disabled" not in connect_args
+ assert connect_args["ssl_verify_cert"] is True
+
+
+@pytest.mark.parametrize("value", ["true", "1"])
+def test_ssl_request_rejects_url_ssl_disabled(value: str) -> None:
+ """A truthy URL ssl_disabled conflicts with the SSL request."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ with pytest.raises(ValueError, match="conflicts with ssl_disabled"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url(f"mysql+mysqlconnector://localhost/db?ssl=1&ssl_disabled={value}"),
+ {},
+ )
+
+
+def test_ssl_request_aurora_data_api_uses_https() -> None:
+ """The HTTPS-only Data API driver must not receive an ssl argument."""
+ from superset.db_engine_specs.aurora import AuroraMySQLDataAPI
+
+ uri = make_url(
+ AuroraMySQLDataAPI.build_sqlalchemy_uri(
+ {
+ "username": "user",
+ "password": "pass",
+ "host": "",
+ "port": 3306,
+ "database": "db",
+ "encryption": True,
+ },
+ {},
+ )
+ ).update_query_dict({"aurora_cluster_arn": "arn"})
+ assert uri.get_driver_name() == "auroradataapi"
+ url, args = AuroraMySQLDataAPI.adjust_engine_params(uri, {})
+ assert "ssl" not in url.query
+ assert url.query["aurora_cluster_arn"] == "arn"
+ assert args == {}
+
+
+def test_pymysql_blank_hostname_check_is_unset() -> None:
+ """A blank ssl_check_hostname must not crash boolean parsing."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ uri = make_url("mysql+pymysql://localhost/db?ssl=1").update_query_dict(
+ {"ssl_check_hostname": ""}
+ )
+ url, args = MySQLEngineSpec.adjust_engine_params(uri, {})
+ assert "ssl_check_hostname" not in url.query
+ assert "ssl_verify_identity" not in args
+ assert args["ssl_verify_cert"] is True
+
+
+def test_pymysql_hostname_check_conflict_fails_closed() -> None:
+ """A connect_arg must not silently cancel URL hostname verification."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ with pytest.raises(ValueError, match="conflicts with ssl_verify_identity"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url("mysql+pymysql://localhost/db?ssl=1&ssl_check_hostname=true"),
+ {"ssl_verify_identity": False},
+ )
+
+
+def test_pymysql_old_version_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
+ """PyMySQL before 1.2 must not reach its silent cleartext fallback."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ monkeypatch.setattr("pymysql.VERSION", (1, 1, 1))
+ with pytest.raises(ValueError, match="requires PyMySQL >= 1.2"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url("mysql+pymysql://localhost/db?ssl=1"), {}
+ )
+
+
+@pytest.mark.parametrize("host", ["cluster.rds.amazonaws.com", "127.0.0.1"])
+def test_aurora_mysql_tls_preserves_ca(host: str) -> None:
+ """Standard Aurora and tunneled hosts keep verification and the RDS CA."""
+ from superset.db_engine_specs.aurora import AuroraMySQLEngineSpec
+
+ uri = make_url(f"mysql://{host}/db?ssl=1&ssl_ca=/certs/rds-ca.pem")
+ with patch("superset.db_engine_specs.mysql.import_module") as module:
+ module.return_value.get_client_info.return_value = "3.3.17"
+ url, args = AuroraMySQLEngineSpec.adjust_engine_params(uri, {})
+ assert url.host == host
+ assert url.query["ssl_ca"] == "/certs/rds-ca.pem"
+ assert args["ssl_mode"] == "VERIFY_CA"
+
+
+@pytest.mark.parametrize("driver", ["mysql", "mysql+pymysql"])
+@pytest.mark.parametrize("host", ["localhost", "127.0.0.1"])
+def test_native_tls_without_toggle_is_unchanged(driver: str, host: str) -> None:
+ """The documented native TLS recovery path bypasses toggle enforcement."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ uri = make_url(f"{driver}://{host}/db")
+ options = {"ssl": {"ca": "/certs/ca.pem"}}
+ with patch("superset.db_engine_specs.mysql.import_module") as module:
+ url, args = MySQLEngineSpec.adjust_engine_params(uri, options)
+ module.assert_not_called()
+ assert url == uri
+ assert args["ssl"] == options["ssl"]
+ assert "ssl_mode" not in args
+ assert "ssl_verify_cert" not in args
+
+
+def test_pymysql_nested_ssl_dict_fails_closed() -> None:
+ """PyMySQL discards a nested ssl dictionary once ssl_verify_cert is set."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ with pytest.raises(ValueError, match="individual ssl_ca/ssl_cert/ssl_key"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url("mysql+pymysql://localhost/db?ssl=1"),
+ {"ssl": {"ca": "/certs/ca.pem"}},
+ )
+
+
+@pytest.mark.parametrize("value", [["1"], 1.5])
+def test_ssl_request_rejects_invalid_ssl_value(value: Any) -> None:
+ """An ssl value that is neither a flag nor a native dictionary fails closed."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ with pytest.raises(ValueError, match="Invalid MySQL ssl option"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url("mysql://localhost/db"), {"ssl": value}
+ )
+
+
+@pytest.mark.parametrize(
+ "driver", ["mysql+cymysql", "mysql+mariadbconnector", "mysql+aiomysql"]
+)
+def test_ssl_request_unsupported_driver_fails_closed(driver: str) -> None:
+ """Drivers without known fail-closed TLS options must not receive ssl=1."""
+ from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+ with pytest.raises(ValueError, match="Unsupported driver"):
+ MySQLEngineSpec.adjust_engine_params(
+ make_url(f"{driver}://localhost/db?ssl=1"), {}
+ )