Compare commits

...

15 Commits

Author SHA1 Message Date
Evan
6bc90dc8b9 test(email): add return type annotations to setUp/tearDown
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:22:39 -07:00
Evan
0b40d8d438 test(email): restore mutated SMTP config keys in TestEmailSmtp teardown
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:06:16 -07:00
Evan
c76606f48a docs(tests): add docstring to _smtp_config test helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:06:16 -07:00
Evan
5288083a79 chore(config): correct SMTP_SSL_SERVER_AUTH comment to SERVER_AUTH
ssl.create_default_context() defaults to ssl.Purpose.SERVER_AUTH, which is
correct for Superset acting as an SMTP client verifying the mail server's
certificate. The comment incorrectly referenced CLIENT_AUTH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:06:16 -07:00
Evan
7fc50614e8 test(config): cover SMTP_SSL_SERVER_AUTH enabled behavior
Add unit tests in config_test.py that exercise send_mime_email and assert
ssl.create_default_context() is called and its context is threaded through
to SMTP_SSL and starttls when SMTP_SSL_SERVER_AUTH=True, plus the opt-out
path passing context=None. Complements the existing module-level default
assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:06:16 -07:00
Evan
1c994194e3 test(email): explicitly opt out of SMTP_SSL_SERVER_AUTH in test_send_mime_ssl
The new default for SMTP_SSL_SERVER_AUTH is True. test_send_mime_ssl
tests the no-server-auth code path and must explicitly set the flag to
False to avoid asserting context=None when the default now produces an
SSL context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:06:16 -07:00
Claude Code
6b466225fb chore(config): default SMTP_SSL_SERVER_AUTH to True
Change the shipped default for SMTP_SSL_SERVER_AUTH from False to True so
STARTTLS/SSL connections to the SMTP server validate the server's TLS
certificate against the system CA store out of the box.

