You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/11 16:35:54 UTC
(superset) branch master updated: chore(security): warn when legacy encryption engine is configured (#42937)
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 bc436bacad2 chore(security): warn when legacy encryption engine is configured (#42937)
bc436bacad2 is described below
commit bc436bacad20765cc74183eda8d1dd9e2eb4b91d
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Tue Aug 11 09:35:46 2026 -0700
chore(security): warn when legacy encryption engine is configured (#42937)
Co-authored-by: Amin Ghadersohi <am...@gmail.com>
---
superset/config.py | 3 +
superset/initialization/__init__.py | 59 +++++++++++++++
.../initialization/check_encryption_engine_test.py | 88 ++++++++++++++++++++++
.../unit_tests/utils/test_encrypt_cbc_iv_reuse.py | 80 ++++++++++++++++++++
4 files changed, 230 insertions(+)
diff --git a/superset/config.py b/superset/config.py
index fa87c7d8a42..56ac684c743 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -350,6 +350,9 @@ SQLALCHEMY_ENCRYPTED_FIELD_TYPE_ADAPTER = ( # pylint: disable=invalid-name
# (database passwords, SSH tunnel credentials, OAuth tokens, ...) will make
# those values undecryptable unless they are re-encrypted first. See the
# authenticated-encryption SIP/migration before switching an existing install.
+# Leaving this at "aes" logs a startup warning
+# (SupersetAppInitializer.check_encryption_engine) pointing at the
+# `superset re-encrypt-secrets --engine aes-gcm` migration path.
SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"] = "aes"
# Extends the default SQLGlot dialects with additional dialects
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 09e852c1b91..6fc6713dac5 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -1147,6 +1147,64 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
)
sys.exit(1)
+ def check_encryption_engine(self) -> None:
+ """Warn when app-encrypted fields use the legacy AES-CBC engine.
+
+ ``SQLALCHEMY_ENCRYPTED_FIELD_ENGINE`` defaults to ``"aes"`` for backward
+ compatibility: every secret an existing install has ever written through
+ this mechanism (database passwords, SSH tunnel credentials, OAuth2
+ tokens, and similar) is stored in that engine's ciphertext format, and
+ there is no per-value marker recording which engine produced it — the
+ engine is a single, global setting shared by every encrypted column.
+
+ Unlike ``check_secret_key`` and its siblings, this never refuses to
+ start. ``"aes"`` is a working, still-supported configuration, not a
+ known-bad placeholder value: blocking startup on it would turn an
+ opt-in hardening step into a forced-migration outage for every
+ deployment that has not yet run the engine migration. It only warns,
+ on every boot, so operators have a documented path to the
+ authenticated ``"aes-gcm"`` engine (see ``superset re-encrypt-secrets``
+ and ``docs/sip/authenticated-encryption-at-rest.md``).
+ """
+ # pylint: disable=import-outside-toplevel
+ from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
+
+ from superset.utils.encrypt import (
+ DEFAULT_ENCRYPTION_ENGINE_NAME,
+ resolve_encryption_engine,
+ )
+
+ engine_name = self.config.get(
+ "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE", DEFAULT_ENCRYPTION_ENGINE_NAME
+ )
+ try:
+ engine_cls = resolve_encryption_engine(engine_name)
+ except ValueError:
+ # An unrecognized value already fails closed at field construction
+ # (see ``resolve_encryption_engine``); nothing more to warn about.
+ return
+ if engine_cls is not AesEngine:
+ return
+ self._log_config_warning(
+ "SQLALCHEMY_ENCRYPTED_FIELD_ENGINE is set to the legacy 'aes' "
+ "engine (AES-CBC, unauthenticated). App-encrypted fields — "
+ "database passwords, SSH tunnel credentials, OAuth2 tokens, and "
+ "similar — would benefit from the authenticated 'aes-gcm' engine "
+ "instead.\n"
+ "Switching engines on a populated database requires "
+ "re-encrypting existing values first, since the two ciphertext "
+ "formats are not interchangeable:\n"
+ " 1. Back up the metadata database.\n"
+ " 2. superset re-encrypt-secrets --engine aes-gcm\n"
+ " 3. Set SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = 'aes-gcm' in "
+ "superset_config.py.\n"
+ " 4. Restart Superset, then re-run the command above once more "
+ "to sweep up any values written during the cutover.\n"
+ "See UPDATING.md and "
+ "docs/sip/authenticated-encryption-at-rest.md for the full "
+ "runbook."
+ )
+
def configure_session(self) -> None:
if self.config["SESSION_SERVER_SIDE"]:
Session(self.superset_app)
@@ -1269,6 +1327,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
self.configure_feature_flags()
self.check_guest_token_secret()
self.check_async_query_secret()
+ self.check_encryption_engine()
self.configure_db_encrypt()
self.setup_db()
diff --git a/tests/unit_tests/initialization/check_encryption_engine_test.py b/tests/unit_tests/initialization/check_encryption_engine_test.py
new file mode 100644
index 00000000000..ff3ce93d585
--- /dev/null
+++ b/tests/unit_tests/initialization/check_encryption_engine_test.py
@@ -0,0 +1,88 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for the legacy-encryption-engine startup warning."""
+
+from typing import Any
+from unittest.mock import patch
+
+from superset.initialization import SupersetAppInitializer
+
+
+def _make_initializer(config: dict[str, Any]) -> SupersetAppInitializer:
+ """Build a bare initializer with just the attributes the check needs."""
+ initializer = SupersetAppInitializer.__new__(SupersetAppInitializer)
+ initializer.config = config
+ return initializer
+
+
+def test_warns_when_engine_unset_defaults_to_legacy_aes() -> None:
+ """An absent config value resolves to the legacy engine and warns."""
+ initializer = _make_initializer({})
+
+ with patch.object(initializer, "_log_config_warning") as log_warning:
+ initializer.check_encryption_engine()
+
+ log_warning.assert_called_once()
+ message = log_warning.call_args.args[0]
+ assert "aes-gcm" in message
+ assert "re-encrypt-secrets" in message
+
+
+def test_warns_when_engine_explicitly_set_to_aes() -> None:
+ """An explicit 'aes' value warns the same as the implicit default."""
+ initializer = _make_initializer({"SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "aes"})
+
+ with patch.object(initializer, "_log_config_warning") as log_warning:
+ initializer.check_encryption_engine()
+
+ log_warning.assert_called_once()
+
+
+def test_silent_when_engine_is_gcm() -> None:
+ """An operator who has already opted into 'aes-gcm' gets no warning."""
+ initializer = _make_initializer({"SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "aes-gcm"})
+
+ with patch.object(initializer, "_log_config_warning") as log_warning:
+ initializer.check_encryption_engine()
+
+ log_warning.assert_not_called()
+
+
+def test_silent_when_engine_value_is_unrecognized() -> None:
+ """An unrecognized value already fails closed at field construction
+ (``resolve_encryption_engine``); this check does not pile on a second,
+ redundant warning for the same misconfiguration.
+ """
+ initializer = _make_initializer({"SQLALCHEMY_ENCRYPTED_FIELD_ENGINE": "bogus"})
+
+ with patch.object(initializer, "_log_config_warning") as log_warning:
+ initializer.check_encryption_engine()
+
+ log_warning.assert_not_called()
+
+
+def test_never_raises_system_exit() -> None:
+ """Unlike check_secret_key/check_guest_token_secret/check_async_query_secret,
+ this check must never refuse to start: the legacy engine is a supported
+ configuration, not a known-bad placeholder, so blocking startup on it
+ would turn an opt-in hardening step into a forced-migration outage.
+ """
+ initializer = _make_initializer({})
+
+ with patch.object(initializer, "_log_config_warning"):
+ # Should not raise SystemExit.
+ initializer.check_encryption_engine()
diff --git a/tests/unit_tests/utils/test_encrypt_cbc_iv_reuse.py b/tests/unit_tests/utils/test_encrypt_cbc_iv_reuse.py
new file mode 100644
index 00000000000..ad208813755
--- /dev/null
+++ b/tests/unit_tests/utils/test_encrypt_cbc_iv_reuse.py
@@ -0,0 +1,80 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Characterization tests for the IV behavior of Superset's default
+app-encryption engine (``sqlalchemy_utils`` ``AesEngine``, AES-CBC).
+
+``AesEngine._initialize_engine`` (see
+``sqlalchemy_utils.types.encrypted.encrypted_type``) derives its IV as the
+first 16 bytes of the SHA-256 digest of the configured key: ``self.iv =
+self.secret_key[:16]``. That derivation is a pure function of the key alone,
+so every encryption performed under the same key reuses the same IV, unlike
+``AesGcmEngine``, which samples a fresh random IV (``os.urandom``) on every
+call and embeds it in the output. These tests pin that observable difference:
+same-plaintext encryptions are identical under the default engine and distinct
+under the GCM engine.
+"""
+
+from sqlalchemy import String
+from sqlalchemy.engine import make_url
+
+from superset.utils.encrypt import (
+ DEFAULT_ENCRYPTION_ENGINE_NAME,
+ EncryptedType,
+ resolve_encryption_engine,
+)
+
+DIALECT = make_url("sqlite://").get_dialect()
+SECRET_KEY = "k" * 32
+
+
+def _field(engine: type) -> EncryptedType:
+ return EncryptedType(String(1024), key=lambda: SECRET_KEY, engine=engine)
+
+
+def test_default_encryption_engine_name_resolves_to_cbc() -> None:
+ """The engine name the codebase falls back to when config is unset resolves
+ to the unauthenticated AES-CBC engine, not the authenticated AES-GCM one.
+ """
+ from sqlalchemy_utils.types.encrypted.encrypted_type import AesEngine
+
+ assert resolve_encryption_engine(DEFAULT_ENCRYPTION_ENGINE_NAME) is AesEngine
+
+
+def test_default_engine_repeats_ciphertext_for_repeated_plaintext() -> None:
+ """Encrypting the same plaintext twice under one key produces identical
+ ciphertext with the default engine, because its IV is a deterministic
+ function of the key rather than freshly sampled per call.
+ """
+ field = _field(resolve_encryption_engine(DEFAULT_ENCRYPTION_ENGINE_NAME))
+
+ first = field.process_bind_param("hunter2", DIALECT)
+ second = field.process_bind_param("hunter2", DIALECT)
+
+ assert first == second
+
+
+def test_gcm_engine_varies_ciphertext_for_repeated_plaintext() -> None:
+ """Contrast case: the authenticated engine samples a fresh random IV each
+ call, so encrypting the same plaintext twice under one key produces
+ different ciphertext.
+ """
+ field = _field(resolve_encryption_engine("aes-gcm"))
+
+ first = field.process_bind_param("hunter2", DIALECT)
+ second = field.process_bind_param("hunter2", DIALECT)
+
+ assert first != second