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/06/15 17:37:35 UTC
(superset) branch feat/ssh-tunnel-host-key-checking updated: fix(ssh_tunnel): pin verified host key on the tunnel connection (close TOCTOU)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch feat/ssh-tunnel-host-key-checking
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/feat/ssh-tunnel-host-key-checking by this push:
new 4c391a4508f fix(ssh_tunnel): pin verified host key on the tunnel connection (close TOCTOU)
4c391a4508f is described below
commit 4c391a4508fcac33c292a9110f51c1978833d453
Author: Evan <ev...@preset.io>
AuthorDate: Mon Jun 15 10:36:52 2026 -0700
fix(ssh_tunnel): pin verified host key on the tunnel connection (close TOCTOU)
The pre-flight probe and sshtunnel.open_tunnel establish separate
connections, so verifying only the probe left a TOCTOU gap (DNS
re-resolution, selective interception). Pass the parsed expected key as
ssh_host_key so paramiko verifies the host key on the same transport that
carries traffic. The probe is kept for the precise early error and the
strict-no-key policy (sshtunnel swallows paramiko's host-key SSHException
into a generic 'could not establish session' error).
Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
superset/extensions/ssh.py | 24 +++++++++++++---
tests/unit_tests/extensions/ssh_test.py | 49 +++++++++++++++++++++++++++++++--
2 files changed, 67 insertions(+), 6 deletions(-)
diff --git a/superset/extensions/ssh.py b/superset/extensions/ssh.py
index df7a71e9e07..8777a5368c4 100644
--- a/superset/extensions/ssh.py
+++ b/superset/extensions/ssh.py
@@ -79,7 +79,7 @@ class SSHManager:
port=server.local_bind_port,
)
- def _verify_host_key(self, ssh_tunnel: "SSHTunnel") -> None:
+ 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
@@ -95,6 +95,10 @@ class SSHManager:
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
@@ -106,7 +110,7 @@ class SSHManager:
"expected server host key is configured for this tunnel."
)
)
- return
+ return None
try:
expected_key = _parse_authorized_key(expected_raw)
@@ -154,6 +158,8 @@ class SSHManager:
)
)
+ return expected_key
+
def create_tunnel(
self,
ssh_tunnel: "SSHTunnel",
@@ -167,8 +173,10 @@ class SSHManager:
if not port:
raise SSHTunnelDatabasePortError()
- # Opt-in host-key verification runs before the tunnel is opened.
- self._verify_host_key(ssh_tunnel)
+ # 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),
@@ -178,6 +186,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:
diff --git a/tests/unit_tests/extensions/ssh_test.py b/tests/unit_tests/extensions/ssh_test.py
index 62a91bd1c42..d3e22d0e71e 100644
--- a/tests/unit_tests/extensions/ssh_test.py
+++ b/tests/unit_tests/extensions/ssh_test.py
@@ -79,7 +79,7 @@ def test_verify_host_key_match(
transport = mock_transport_cls.return_value
transport.get_remote_server_key.return_value = server_key
- manager._verify_host_key(tunnel) # should not raise
+ 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.
@@ -89,6 +89,8 @@ def test_verify_host_key_match(
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")
@@ -133,7 +135,7 @@ def test_verify_host_key_unset_non_strict_skips(mock_transport_cls: Mock) -> Non
manager = _make_manager(strict=False)
tunnel = _ssh_tunnel(None)
- manager._verify_host_key(tunnel) # should not raise
+ assert manager._verify_host_key(tunnel) is None # should not raise
mock_transport_cls.assert_not_called()
@@ -177,6 +179,49 @@ def test_verify_host_key_invalid_expected_raises() -> None:
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:
+ # 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