The setting remains overridable: operators using a self-signed or otherwise
untrusted certificate can restore the previous behavior by setting
SMTP_SSL_SERVER_AUTH = False in superset_config.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:06:12 -07:00
Evan Rusackas
379435b7eb feat(ssh_tunnel): add opt-in server host key verification (#40673)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-15 12:17:14 -07:00
Amin Ghadersohi
4b96b91b53 feat(mcp): add aggregation field to BigNumberChartConfig for Big Number with Trendline (#40775)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 14:40:37 -04:00
dependabot[bot]
073599bd0c chore(deps-dev): bump @babel/core from 7.25.2 to 7.29.6 in /superset-embedded-sdk (#41057)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:20:01 -07:00
dependabot[bot]
9e2c4533c8 chore(deps): bump @babel/core from 7.28.6 to 7.29.7 in /docs (#41058)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:19:43 -07:00
dependabot[bot]
1ae115981f chore(deps): bump form-data from 4.0.5 to 4.0.6 in /superset-frontend/cypress-base (#41061)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:19:23 -07:00
dependabot[bot]
b078ae4b51 chore(deps): bump launch-editor from 2.11.1 to 2.14.1 in /docs (#41062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:19:05 -07:00
dependabot[bot]
48b755470f chore(deps): bump form-data from 4.0.5 to 4.0.6 in /superset-frontend (#41063)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:18:48 -07:00
dependabot[bot]
dad7dae4f6 chore(deps): bump launch-editor from 2.9.1 to 2.14.1 in /superset-frontend (#41065)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 11:18:20 -07:00
24 changed files with 1120 additions and 233 deletions

View File

@@ -121,6 +121,32 @@ This change is backward compatible. The feature is off by default, and even when
Disabling a user account (setting `active` to `False`, via the admin UI, REST API, or CLI) now terminates that user's outstanding sessions on their next request, instead of relying on a passive check. This works for both client-side cookie sessions and server-side session stores via a per-user invalidation epoch (`user_attribute.sessions_invalidated_at`, added by a migration). The mechanism is inert for users that were never disabled (NULL epoch), so there is no behavior change for active users. Re-enabling an account and logging in again starts a fresh, valid session. The migration backfills the epoch for accounts that are already disabled at upgrade time, so re-enabling such an account does not revive a session that predates this feature.
### Opt-in SSH tunnel server host key verification
SSH tunnels can now optionally pin the expected SSH server host key as a defense-in-depth measure against man-in-the-middle attacks. paramiko's transport performs no known-hosts checking by default, so previously the SSH server's identity was not verified. This feature is opt-in and off by default; existing tunnels are unaffected.
- A new nullable `server_host_key` column on the `ssh_tunnels` table stores the expected host key in authorized-key form (e.g. `ssh-ed25519 AAAA...`). It is a public key and is stored in plaintext. It can be set via the SSH tunnel POST/PUT payloads (`ssh_tunnel.server_host_key`).
- When a tunnel has `server_host_key` set, Superset connects to the SSH server, reads the host key it presents, and rejects the tunnel if it does not match.
- A new config flag `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING` (default `False`) controls fail-closed behavior. When `True`, every tunnel must declare a `server_host_key`; a tunnel without one is rejected.
Runbook to adopt:
1. Capture the SSH server's host key, e.g. `ssh-keyscan -t ed25519 ssh.example.com` (verify it out-of-band).
2. Set that value on the tunnel's `server_host_key` (via the database/SSH tunnel API or UI payload).
3. Optionally set `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING = True` in `superset_config.py` to require host-key verification on all tunnels.
### SMTP server certificate validation enabled by default
`SMTP_SSL_SERVER_AUTH` now defaults to `True` (previously `False`). With this default, STARTTLS/SSL connections to the configured SMTP server validate the server's TLS certificate against the system trusted CA store. This makes outbound email (alerts and reports) verify the mail server's identity out of the box.
If your SMTP server presents a self-signed certificate, or a certificate that is not trusted by the system CA store, email delivery may now fail with a certificate verification error. To restore the previous behavior of skipping certificate validation, set the following in `superset_config.py`:
```python
SMTP_SSL_SERVER_AUTH = False
```
The recommended fix is to add the SMTP server's certificate (or its issuing CA) to the system trust store rather than disabling validation.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.

View File

@@ -265,6 +265,15 @@
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/code-frame@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7"
integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==
dependencies:
"@babel/helper-validator-identifier" "^7.29.7"
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0":
version "7.28.0"
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz"
@@ -275,20 +284,25 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz"
integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==
"@babel/compat-data@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629"
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
"@babel/core@^7.21.3", "@babel/core@^7.25.9":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz"
integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7"
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
dependencies:
"@babel/code-frame" "^7.28.6"
"@babel/generator" "^7.28.6"
"@babel/helper-compilation-targets" "^7.28.6"
"@babel/helper-module-transforms" "^7.28.6"
"@babel/helpers" "^7.28.6"
"@babel/parser" "^7.28.6"
"@babel/template" "^7.28.6"
"@babel/traverse" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/code-frame" "^7.29.7"
"@babel/generator" "^7.29.7"
"@babel/helper-compilation-targets" "^7.29.7"
"@babel/helper-module-transforms" "^7.29.7"
"@babel/helpers" "^7.29.7"
"@babel/parser" "^7.29.7"
"@babel/template" "^7.29.7"
"@babel/traverse" "^7.29.7"
"@babel/types" "^7.29.7"
"@jridgewell/remapping" "^2.3.5"
convert-source-map "^2.0.0"
debug "^4.1.0"
@@ -318,6 +332,17 @@
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/generator@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3"
integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==
dependencies:
"@babel/parser" "^7.29.7"
"@babel/types" "^7.29.7"
"@jridgewell/gen-mapping" "^0.3.12"
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
version "7.27.3"
resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz"
@@ -325,7 +350,7 @@
dependencies:
"@babel/types" "^7.27.3"
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2", "@babel/helper-compilation-targets@^7.28.6":
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz"
integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==
@@ -336,6 +361,17 @@
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-compilation-targets@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042"
integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==
dependencies:
"@babel/compat-data" "^7.29.7"
"@babel/helper-validator-option" "^7.29.7"
browserslist "^4.24.0"
lru-cache "^5.1.1"
semver "^6.3.1"
"@babel/helper-create-class-features-plugin@^7.27.1", "@babel/helper-create-class-features-plugin@^7.28.3":
version "7.28.3"
resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz"
@@ -374,6 +410,11 @@
resolved "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz"
integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
"@babel/helper-globals@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b"
integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==
"@babel/helper-member-expression-to-functions@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz"
@@ -398,6 +439,14 @@
"@babel/traverse" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/helper-module-imports@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396"
integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==
dependencies:
"@babel/traverse" "^7.29.7"
"@babel/types" "^7.29.7"
"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.6":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz"
@@ -407,6 +456,15 @@
"@babel/helper-validator-identifier" "^7.28.5"
"@babel/traverse" "^7.28.6"
"@babel/helper-module-transforms@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae"
integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==
dependencies:
"@babel/helper-module-imports" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@babel/traverse" "^7.29.7"
"@babel/helper-optimise-call-expression@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz"
@@ -455,16 +513,31 @@
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-string-parser@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f"
integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==
"@babel/helper-validator-identifier@^7.28.5":
version "7.28.5"
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
"@babel/helper-validator-identifier@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2"
integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==
"@babel/helper-validator-option@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz"
integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
"@babel/helper-validator-option@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a"
integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==
"@babel/helper-wrap-function@^7.27.1":
version "7.28.3"
resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz"
@@ -474,13 +547,13 @@
"@babel/traverse" "^7.28.3"
"@babel/types" "^7.28.2"
"@babel/helpers@^7.28.6":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz"
integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==
"@babel/helpers@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607"
integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==
dependencies:
"@babel/template" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/template" "^7.29.7"
"@babel/types" "^7.29.7"
"@babel/parser@^7.28.6":
version "7.28.6"
@@ -496,6 +569,13 @@
dependencies:
"@babel/types" "^7.29.0"
"@babel/parser@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334"
integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==
dependencies:
"@babel/types" "^7.29.7"
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz"
@@ -1172,6 +1252,15 @@
"@babel/parser" "^7.28.6"
"@babel/types" "^7.28.6"
"@babel/template@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==
dependencies:
"@babel/code-frame" "^7.29.7"
"@babel/parser" "^7.29.7"
"@babel/types" "^7.29.7"
"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.6":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz"
@@ -1198,6 +1287,19 @@
"@babel/types" "^7.29.0"
debug "^4.3.1"
"@babel/traverse@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d"
integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==
dependencies:
"@babel/code-frame" "^7.29.7"
"@babel/generator" "^7.29.7"
"@babel/helper-globals" "^7.29.7"
"@babel/parser" "^7.29.7"
"@babel/template" "^7.29.7"
"@babel/types" "^7.29.7"
debug "^4.3.1"
"@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.4.4":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz"
@@ -1214,6 +1316,14 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
"@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@braintree/sanitize-url@^7.1.1":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz#ca2035b0fefe956a8676ff0c69af73e605fcd81f"
@@ -9532,12 +9642,12 @@ latest-version@^7.0.0:
package-json "^8.1.0"
launch-editor@^2.6.1:
version "2.11.1"
resolved "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz"
integrity sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==
version "2.14.1"
resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.14.1.tgz#f7e0da3f58aaea03fea01074d840b5f739ed7ddc"
integrity sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==
dependencies:
picocolors "^1.1.1"
shell-quote "^1.8.3"
shell-quote "^1.8.4"
layout-base@^1.0.0:
version "1.0.2"
@@ -13489,7 +13599,7 @@ shebang-regex@^3.0.0:
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
shell-quote@^1.8.3:
shell-quote@^1.8.4:
version "1.8.4"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190"
integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==

View File

@@ -27,19 +27,6 @@
"webpack-cli": "^5.1.4"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/cli": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.25.6.tgz",
@@ -71,12 +58,12 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -85,32 +72,30 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.25.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
"integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"version": "7.29.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.25.0",
"@babel/helper-compilation-targets": "^7.25.2",
"@babel/helper-module-transforms": "^7.25.2",
"@babel/helpers": "^7.25.0",
"@babel/parser": "^7.25.0",
"@babel/template": "^7.25.0",
"@babel/traverse": "^7.25.2",
"@babel/types": "^7.25.2",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.6",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.29.2",
"@babel/parser": "^7.29.3",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -126,13 +111,13 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -169,15 +154,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
"integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.25.2",
"@babel/helper-validator-option": "^7.24.8",
"browserslist": "^4.23.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
@@ -382,29 +366,28 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
"integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -425,26 +408,25 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.25.0",
"@babel/types": "^7.25.6"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1843,14 +1825,14 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1875,13 +1857,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -2649,6 +2631,16 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -7983,16 +7975,6 @@
}
},
"dependencies": {
"@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@babel/cli": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.25.6.tgz",
@@ -8011,38 +7993,38 @@
}
},
"@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
}
},
"@babel/compat-data": {
"version": "7.25.4",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
"integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true
},
"@babel/core": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"version": "7.29.6",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
"dev": true,
"requires": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.25.0",
"@babel/helper-compilation-targets": "^7.25.2",
"@babel/helper-module-transforms": "^7.25.2",
"@babel/helpers": "^7.25.0",
"@babel/parser": "^7.25.0",
"@babel/template": "^7.25.0",
"@babel/traverse": "^7.25.2",
"@babel/types": "^7.25.2",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.6",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.29.2",
"@babel/parser": "^7.29.3",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -8051,13 +8033,13 @@
}
},
"@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"requires": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -8083,14 +8065,14 @@
}
},
"@babel/helper-compilation-targets": {
"version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
"integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"requires": {
"@babel/compat-data": "^7.25.2",
"@babel/helper-validator-option": "^7.24.8",
"browserslist": "^4.23.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
}
@@ -8229,21 +8211,21 @@
}
},
"@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true
},
"@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true
},
"@babel/helper-validator-option": {
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
"integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true
},
"@babel/helper-wrap-function": {
@@ -8258,22 +8240,22 @@
}
},
"@babel/helpers": {
"version": "7.25.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"requires": {
"@babel/template": "^7.25.0",
"@babel/types": "^7.25.6"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"@babel/parser": {
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"requires": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
}
},
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
@@ -9157,14 +9139,14 @@
}
},
"@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"@babel/traverse": {
@@ -9183,13 +9165,13 @@
}
},
"@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"requires": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
}
},
"@bcoe/v8-coverage": {
@@ -9771,6 +9753,16 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"requires": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"@jridgewell/resolve-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",

View File

@@ -4708,16 +4708,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -5029,10 +5028,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -10228,7 +10226,7 @@
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "4.1.1",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"dependencies": {
@@ -10238,8 +10236,7 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"requires": {
"argparse": "^2.0.1"
@@ -12345,15 +12342,15 @@
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="
},
"form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
}
},
"fromentries": {
@@ -12574,9 +12571,9 @@
}
},
"hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"requires": {
"function-bind": "^1.1.2"
}

