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"), {}
+        )