View File

@@ -11349,19 +11349,6 @@
"integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==",
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -20682,22 +20669,35 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@@ -26524,14 +26524,14 @@
}
},
"node_modules/launch-editor": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.9.1.tgz",
"integrity": "sha512-Gcnl4Bd+hRO9P9icCP/RVVT2o8SFlPXofuCxvA2SaZuH45whSvf5p8x5oih5ftLiVhEI4sp5xDY+R+b3zJBh5w==",
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"shell-quote": "^1.8.1"
"picocolors": "^1.1.1",
"shell-quote": "^1.8.4"
}
},
"node_modules/lerc": {

View File

@@ -33,6 +33,7 @@ from superset.commands.database.exceptions import (
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelCreateFailedError,
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
SSHTunnelingNotEnabledError,
SSHTunnelInvalidError,
)
@@ -75,6 +76,7 @@ class CreateDatabaseCommand(BaseCommand):
SupersetErrorsException,
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
) as ex:
event_logger.log_with_context(
action=f"db_creation_failed.{ex.__class__.__name__}",

View File

@@ -75,3 +75,11 @@ class SSHTunnelMissingCredentials(CommandInvalidError, SSHTunnelError): # noqa:
class SSHTunnelInvalidCredentials(CommandInvalidError, SSHTunnelError): # noqa: N818
message = _("Cannot have multiple credentials for the SSH Tunnel")
class SSHTunnelHostKeyVerificationError(CommandInvalidError, SSHTunnelError):
"""The SSH server's host key failed opt-in verification for a tunnel."""
message = _(
"The SSH server host key could not be verified against the expected key."
)

View File

@@ -29,6 +29,7 @@ from superset.commands.database.exceptions import (
)
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
SSHTunnelingNotEnabledError,
)
from superset.commands.database.utils import ping
@@ -221,7 +222,11 @@ class TestConnectionDatabaseCommand(BaseCommand):
engine=engine_name,
)
raise DatabaseSecurityUnsafeError(message=str(ex)) from ex
except (SupersetTimeoutException, SSHTunnelingNotEnabledError) as ex:
except (
SupersetTimeoutException,
SSHTunnelingNotEnabledError,
SSHTunnelHostKeyVerificationError,
) as ex:
event_logger.log_with_context(
action=get_log_connection_action(
"test_connection_error",
@@ -230,7 +235,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
),
engine=engine_name,
)
# bubble up the exception to return proper status code
# bubble up the exception (preserving its specific message and status)
# instead of flattening it into a generic connection failure
raise
except Exception as ex:
if not database:

View File

@@ -895,6 +895,15 @@ SSH_TUNNEL_TIMEOUT_SEC = 10.0
#: Timeout (seconds) for transport socket (``socket.settimeout``)
SSH_TUNNEL_PACKET_TIMEOUT_SEC = 1.0
#: Opt-in defense-in-depth: when enabled, every SSH tunnel must declare an expected
#: server host key (``server_host_key`` on the tunnel) and the SSH server's presented
#: host key is verified against it before the tunnel is opened. A mismatch, or a
#: missing expected key while this flag is enabled, fails closed and the tunnel is
#: rejected. When disabled (the default), tunnels without a ``server_host_key`` open
#: without host-key verification, preserving existing behavior; tunnels that do set a
#: ``server_host_key`` are still verified regardless of this flag.
SSH_TUNNEL_STRICT_HOST_KEY_CHECKING: bool = False
# Feature flags may also be set via 'SUPERSET_FEATURE_' prefixed environment vars.
DEFAULT_FEATURE_FLAGS.update(
@@ -1722,9 +1731,14 @@ SMTP_USER = "superset"
SMTP_PORT = 25
SMTP_PASSWORD = "superset" # noqa: S105
SMTP_MAIL_FROM = "superset@superset.com"
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
# default system root CA certificates.
SMTP_SSL_SERVER_AUTH = False
# If True creates a default SSL context with ssl.Purpose.SERVER_AUTH using the
# default system root CA certificates. This makes STARTTLS/SSL connections to the
# SMTP server validate the server's certificate against the trusted CA store.
# Defaults to True so the mail server identity is verified out of the box. Set to
# False to restore the previous behavior of skipping certificate validation (for
# example, when using a self-signed certificate that is not in the system CA
# store).
SMTP_SSL_SERVER_AUTH = True
ENABLE_CHUNK_ENCODING = False
# Whether to bump the logging level to ERROR on the flask_appbuilder package

View File

@@ -56,6 +56,7 @@ from superset.commands.database.importers.dispatcher import ImportDatabasesComma
from superset.commands.database.oauth2 import OAuth2StoreTokenCommand
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
SSHTunnelingNotEnabledError,
)
from superset.commands.database.sync_permissions import SyncPermissionsCommand
@@ -484,7 +485,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
exc_info=True,
)
return self.response_422(message=str(ex))
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
except (
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
) as ex:
return self.response_400(message=str(ex))
except SupersetException as ex:
return self.response(ex.status, message=ex.message)
@@ -569,7 +574,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
exc_info=True,
)
return self.response_422(message=str(ex))
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
except (
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
) as ex:
return self.response_400(message=str(ex))
@expose("/<int:pk>", methods=("DELETE",))
@@ -1291,7 +1300,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
try:
TestConnectionDatabaseCommand(item).run()
return self.response(200, message="OK")
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
except (
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
) as ex:
return self.response_400(message=str(ex))
@expose("/<int:pk>/related_objects/", methods=("GET",))

View File

@@ -477,6 +477,22 @@ class DatabaseSSHTunnel(Schema):
private_key = fields.String(required=False, load_only=True)
private_key_password = fields.String(required=False, load_only=True)
# Optional expected SSH server host key in authorized-key form
# (e.g. "ssh-rsa AAAA...", "ssh-ed25519 AAAA..."). When set, the SSH server's
# presented host key is verified against it before the tunnel is opened. This is
# a public key, so it is not sensitive and is not masked.
server_host_key = fields.String(
required=False,
allow_none=True,
metadata={
"description": (
"Expected SSH server host key in authorized-key form "
"(e.g. 'ssh-ed25519 AAAA...'). When set, the server's host key is "
"verified against it before the tunnel is opened."
)
},
)
@validates_schema
def validate_authentication(self, data: dict[str, Any], **kwargs: Any) -> None:
errors: dict[str, str] = {}

View File

@@ -72,6 +72,12 @@ class SSHTunnel(AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, Model):
encrypted_field_factory.create(Text), nullable=True
)
# Optional expected SSH server host key, in authorized-key form
# (e.g. "ssh-rsa AAAA...", "ssh-ed25519 AAAA..."). When set, the SSH server's
# presented host key is verified against this value before the tunnel is opened.
# This is a public key, so it is stored in plaintext (not encrypted).
server_host_key = sa.Column(sa.Text, nullable=True)
export_fields = [
"server_address",
"server_port",
@@ -79,6 +85,7 @@ class SSHTunnel(AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, Model):
"password",
"private_key",
"private_key_password",
"server_host_key",
]
extra_import_fields = [
@@ -93,6 +100,9 @@ class SSHTunnel(AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, Model):
"server_port": self.server_port,
"username": self.username,
}
if self.server_host_key is not None:
# public key, not sensitive: returned in cleartext
output["server_host_key"] = self.server_host_key
if self.password is not None:
output["password"] = PASSWORD_MASK
if self.private_key is not None:

View File

@@ -15,26 +15,63 @@
# specific language governing permissions and limitations
# under the License.
import base64
import binascii
import logging
import socket
from io import StringIO
from typing import TYPE_CHECKING
import paramiko
import sshtunnel
from flask import Flask
from paramiko import RSAKey
from paramiko.pkey import UnknownKeyType
from superset.commands.database.ssh_tunnel.exceptions import SSHTunnelDatabasePortError
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
)
from superset.databases.utils import make_url_safe
from superset.utils.class_utils import load_class_from_name
if TYPE_CHECKING:
from superset.databases.ssh_tunnel.models import SSHTunnel
logger = logging.getLogger(__name__)
def _parse_authorized_key(authorized_key: str) -> paramiko.PKey:
"""
Parse a host key in authorized-key form (``"<type> <base64>[ comment]"``) into a
:class:`paramiko.PKey`. The optional trailing comment field and surrounding
whitespace are ignored.
:raises ValueError: if the value is empty or cannot be parsed as a host key.
"""
fields = authorized_key.strip().split()
if len(fields) < 2:
raise ValueError("Host key must be in 'ssh-<type> <base64>' form")
key_type, key_b64 = fields[0], fields[1]
try:
# validate=True so malformed characters raise instead of being silently
# dropped, which could otherwise pin an unintended key value.
key_bytes = base64.b64decode(key_b64, validate=True)
except (binascii.Error, ValueError) as ex:
raise ValueError("Host key base64 payload could not be decoded") from ex
try:
return paramiko.PKey.from_type_string(key_type, key_bytes)
except (paramiko.SSHException, UnknownKeyType) as ex:
raise ValueError(f"Host key could not be parsed: {ex}") from ex
class SSHManager:
def __init__(self, app: Flask) -> None:
super().__init__()
self.local_bind_address = app.config["SSH_TUNNEL_LOCAL_BIND_ADDRESS"]
self.strict_host_key_checking = app.config.get(
"SSH_TUNNEL_STRICT_HOST_KEY_CHECKING", False
)
sshtunnel.TUNNEL_TIMEOUT = app.config["SSH_TUNNEL_TIMEOUT_SEC"]
sshtunnel.SSH_TIMEOUT = app.config["SSH_TUNNEL_PACKET_TIMEOUT_SEC"]
@@ -48,6 +85,87 @@ class SSHManager:
port=server.local_bind_port,
)
def _verify_host_key(self, ssh_tunnel: "SSHTunnel") -> "paramiko.PKey | None":
"""
Opt-in defense-in-depth: verify the SSH server's host key before opening the
tunnel, to resist man-in-the-middle attacks (paramiko's ``Transport`` does no
known-hosts checking by default).
Behavior:
- If the tunnel declares an expected ``server_host_key``, connect to the SSH
server, read the host key it presents, and compare. On mismatch (or if the
expected key cannot be parsed) raise
:class:`SSHTunnelHostKeyVerificationError`.
- If no expected key is set and ``SSH_TUNNEL_STRICT_HOST_KEY_CHECKING`` is
enabled, fail closed and raise.
- If no expected key is set and strict checking is disabled, do nothing,
preserving existing (unverified) behavior.
:returns: the parsed expected host key when one is configured (so the caller
can pin it on the tunnel's own connection), or ``None`` when no key is
configured.
"""
expected_raw = ssh_tunnel.server_host_key
if not expected_raw or not expected_raw.strip():
if self.strict_host_key_checking:
raise SSHTunnelHostKeyVerificationError(
message=(
"SSH_TUNNEL_STRICT_HOST_KEY_CHECKING is enabled but no "
"expected server host key is configured for this tunnel."
)
)
return None
try:
expected_key = _parse_authorized_key(expected_raw)
except ValueError as ex:
raise SSHTunnelHostKeyVerificationError(
message=f"The configured expected server host key is invalid: {ex}"
) from ex
# Build the socket ourselves with an explicit timeout so the TCP connect
# phase is bounded too. ``paramiko.Transport((host, port))`` would connect
# synchronously with no timeout, leaving ``start_client(timeout=...)`` to
# govern only the SSH handshake; an unreachable host could then block for the
# full OS-level TCP timeout.
try:
sock = socket.create_connection(
(ssh_tunnel.server_address, ssh_tunnel.server_port),
timeout=sshtunnel.SSH_TIMEOUT,
)
except OSError as ex:
raise SSHTunnelHostKeyVerificationError(
message=f"Could not connect to the SSH server: {ex}"
) from ex
transport = paramiko.Transport(sock)
try:
transport.start_client(timeout=sshtunnel.SSH_TIMEOUT)
remote_key = transport.get_remote_server_key()
except Exception as ex: # noqa: BLE001
raise SSHTunnelHostKeyVerificationError(
message=f"Could not retrieve the SSH server host key: {ex}"
) from ex
finally:
transport.close()
if remote_key != expected_key:
logger.warning(
"SSH host key mismatch for %s:%s",
ssh_tunnel.server_address,
ssh_tunnel.server_port,
)
raise SSHTunnelHostKeyVerificationError(
message=(
"The SSH server presented a host key that does not match the "
"expected server host key configured for this tunnel."
)
)
return expected_key
def create_tunnel(
self,
ssh_tunnel: "SSHTunnel",
@@ -60,6 +178,12 @@ class SSHManager:
port = url.port or get_default_port(backend)
if not port:
raise SSHTunnelDatabasePortError()
# Opt-in host-key verification runs before the tunnel is opened. It returns
# the parsed expected key (or None) so we can also pin it on the tunnel's own
# connection below.
expected_host_key = self._verify_host_key(ssh_tunnel)
params = {
"ssh_address_or_host": (ssh_tunnel.server_address, ssh_tunnel.server_port),
"ssh_username": ssh_tunnel.username,
@@ -68,6 +192,14 @@ class SSHManager:
"debug_level": logging.getLogger("flask_appbuilder").level,
}
if expected_host_key is not None:
# Pin the expected key on the tunnel's own connection, so paramiko verifies
# the host that actually carries traffic on the same transport. The probe
# above and the tunnel open separate connections, so verifying only the
# probe would leave a TOCTOU gap (DNS re-resolution, selective
# interception); pinning here closes it.
params["ssh_host_key"] = expected_host_key
if ssh_tunnel.password:
params["ssh_password"] = ssh_tunnel.password
elif ssh_tunnel.private_key:

View File

@@ -327,7 +327,16 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
- chart_type="xy", kind="scatter": Scatter plot for correlation analysis
- chart_type="big_number": Big Number display (single metric, header only)
- chart_type="big_number", show_trendline=True,
temporal_column="<date_col>": Big Number with trendline
temporal_column="<date_col>", aggregation="sum": Big Number with trendline
(aggregation controls how the value is computed from trendline data points;
default when omitted is "LAST_VALUE" — most recent point only.
Use aggregation="sum" for all-time totals, "mean" for averages, "max"/"min" for extremes.
DIAGNOSIS: if a Big Number with Trendline shows wrong values, check
form_data["aggregation"] — missing/LAST_VALUE means the chart shows only the last data
point, not a total. Fix by calling update_chart with a complete Big Number config:
chart_type="big_number", metric=<metric>, show_trendline=True,
temporal_column=<date_col>, aggregation="sum". update_chart requires the full
config — omitting chart_type or metric causes a validation error.)
- chart_type="table": Data table for detailed views
- chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
- chart_type="pie": Pie chart for proportional data (set donut=True for donut)

View File

@@ -859,6 +859,9 @@ def map_big_number_config(config: BigNumberChartConfig) -> Dict[str, Any]:
if config.time_format:
form_data["time_format"] = config.time_format
if config.aggregation is not None:
form_data["aggregation"] = config.aggregation
_add_adhoc_filters(form_data, config.filters)
return form_data

View File

@@ -1417,6 +1417,32 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
),
ge=1,
)
aggregation: (
Literal["LAST_VALUE", "sum", "mean", "min", "max", "median", "raw"] | None
) = Field(
None,
description=(
"How the single big-number value is computed from the trendline "
"data points. Only applies when show_trendline=True. "
"Options: "
"'sum' = Total (Sum) — add all data points; use for all-time totals. "
"'LAST_VALUE' = most recent data point "
"(frontend default when this field is absent). "
"'mean' = Average (Mean). "
"'min' = Minimum. "
"'max' = Maximum. "
"'median' = Median. "
"'raw' = Overall value — single aggregate across the full period; best for "
"non-additive metrics like ratios, averages, or distinct counts. "
"DIAGNOSIS: if a Big Number with Trendline shows an unexpectedly low value "
"(e.g. yesterday's revenue instead of all-time total), "
"inspect form_data['aggregation'] "
"— when absent or 'LAST_VALUE' the chart shows only the last data point. "
"Fix by setting aggregation='sum'. "
"IMPORTANT: when updating aggregation, always include "
"show_trendline=True and temporal_column to preserve the trendline."
),
)
filters: list[FilterConfig] | None = Field(
None,
description="Filters to apply",
@@ -1437,6 +1463,13 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
"Period comparison is only available for "
"trendline charts."
)
if self.aggregation and not self.show_trendline:
raise ValueError(
"aggregation requires show_trendline=True. "
"The aggregation field only applies to Big Number with "
"Trendline charts. Set show_trendline=True and provide "
"a temporal_column, or omit aggregation."
)
return self
@model_validator(mode="after")

View File

@@ -115,6 +115,14 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"chart_type": "big_number",
"metric": {"name": "revenue", "aggregate": "SUM"},
},
{
"chart_type": "big_number",
"metric": {"name": "revenue", "aggregate": "SUM"},
"temporal_column": "order_date",
"show_trendline": True,
"aggregation": "sum",
"time_grain": "P1D",
},
],
}

View File

@@ -25,7 +25,7 @@ import sqlalchemy as sa
from alembic import op
from flask import current_app
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session
from sqlalchemy.orm import lazyload, Session
from superset import db, security_manager
from superset.db_engine_specs.base import GenericDBException
@@ -379,7 +379,15 @@ def upgrade_catalog_perms(engines: set[str] | None = None) -> None:
bind = op.get_bind()
session = db.Session(bind=bind)
for database in session.query(Database).all():
# The Database model has an eager-loaded (``lazy="joined"``) ``ssh_tunnel``
# backref. Eager-loading it here would SELECT every column on ``ssh_tunnels``,
# including columns added by later migrations that do not yet exist at the
# revision this helper runs in (e.g. on a fresh DB upgraded in one pass). The
# catalog upgrade only needs scalar ``Database`` columns, so disable the eager
# join to keep the query schema-safe across migration revisions.
for database in (
session.query(Database).options(lazyload(Database.ssh_tunnel)).all()
):
db_engine_spec = database.db_engine_spec
if (
engines and db_engine_spec.engine not in engines
@@ -576,7 +584,11 @@ def downgrade_catalog_perms(engines: set[str] | None = None) -> None:
bind = op.get_bind()
session = db.Session(bind=bind)
for database in session.query(Database).all():
# See upgrade_catalog_perms: avoid eager-loading the ``ssh_tunnel`` backref so the
# query stays schema-safe across migration revisions.
for database in (
session.query(Database).options(lazyload(Database.ssh_tunnel)).all()
):
db_engine_spec = database.db_engine_spec
if (
engines and db_engine_spec.engine not in engines

View File

@@ -0,0 +1,50 @@
# 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.
"""add server_host_key to ssh_tunnels
Adds a nullable ``server_host_key`` column to the ``ssh_tunnels`` table. It stores the
expected SSH server host key in authorized-key form (e.g. "ssh-ed25519 AAAA...") so
operators can opt in to verifying the SSH server's host key before a tunnel is opened.
This is a public key and is stored in plaintext (not encrypted). The column is
nullable, so existing tunnels are unaffected.
Revision ID: 78a40c08b4be
Revises: b7c9d1e2f3a4
Create Date: 2026-06-03 10:00:00.000000
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import add_columns, drop_columns
# revision identifiers, used by Alembic.
revision = "78a40c08b4be"
down_revision = "b7c9d1e2f3a4"
def upgrade() -> None:
"""Add the nullable ``server_host_key`` column to ``ssh_tunnels``."""
add_columns(
"ssh_tunnels",
sa.Column("server_host_key", sa.Text(), nullable=True),
)
def downgrade() -> None:
"""Drop the ``server_host_key`` column from ``ssh_tunnels``."""
drop_columns("ssh_tunnels", "server_host_key")

View File

@@ -37,9 +37,18 @@ logger = logging.getLogger(__name__)
class TestEmailSmtp(SupersetTestCase):
def setUp(self):
SMTP_CONFIG_KEYS = ("SMTP_SSL", "SMTP_SSL_SERVER_AUTH", "SMTP_STARTTLS")
def setUp(self) -> None:
self._original_smtp_config = {
key: current_app.config[key] for key in self.SMTP_CONFIG_KEYS
}
current_app.config["SMTP_SSL"] = False
def tearDown(self) -> None:
current_app.config.update(self._original_smtp_config)
super().tearDown()
@mock.patch("superset.utils.core.send_mime_email")
def test_send_smtp(self, mock_send_mime):
attachment = tempfile.NamedTemporaryFile()
@@ -208,6 +217,7 @@ class TestEmailSmtp(SupersetTestCase):
@mock.patch("smtplib.SMTP")
def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
current_app.config["SMTP_SSL"] = True
current_app.config["SMTP_SSL_SERVER_AUTH"] = False
mock_smtp.return_value = mock.Mock()
mock_smtp_ssl.return_value = mock.Mock()
utils.send_mime_email(

View File

@@ -312,3 +312,123 @@ def test_full_setting(
assert dttm_col.is_dttm
assert dttm_col.python_date_format == "epoch_s"
assert dttm_col.expression == "CAST(dttm as INTEGER)"
def test_smtp_ssl_server_auth_defaults_to_true() -> None:
"""
The shipped default for SMTP_SSL_SERVER_AUTH validates the SMTP server's
TLS certificate. Operators can still opt out by overriding it to False.
"""
from superset import config
assert config.SMTP_SSL_SERVER_AUTH is True
def _smtp_config(**overrides: Any) -> dict[str, Any]:
"""
Build a minimal SMTP config dict for ``send_mime_email`` tests, with
plaintext transport defaults; keyword ``overrides`` replace any key.
"""
config = {
"SMTP_HOST": "localhost",
"SMTP_PORT": 25,
"SMTP_USER": "",
"SMTP_PASSWORD": "",
"SMTP_STARTTLS": False,
"SMTP_SSL": False,
"SMTP_SSL_SERVER_AUTH": True,
}
config.update(overrides)
return config
def test_send_mime_email_ssl_server_auth_passes_context(
mocker: MockerFixture,
) -> None:
"""
With SMTP_SSL and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
default SSL context and threads it through to ``smtplib.SMTP_SSL`` so the
server certificate is validated.
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
smtp = mocker.patch("smtplib.SMTP")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=True),
dryrun=False,
)
create_default_context.assert_called_once_with()
assert not smtp.called
smtp_ssl.assert_called_once_with(
"localhost", 25, context=create_default_context.return_value
)
def test_send_mime_email_starttls_server_auth_passes_context(
mocker: MockerFixture,
) -> None:
"""
With STARTTLS and SMTP_SSL_SERVER_AUTH enabled, ``send_mime_email`` builds a
default SSL context and threads it through to ``starttls`` so the server
certificate is validated.
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp = mocker.patch("smtplib.SMTP")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_STARTTLS=True, SMTP_SSL_SERVER_AUTH=True),
dryrun=False,
)
create_default_context.assert_called_once_with()
smtp.return_value.starttls.assert_called_once_with(
context=create_default_context.return_value
)
def test_send_mime_email_server_auth_disabled_skips_context(
mocker: MockerFixture,
) -> None:
"""
When SMTP_SSL_SERVER_AUTH is disabled no SSL context is built and ``None`` is
passed through, preserving the opt-out (certificate validation skipped).
"""
from email.mime.multipart import MIMEMultipart
from superset.utils import core as utils
create_default_context = mocker.patch(
"superset.utils.core.ssl.create_default_context"
)
smtp_ssl = mocker.patch("smtplib.SMTP_SSL")
utils.send_mime_email(
"from",
["to"],
MIMEMultipart(),
_smtp_config(SMTP_SSL=True, SMTP_SSL_SERVER_AUTH=False),
dryrun=False,
)
assert not create_default_context.called
smtp_ssl.assert_called_once_with("localhost", 25, context=None)

View File

@@ -119,5 +119,5 @@ def test_database_filter(mocker: MockerFixture) -> None:
)
assert (
str(compiled_query)
== "SELECT dbs.uuid, dbs.created_on, dbs.changed_on, dbs.id, dbs.verbose_name, dbs.database_name, dbs.sqlalchemy_uri, dbs.password, dbs.cache_timeout, dbs.select_as_create_table_as, dbs.expose_in_sqllab, dbs.configuration_method, dbs.allow_run_async, dbs.allow_file_upload, dbs.allow_ctas, dbs.allow_cvas, dbs.allow_dml, dbs.force_ctas_schema, dbs.extra, dbs.encrypted_extra, dbs.impersonate_user, dbs.server_cert, dbs.is_managed_externally, dbs.external_url, dbs.created_by_fk, dbs.changed_by_fk, ssh_tunnels_1.uuid AS uuid_1, ssh_tunnels_1.created_on AS created_on_1, ssh_tunnels_1.changed_on AS changed_on_1, ssh_tunnels_1.extra_json, ssh_tunnels_1.id AS id_1, ssh_tunnels_1.database_id, ssh_tunnels_1.server_address, ssh_tunnels_1.server_port, ssh_tunnels_1.username, ssh_tunnels_1.password AS password_1, ssh_tunnels_1.private_key, ssh_tunnels_1.private_key_password, ssh_tunnels_1.created_by_fk AS created_by_fk_1, ssh_tunnels_1.changed_by_fk AS changed_by_fk_1 \nFROM dbs LEFT OUTER JOIN ssh_tunnels AS ssh_tunnels_1 ON dbs.id = ssh_tunnels_1.database_id \nWHERE '[' || dbs.database_name || '].(id:' || CAST(dbs.id AS VARCHAR) || ')' IN ('[my_db].(id:42)', '[my_other_db].(id:43)') OR dbs.database_name IN ('my_db', 'my_other_db', 'third_db')" # noqa: E501
== "SELECT dbs.uuid, dbs.created_on, dbs.changed_on, dbs.id, dbs.verbose_name, dbs.database_name, dbs.sqlalchemy_uri, dbs.password, dbs.cache_timeout, dbs.select_as_create_table_as, dbs.expose_in_sqllab, dbs.configuration_method, dbs.allow_run_async, dbs.allow_file_upload, dbs.allow_ctas, dbs.allow_cvas, dbs.allow_dml, dbs.force_ctas_schema, dbs.extra, dbs.encrypted_extra, dbs.impersonate_user, dbs.server_cert, dbs.is_managed_externally, dbs.external_url, dbs.created_by_fk, dbs.changed_by_fk, ssh_tunnels_1.uuid AS uuid_1, ssh_tunnels_1.created_on AS created_on_1, ssh_tunnels_1.changed_on AS changed_on_1, ssh_tunnels_1.extra_json, ssh_tunnels_1.id AS id_1, ssh_tunnels_1.database_id, ssh_tunnels_1.server_address, ssh_tunnels_1.server_port, ssh_tunnels_1.username, ssh_tunnels_1.password AS password_1, ssh_tunnels_1.private_key, ssh_tunnels_1.private_key_password, ssh_tunnels_1.server_host_key, ssh_tunnels_1.created_by_fk AS created_by_fk_1, ssh_tunnels_1.changed_by_fk AS changed_by_fk_1 \nFROM dbs LEFT OUTER JOIN ssh_tunnels AS ssh_tunnels_1 ON dbs.id = ssh_tunnels_1.database_id \nWHERE '[' || dbs.database_name || '].(id:' || CAST(dbs.id AS VARCHAR) || ')' IN ('[my_db].(id:42)', '[my_other_db].(id:43)') OR dbs.database_name IN ('my_db', 'my_other_db', 'third_db')" # noqa: E501
)

View File

@@ -14,11 +14,44 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from unittest.mock import Mock
from unittest.mock import Mock, patch
import paramiko
import pytest
import sshtunnel
from superset.extensions.ssh import SSHManagerFactory
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelHostKeyVerificationError,
)
from superset.extensions.ssh import SSHManager, SSHManagerFactory
def _make_manager(strict: bool = False) -> SSHManager:
"""Build an ``SSHManager`` test instance with configurable strict checking."""
app = Mock()
app.config = {
"SSH_TUNNEL_MAX_RETRIES": 2,
"SSH_TUNNEL_LOCAL_BIND_ADDRESS": "127.0.0.1",
"SSH_TUNNEL_TIMEOUT_SEC": 123.0,
"SSH_TUNNEL_PACKET_TIMEOUT_SEC": 321.0,
"SSH_TUNNEL_MANAGER_CLASS": "superset.extensions.ssh.SSHManager",
"SSH_TUNNEL_STRICT_HOST_KEY_CHECKING": strict,
}
return SSHManager(app)
def _authorized_key(key: paramiko.PKey) -> str:
"""Render a paramiko key in authorized-key (``"<type> <base64>"``) form."""
return f"{key.get_name()} {key.get_base64()}"
def _ssh_tunnel(server_host_key: str | None) -> Mock:
"""Create a mocked SSH tunnel with server connection fields populated."""
tunnel = Mock()
tunnel.server_address = "ssh.example.com"
tunnel.server_port = 22
tunnel.server_host_key = server_host_key
return tunnel
def test_ssh_tunnel_timeout_setting() -> None:
@@ -34,3 +67,199 @@ def test_ssh_tunnel_timeout_setting() -> None:
factory.init_app(app)
assert sshtunnel.TUNNEL_TIMEOUT == 123.0
assert sshtunnel.SSH_TIMEOUT == 321.0
@patch("superset.extensions.ssh.socket.create_connection")
@patch("superset.extensions.ssh.paramiko.Transport")
def test_verify_host_key_match(
mock_transport_cls: Mock, mock_create_connection: Mock
) -> None:
"""The server presents the same key we expect: verification passes."""
server_key = paramiko.RSAKey.generate(2048)
manager = _make_manager(strict=False)
tunnel = _ssh_tunnel(_authorized_key(server_key))
transport = mock_transport_cls.return_value
transport.get_remote_server_key.return_value = server_key
result = manager._verify_host_key(tunnel) # should not raise
# The TCP connect is bounded by an explicit timeout, and the resulting
# socket is handed to Transport.
mock_create_connection.assert_called_once_with(
("ssh.example.com", 22), timeout=321.0
)
mock_transport_cls.assert_called_once_with(mock_create_connection.return_value)
transport.start_client.assert_called_once()
transport.close.assert_called_once()
# The parsed expected key is returned so the caller can pin it on the tunnel.
assert result == server_key
@patch("superset.extensions.ssh.socket.create_connection")
@patch("superset.extensions.ssh.paramiko.Transport")
def test_verify_host_key_mismatch_raises(
mock_transport_cls: Mock, mock_create_connection: Mock
) -> None:
"""The server presents a different key than expected: verification fails."""
expected_key = paramiko.RSAKey.generate(2048)
presented_key = paramiko.RSAKey.generate(2048)
manager = _make_manager(strict=False)
tunnel = _ssh_tunnel(_authorized_key(expected_key))
transport = mock_transport_cls.return_value
transport.get_remote_server_key.return_value = presented_key
with pytest.raises(SSHTunnelHostKeyVerificationError):
manager._verify_host_key(tunnel)
mock_create_connection.assert_called_once()
transport.close.assert_called_once()
@patch("superset.extensions.ssh.socket.create_connection")
def test_verify_host_key_connect_failure_raises(
mock_create_connection: Mock,
) -> None:
"""A bounded TCP connect failure surfaces as a host-key verification error."""
manager = _make_manager(strict=False)
server_key = paramiko.RSAKey.generate(2048)
tunnel = _ssh_tunnel(_authorized_key(server_key))
mock_create_connection.side_effect = OSError("connection refused")
with pytest.raises(SSHTunnelHostKeyVerificationError):
manager._verify_host_key(tunnel)
@patch("superset.extensions.ssh.paramiko.Transport")
def test_verify_host_key_unset_non_strict_skips(mock_transport_cls: Mock) -> None:
"""Back-compat: no expected key + strict checking off => no verification at all."""
manager = _make_manager(strict=False)
tunnel = _ssh_tunnel(None)
assert manager._verify_host_key(tunnel) is None # should not raise
mock_transport_cls.assert_not_called()
@patch("superset.extensions.ssh.paramiko.Transport")
def test_verify_host_key_unset_strict_raises(mock_transport_cls: Mock) -> None:
"""Fail-closed: no expected key + strict checking on => reject."""
manager = _make_manager(strict=True)
tunnel = _ssh_tunnel(None)
with pytest.raises(SSHTunnelHostKeyVerificationError):
manager._verify_host_key(tunnel)
mock_transport_cls.assert_not_called()
@patch("superset.extensions.ssh.socket.create_connection")
@patch("superset.extensions.ssh.paramiko.Transport")
def test_verify_host_key_match_ignores_comment_and_whitespace(
mock_transport_cls: Mock,
mock_create_connection: Mock,
) -> None:
# The stored key may carry a trailing comment and extra whitespace.
server_key = paramiko.RSAKey.generate(2048)
manager = _make_manager(strict=False)
stored = f" {_authorized_key(server_key)} user@host "
tunnel = _ssh_tunnel(stored)
transport = mock_transport_cls.return_value
transport.get_remote_server_key.return_value = server_key
manager._verify_host_key(tunnel) # should not raise
# Whitespace/comment stripping must not short-circuit verification: the
# bounded TCP connect and Transport handshake still run as in the plain
# match case.
mock_create_connection.assert_called_once_with(
("ssh.example.com", 22), timeout=321.0
)
mock_transport_cls.assert_called_once_with(mock_create_connection.return_value)
transport.start_client.assert_called_once()
transport.close.assert_called_once()
def test_verify_host_key_invalid_expected_raises() -> None:
# A malformed expected key is rejected before any network connection.
manager = _make_manager(strict=False)
tunnel = _ssh_tunnel("not-a-valid-key")
with pytest.raises(SSHTunnelHostKeyVerificationError):
manager._verify_host_key(tunnel)
def test_verify_host_key_unknown_key_type_raises() -> None:
"""An unsupported key type is wrapped in the verification error, not leaked."""
manager = _make_manager(strict=False)
server_key = paramiko.RSAKey.generate(2048)
tunnel = _ssh_tunnel(f"ssh-bogus {server_key.get_base64()}")
with pytest.raises(SSHTunnelHostKeyVerificationError):
manager._verify_host_key(tunnel)
@patch("superset.extensions.ssh.sshtunnel.open_tunnel")
@patch("superset.extensions.ssh.socket.create_connection")
@patch("superset.extensions.ssh.paramiko.Transport")
def test_create_tunnel_pins_verified_host_key(
mock_transport_cls: Mock,
mock_create_connection: Mock,
mock_open_tunnel: Mock,
) -> None:
"""A verified expected key is also pinned on the tunnel's own connection.
When an expected host key is configured and verified, it is also pinned on the
tunnel's own connection (``ssh_host_key``) so paramiko verifies the host that
actually carries traffic on the same transport — closing the probe-vs-tunnel
TOCTOU gap rather than trusting only the pre-flight probe.
"""
server_key = paramiko.RSAKey.generate(2048)
manager = _make_manager(strict=False)
tunnel = _ssh_tunnel(_authorized_key(server_key))
tunnel.username = "user"
tunnel.password = None
tunnel.private_key = None
mock_transport_cls.return_value.get_remote_server_key.return_value = server_key
manager.create_tunnel(tunnel, "postgresql://u:p@db:5432/ex")
_, kwargs = mock_open_tunnel.call_args
assert kwargs["ssh_host_key"] == server_key
@patch("superset.extensions.ssh.sshtunnel.open_tunnel")
def test_create_tunnel_without_host_key_does_not_pin(mock_open_tunnel: Mock) -> None:
# No expected key configured (non-strict): nothing is pinned, preserving the
# prior behavior.
manager = _make_manager(strict=False)
tunnel = _ssh_tunnel(None)
tunnel.username = "user"
tunnel.password = None
tunnel.private_key = None
manager.create_tunnel(tunnel, "postgresql://u:p@db:5432/ex")
_, kwargs = mock_open_tunnel.call_args
assert "ssh_host_key" not in kwargs
def test_ssh_tunnel_schema_round_trips_server_host_key() -> None:
"""The schema accepts and preserves the public host key field."""
from superset.databases.schemas import DatabaseSSHTunnel
server_key = paramiko.RSAKey.generate(2048)
authorized = _authorized_key(server_key)
payload = {
"server_address": "ssh.example.com",
"server_port": 22,
"username": "user",
"password": "secret",
"server_host_key": authorized,
}
loaded = DatabaseSSHTunnel().load(payload)
assert loaded["server_host_key"] == authorized

View File

@@ -177,6 +177,17 @@ class TestBigNumberChartConfig:
compare_lag=1,
)
def test_aggregation_requires_trendline(self) -> None:
"""aggregation without show_trendline=True must raise ValidationError."""
with pytest.raises(
ValidationError, match="aggregation requires show_trendline"
):
BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
aggregation="sum",
)
def test_with_filters(self) -> None:
config = BigNumberChartConfig(
chart_type="big_number",
@@ -188,6 +199,36 @@ class TestBigNumberChartConfig:
assert config.filters is not None
assert len(config.filters) == 1
def test_with_aggregation_sum(self) -> None:
"""aggregation='sum' is accepted when show_trendline=True."""
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
temporal_column="ds",
show_trendline=True,
aggregation="sum",
)
assert config.aggregation == "sum"
def test_with_aggregation_last_value(self) -> None:
"""aggregation='LAST_VALUE' is accepted when show_trendline=True."""
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
temporal_column="ds",
show_trendline=True,
aggregation="LAST_VALUE",
)
assert config.aggregation == "LAST_VALUE"
def test_aggregation_defaults_to_none(self) -> None:
"""aggregation field is None when omitted."""
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
)
assert config.aggregation is None
def test_extra_fields_forbidden(self) -> None:
with pytest.raises(ValueError, match="Unknown field 'unknown_field'"):
BigNumberChartConfig(
@@ -307,6 +348,52 @@ class TestMapBigNumberConfig:
assert "time_grain_sqla" not in form_data
assert "start_y_axis_at_zero" not in form_data
def test_with_aggregation_sum(self) -> None:
"""aggregation='sum' is written to form_data for trendline charts."""
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
temporal_column="order_date",
show_trendline=True,
aggregation="sum",
)
form_data = map_big_number_config(config)
assert form_data["aggregation"] == "sum"
def test_with_aggregation_last_value(self) -> None:
"""aggregation='LAST_VALUE' is written to form_data for trendline charts."""
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
temporal_column="order_date",
show_trendline=True,
aggregation="LAST_VALUE",
)
form_data = map_big_number_config(config)
assert form_data["aggregation"] == "LAST_VALUE"
def test_aggregation_absent_when_not_set(self) -> None:
"""aggregation key is absent from form_data when config.aggregation is None."""
config = BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
temporal_column="order_date",
show_trendline=True,
)
form_data = map_big_number_config(config)
assert "aggregation" not in form_data
def test_aggregation_not_allowed_for_big_number_total(self) -> None:
"""aggregation is rejected when show_trendline=False (big_number_total)."""
with pytest.raises(
ValidationError, match="aggregation requires show_trendline"
):
BigNumberChartConfig(
chart_type="big_number",
metric=ColumnRef(name="revenue", aggregate="SUM"),
aggregation="sum",
)
class TestMapConfigToFormDataBigNumber:
"""Test map_config_to_form_data dispatch for big number."""