This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 8b115c0be65 Verify SSH host keys by default in SSH and SFTP hooks 
(#73419)
8b115c0be65 is described below

commit 8b115c0be6549f1ab57d0e4eb52bd13969a092fc
Author: Jarek Potiuk <[email protected]>
AuthorDate: Wed Oct 7 21:04:09 2026 +0200

    Verify SSH host keys by default in SSH and SFTP hooks (#73419)
    
    * Verify SSH host keys by default in SSH and SFTP hooks
    
    The SFTP filesystem backend has always verified host keys by default,
    while SSHHook, SSHHookAsync and SFTPHookAsync did not: `no_host_key_check`
    defaulted to true, so any connection that did not say otherwise got
    paramiko's AutoAddPolicy, or `known_hosts=None` on the asyncssh paths.
    This brings the hooks in line with the filesystem backend.
    
    This is a breaking change: a connection to a host with no entry in the
    known hosts file is now refused unless verification is disabled
    explicitly.
    
    Three further changes were needed to make that default workable:
    
    * SSHHook gains a `no_host_key_check` constructor argument. The setting
      could previously only come from a Connection extra, so code building
      the hook directly had no way to opt out at all -- which also made the
      documented migration path unusable for those callers.
    
    * `ignore_hostkey_verification` is now honoured as a deprecated alias for
      `no_host_key_check`. Nothing read it: connections setting it were
      relying on the permissive default rather than on the extra, and would
      otherwise have broken with no working replacement.
    
    * store_directory_concurrently and retrieve_directory_concurrently built
      their worker hooks with only `ssh_conn_id`, discarding the rest of the
      parent hook's configuration. That was invisible while the default was
      permissive; the workers now inherit the host key setting.
    
    Generated-by: Claude Opus 5
    
    * Point the Amazon SFTP transfer tests at a connection that skips host key 
checks
    
    `SFTPToS3Operator` and `S3ToSFTPOperator` build their own `SFTPHook` from
    `sftp_conn_id` and take no hook argument, so these tests could not reuse the
    `SSHHook` they already configure. They rely on the bundled `ssh_default`
    connection, a bare `ssh://localhost` with no extras, which host key
    verification now refuses.
    
    Overrides that conn id for the duration of each class with the same host and
    `no_host_key_check=true` -- the migration step any deployment relying on the
    previous default has to take.
    
    Generated-by: Claude Opus 5
    
    * Apply host key verification settings consistently across SSH hooks
    
    Connections migrating through the deprecated `ignore_hostkey_verification` 
alias only kept working on the synchronous hook, so a deferrable task could 
submit successfully and then fail once its trigger reconnected through 
`SSHHookAsync` or `SFTPHookAsync`.
    
    A connection carrying both `host_key` and `no_host_key_check` was rejected 
before the constructor argument was applied, which left no way to resolve that 
combination for a connection the caller cannot edit.
    
    The SFTP provider now passes a constructor argument that SSH provider 6.0.x 
does not accept, so its minimum SSH provider version has to move with the next 
release.
    
    Generated-by: Claude Opus 5
    
    * Keep host key settings consistent on deferred and async SSH paths
    
    A no_host_key_check set on the hook given to a deferrable SFTPOperator
    was lost at deferral, so a transfer that worked synchronously failed in
    the trigger once host keys became verified by default. The async hooks
    also wrote their inline known_hosts entry for the connection's host
    rather than the host actually connected to, and emitted an entry
    asyncssh cannot use for a bare base64 host_key -- both undermining the
    host_key migration path this change recommends.
    
    Generated-by: Claude Opus 5
    
    * Use stable parametrize ids for SFTP async host key test
    
    Generated-by: Claude Opus 5
    
    * Preserve async host verification without a known hosts file
    
    An absent default known_hosts file should still produce an untrusted-host 
result, rather than a filesystem error. Keep explicit known_hosts paths 
unchanged and carry the effective host-key policy into concurrent SFTP workers.
    
    Co-authored-by: Copilot <[email protected]>
    
    ---------
    
    Co-authored-by: Copilot <[email protected]>
---
 .../unit/amazon/aws/transfers/test_s3_to_sftp.py   |  12 ++
 .../unit/amazon/aws/transfers/test_sftp_to_s3.py   |  12 ++
 providers/sftp/docs/changelog.rst                  |  16 ++
 providers/sftp/docs/connections/sftp.rst           |   2 +-
 providers/sftp/pyproject.toml                      |   2 +-
 .../sftp/src/airflow/providers/sftp/hooks/sftp.py  |  38 +++-
 .../src/airflow/providers/sftp/operators/sftp.py   |   1 +
 .../src/airflow/providers/sftp/triggers/sftp.py    |  22 ++-
 providers/sftp/tests/unit/sftp/hooks/test_sftp.py  | 205 +++++++++++++++++++--
 .../sftp/tests/unit/sftp/operators/test_sftp.py    |  31 +++-
 providers/ssh/docs/changelog.rst                   |  15 ++
 providers/ssh/docs/connections/ssh.rst             |   2 +-
 .../ssh/src/airflow/providers/ssh/hooks/ssh.py     |  71 ++++++-
 providers/ssh/tests/unit/ssh/hooks/test_ssh.py     |  67 ++++++-
 .../ssh/tests/unit/ssh/hooks/test_ssh_async.py     |  64 +++++++
 15 files changed, 518 insertions(+), 42 deletions(-)

diff --git 
a/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py 
b/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py
index 3ed273f7d96..e84d00ee8dc 100644
--- a/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py
+++ b/providers/amazon/tests/unit/amazon/aws/transfers/test_s3_to_sftp.py
@@ -51,6 +51,18 @@ DEFAULT_DATE = timezone.datetime(2018, 1, 1)
 
 
 class TestS3ToSFTPOperator:
+    @pytest.fixture(autouse=True)
+    def _ssh_default_allows_unknown_host(self, monkeypatch):
+        """Let the bundled ``ssh_default`` connection reach the test SSH 
server.
+
+        ``no_host_key_check`` now defaults to false, and ``ssh_default`` is a 
bare
+        ``ssh://localhost`` with no extras, so the transfer operators -- which 
build
+        their own ``SFTPHook`` from the conn id and cannot be handed a 
pre-configured
+        hook -- would be refused by host key verification. Setting the extra 
here is
+        exactly what a deployment relying on the old default has to do.
+        """
+        monkeypatch.setenv("AIRFLOW_CONN_SSH_DEFAULT", 
"ssh://localhost/?no_host_key_check=true")
+
     def setup_method(self):
         hook = SSHHook(ssh_conn_id="ssh_default")
         hook.no_host_key_check = True
diff --git 
a/providers/amazon/tests/unit/amazon/aws/transfers/test_sftp_to_s3.py 
b/providers/amazon/tests/unit/amazon/aws/transfers/test_sftp_to_s3.py
index c715c67fb99..5a5f5c466c1 100644
--- a/providers/amazon/tests/unit/amazon/aws/transfers/test_sftp_to_s3.py
+++ b/providers/amazon/tests/unit/amazon/aws/transfers/test_sftp_to_s3.py
@@ -50,6 +50,18 @@ DEFAULT_DATE = timezone.datetime(2018, 1, 1)
 
 
 class TestSFTPToS3Operator:
+    @pytest.fixture(autouse=True)
+    def _ssh_default_allows_unknown_host(self, monkeypatch):
+        """Let the bundled ``ssh_default`` connection reach the test SSH 
server.
+
+        ``no_host_key_check`` now defaults to false, and ``ssh_default`` is a 
bare
+        ``ssh://localhost`` with no extras, so the transfer operators -- which 
build
+        their own ``SFTPHook`` from the conn id and cannot be handed a 
pre-configured
+        hook -- would be refused by host key verification. Setting the extra 
here is
+        exactly what a deployment relying on the old default has to do.
+        """
+        monkeypatch.setenv("AIRFLOW_CONN_SSH_DEFAULT", 
"ssh://localhost/?no_host_key_check=true")
+
     def setup_method(self):
         hook = SSHHook(ssh_conn_id="ssh_default")
 
diff --git a/providers/sftp/docs/changelog.rst 
b/providers/sftp/docs/changelog.rst
index 8e937cac855..79712edd86e 100644
--- a/providers/sftp/docs/changelog.rst
+++ b/providers/sftp/docs/changelog.rst
@@ -27,6 +27,22 @@
 Changelog
 ---------
 
+.. warning::
+  The ``no_host_key_check`` connection extra now defaults to ``false``. A 
connection to a host that has
+  no entry in the known hosts file is refused unless host key verification is 
disabled explicitly or a
+  ``host_key`` is supplied in the connection's extra field.
+
+  Deployments that relied on the previous default can keep the earlier 
behaviour by adding the host key
+  to the known hosts file, supplying ``host_key`` on the connection, setting 
the ``no_host_key_check``
+  connection extra to ``true``, or -- when building 
``SSHHook``/``SFTPHook``/``SFTPHookAsync`` directly
+  rather than from a connection -- passing the new ``no_host_key_check=True`` 
constructor argument. The
+  constructor argument takes precedence over the connection extra, and a value 
set on the hook passed to
+  a deferrable ``SFTPOperator`` is carried into its trigger.
+
+  The previously undocumented ``ignore_hostkey_verification`` extra is now 
honoured as a deprecated alias
+  for ``no_host_key_check`` and emits a ``DeprecationWarning``. It had no 
effect before: connections that
+  set it were relying on the old permissive default rather than on the setting 
itself.
+
 6.1.0
 .....
 
diff --git a/providers/sftp/docs/connections/sftp.rst 
b/providers/sftp/docs/connections/sftp.rst
index 6cc3cb47fbb..cb93aff6f54 100644
--- a/providers/sftp/docs/connections/sftp.rst
+++ b/providers/sftp/docs/connections/sftp.rst
@@ -65,7 +65,7 @@ Extra (optional)
     * ``conn_timeout`` - An optional timeout (in seconds) for the TCP connect. 
Default is ``10``.
     * ``timeout`` - Deprecated - use conn_timeout instead.
     * ``compress`` - ``true`` to ask the remote client/server to compress 
traffic; ``false`` to refuse compression. Default is ``true``.
-    * ``no_host_key_check`` - Set to ``false`` to restrict connecting to hosts 
with no entries in ``~/.ssh/known_hosts`` (Hosts file). This provides maximum 
protection against trojan horse attacks, but can be troublesome when the 
``/etc/ssh/ssh_known_hosts`` file is poorly maintained or connections to new 
hosts are frequently made. This option forces the user to manually add all new 
hosts. Default is ``true``, ssh will automatically add new host keys to the 
user known hosts files.
+    * ``no_host_key_check`` - Set to ``true`` to connect to hosts that have no 
entry in ``~/.ssh/known_hosts`` (Hosts file), accepting unknown host keys 
without requiring a known-hosts entry. Default is ``false``, which restricts 
connecting to hosts already present in the known hosts file. The default 
provides maximum protection against trojan horse attacks, but can be 
troublesome when the ``/etc/ssh/ssh_known_hosts`` file is poorly maintained or 
connections to new hosts are frequently m [...]
     * ``allow_host_key_change`` - Set to ``true`` if you want to allow 
connecting to hosts that has host key changed or when you get 'REMOTE HOST 
IDENTIFICATION HAS CHANGED' error.  This won't protect against 
Man-In-The-Middle attacks. Other possible solution is to remove the host entry 
from ``~/.ssh/known_hosts`` file. Default is ``false``.
     * ``look_for_keys`` - Set to ``false`` if you want to disable searching 
for discoverable private key files in ``~/.ssh/``
     * ``host_key`` - The base64 encoded ssh-rsa public key of the host or 
"ssh-<key type> <key data>" (as you would find in the ``known_hosts`` file). 
Specifying this allows making the connection if and only if the public key of 
the endpoint matches this value.
diff --git a/providers/sftp/pyproject.toml b/providers/sftp/pyproject.toml
index 7a6e5d68279..057c92a0571 100644
--- a/providers/sftp/pyproject.toml
+++ b/providers/sftp/pyproject.toml
@@ -59,7 +59,7 @@ requires-python = ">=3.11"
 # After you modify the dependencies, and rebuild your Breeze CI image with 
``breeze ci-image build``
 dependencies = [
     "apache-airflow>=2.11.0",
-    "apache-airflow-providers-ssh>=6.0.0",
+    "apache-airflow-providers-ssh>=6.0.0",  # use next version
     "apache-airflow-providers-common-compat>=1.12.0",
     "paramiko>=4.0.0",
     "asyncssh>=2.12.0; python_version < '3.14'",
diff --git a/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py 
b/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py
index 96df4feb730..48badace7d3 100644
--- a/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py
+++ b/providers/sftp/src/airflow/providers/sftp/hooks/sftp.py
@@ -235,11 +235,11 @@ class SFTPHook(SSHHook):
             auth_timeout=self.auth_timeout,
             host_proxy_cmd=self.host_proxy_cmd,
             conn_retry_attempts=self.conn_retry_attempts,
+            no_host_key_check=self.no_host_key_check,
         )
         # These have no constructor parameter and are only ever resolved from 
the
         # connection's `extra` field or left at their class default, so copy 
the
         # parent's already-resolved values across explicitly.
-        worker_hook.no_host_key_check = self.no_host_key_check
         worker_hook.allow_host_key_change = self.allow_host_key_change
         worker_hook.host_key = self.host_key
         worker_hook.look_for_keys = self.look_for_keys
@@ -879,6 +879,9 @@ class SFTPHookAsync(BaseHook):
     :param known_hosts: path to the known_hosts file on the local file system. 
Defaults to ``~/.ssh/known_hosts``.
     :param key_file: path to the client key file used for authentication to 
SFTP server
     :param passphrase: passphrase used with the key_file for authentication to 
SFTP server
+    :param no_host_key_check: Set to ``True`` to skip host key verification. 
Overrides the
+        connection's ``no_host_key_check`` extra. Defaults to ``None``, 
meaning the value is
+        taken from the connection (and host keys are verified when the 
connection does not set it).
     """
 
     conn_name_attr = "ssh_conn_id"
@@ -898,6 +901,7 @@ class SFTPHookAsync(BaseHook):
         key_file: str = "",
         passphrase: str = "",
         private_key: str = "",
+        no_host_key_check: bool | None = None,
     ) -> None:
         self.sftp_conn_id = sftp_conn_id
         self.host = host
@@ -908,6 +912,7 @@ class SFTPHookAsync(BaseHook):
         self.key_file = key_file
         self.passphrase = passphrase
         self.private_key = private_key
+        self.no_host_key_check = no_host_key_check
         self.conn: asyncssh.SFTPClient | None = None
         self._conn_count = 0
         self._conn_lock = asyncio.Lock()
@@ -931,8 +936,20 @@ class SFTPHookAsync(BaseHook):
 
         host_key = extra_options.get("host_key")
         nhkc_raw = extra_options.get("no_host_key_check")
-        no_host_key_check = True if nhkc_raw is None else 
(str(nhkc_raw).lower() == "true")
+        if nhkc_raw is None and "ignore_hostkey_verification" in extra_options:
+            warnings.warn(
+                "The `ignore_hostkey_verification` connection extra is 
deprecated; "
+                "use `no_host_key_check` instead.",
+                AirflowProviderDeprecationWarning,
+                stacklevel=2,
+            )
+            nhkc_raw = extra_options["ignore_hostkey_verification"]
+        no_host_key_check = False if nhkc_raw is None else 
(str(nhkc_raw).lower() == "true")
+        if self.no_host_key_check is not None:
+            no_host_key_check = self.no_host_key_check
 
+        # Validated on the effective value, so the constructor argument can 
resolve a connection
+        # that sets both `host_key` and `no_host_key_check` -- the same rule 
as `SSHHook`.
         if host_key is not None and no_host_key_check:
             raise ValueError("Host key check was skipped, but `host_key` value 
was given")
 
@@ -949,7 +966,16 @@ class SFTPHookAsync(BaseHook):
                 )
             if len(host_key_parts) >= 2:
                 host_key = " ".join(host_key_parts[:2])
-            self.known_hosts = f"{conn.host} {host_key}".encode()
+            else:
+                # A bare key is RSA, as on the sync hook; asyncssh needs the 
type spelled out.
+                host_key = f"ssh-rsa {host_key}"
+            self.known_hosts = f"{self.host or conn.host} {host_key}".encode()
+
+    def _should_use_known_hosts(self) -> bool:
+        """Leave a missing default file unset so AsyncSSH reports an untrusted 
host."""
+        if self.known_hosts == os.path.expanduser(self.default_known_hosts):
+            return os.path.isfile(self.known_hosts)
+        return True
 
     async def _get_conn(self) -> asyncssh.SSHClientConnection:
         """
@@ -963,8 +989,8 @@ class SFTPHookAsync(BaseHook):
         - passphrase
         """
         conn = await get_async_connection(self.sftp_conn_id)
-        if conn.extra is not None:
-            self._parse_extras(conn)  # type: ignore[arg-type]
+        # Parsed even without extras: a constructor `no_host_key_check` still 
has to be applied.
+        self._parse_extras(conn)  # type: ignore[arg-type]
 
         def _get_value(self_val, conn_val, default=None):
             """Return the first non-None value among self, conn, default."""
@@ -985,7 +1011,7 @@ class SFTPHookAsync(BaseHook):
         if self.known_hosts:
             if self.known_hosts.lower() == "none":
                 conn_config.update(known_hosts=None)
-            else:
+            elif self._should_use_known_hosts():
                 conn_config.update(known_hosts=self.known_hosts)
         if self.private_key:
             _private_key = asyncssh.import_private_key(self.private_key, 
self.passphrase)
diff --git a/providers/sftp/src/airflow/providers/sftp/operators/sftp.py 
b/providers/sftp/src/airflow/providers/sftp/operators/sftp.py
index 249628d7a30..aabef230ea3 100644
--- a/providers/sftp/src/airflow/providers/sftp/operators/sftp.py
+++ b/providers/sftp/src/airflow/providers/sftp/operators/sftp.py
@@ -189,6 +189,7 @@ class SFTPOperator(BaseOperator):
                     remote_host=self.remote_host,
                     concurrency=self.concurrency,
                     prefetch=self.prefetch,
+                    no_host_key_check=self.sftp_hook.no_host_key_check,
                 ),
                 method_name="execute_complete",
             )
diff --git a/providers/sftp/src/airflow/providers/sftp/triggers/sftp.py 
b/providers/sftp/src/airflow/providers/sftp/triggers/sftp.py
index 16c894dd3b1..e9d69d94801 100644
--- a/providers/sftp/src/airflow/providers/sftp/triggers/sftp.py
+++ b/providers/sftp/src/airflow/providers/sftp/triggers/sftp.py
@@ -33,13 +33,23 @@ from airflow.triggers.base import BaseTrigger, TriggerEvent
 class BaseSFTPTrigger(BaseTrigger):
     """Base class for SFTP triggers, providing shared async hook 
construction."""
 
-    def __init__(self, sftp_conn_id: str = "sftp_default", remote_host: str | 
None = None) -> None:
+    def __init__(
+        self,
+        sftp_conn_id: str = "sftp_default",
+        remote_host: str | None = None,
+        no_host_key_check: bool | None = None,
+    ) -> None:
         super().__init__()
         self.sftp_conn_id = sftp_conn_id
         self.remote_host = remote_host
+        self.no_host_key_check = no_host_key_check
 
     def _get_async_hook(self) -> SFTPHookAsync:
-        return SFTPHookAsync(sftp_conn_id=self.sftp_conn_id, 
host=self.remote_host)
+        return SFTPHookAsync(
+            sftp_conn_id=self.sftp_conn_id,
+            host=self.remote_host,
+            no_host_key_check=self.no_host_key_check,
+        )
 
 
 class SFTPTrigger(BaseSFTPTrigger):
@@ -150,6 +160,8 @@ class SFTPTransferTrigger(BaseSFTPTrigger):
     :param remote_host: Remote host to connect to (overrides connection).
     :param concurrency: Number of threads for directory transfers.
     :param prefetch: Whether to prefetch during file retrieval.
+    :param no_host_key_check: Host key verification setting of the operator's 
hook, so that a
+        constructor override on that hook survives deferral. ``None`` defers 
to the connection.
     """
 
     def __init__(
@@ -163,8 +175,11 @@ class SFTPTransferTrigger(BaseSFTPTrigger):
         remote_host: str | None = None,
         concurrency: int = 1,
         prefetch: bool = True,
+        no_host_key_check: bool | None = None,
     ) -> None:
-        super().__init__(sftp_conn_id=sftp_conn_id, remote_host=remote_host)
+        super().__init__(
+            sftp_conn_id=sftp_conn_id, remote_host=remote_host, 
no_host_key_check=no_host_key_check
+        )
         self.local_filepath = local_filepath
         self.remote_filepath = remote_filepath
         self.operation = operation
@@ -187,6 +202,7 @@ class SFTPTransferTrigger(BaseSFTPTrigger):
                 "remote_host": self.remote_host,
                 "concurrency": self.concurrency,
                 "prefetch": self.prefetch,
+                "no_host_key_check": self.no_host_key_check,
             },
         )
 
diff --git a/providers/sftp/tests/unit/sftp/hooks/test_sftp.py 
b/providers/sftp/tests/unit/sftp/hooks/test_sftp.py
index c6cdf214915..34a7dfdc75d 100644
--- a/providers/sftp/tests/unit/sftp/hooks/test_sftp.py
+++ b/providers/sftp/tests/unit/sftp/hooks/test_sftp.py
@@ -22,6 +22,7 @@ import json
 import os
 import shutil
 import stat
+from contextlib import nullcontext
 from io import BytesIO, StringIO
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, call, patch
@@ -33,6 +34,7 @@ from asyncssh.sftp import SFTPName
 from paramiko.client import SSHClient
 from paramiko.sftp_client import SFTPClient
 
+from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.models import Connection
 from airflow.providers.common.compat.sdk import AirflowException
 from airflow.providers.sftp.hooks.sftp import CHUNK_SIZE, SFTPHook, 
SFTPHookAsync, SFTPOperation
@@ -97,7 +99,7 @@ class TestSFTPHook:
         """Define default connection during tests and create directory 
structure."""
         temp_dir = tmp_path_factory.mktemp("sftp-temp")
         self.old_login = self.update_connection(SFTP_CONNECTION_USER)
-        self.hook = SFTPHook()
+        self.hook = SFTPHook(no_host_key_check=True)
         os.makedirs(os.path.join(temp_dir, TMP_DIR_FOR_TESTS, SUB_DIR))
 
         for file_name in [TMP_FILE_FOR_TESTS, ANOTHER_FILE_FOR_TESTS, 
LOG_FILE_FOR_TESTS]:
@@ -357,7 +359,7 @@ class TestSFTPHook:
         connection = Connection(login="login", host="host")
         get_connection.return_value = connection
         hook = SFTPHook()
-        assert hook.no_host_key_check is True
+        assert hook.no_host_key_check is False
 
     @patch("airflow.providers.sftp.hooks.sftp.SFTPHook.get_connection")
     def test_no_host_key_check_enabled(self, get_connection):
@@ -393,10 +395,12 @@ class TestSFTPHook:
 
     @patch("airflow.providers.sftp.hooks.sftp.SFTPHook.get_connection")
     def test_no_host_key_check_ignore(self, get_connection):
+        """``ignore_hostkey_verification`` is a deprecated alias for 
``no_host_key_check``."""
         connection = Connection(login="login", host="host", 
extra='{"ignore_hostkey_verification": true}')
 
         get_connection.return_value = connection
-        hook = SFTPHook()
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="ignore_hostkey_verification"):
+            hook = SFTPHook()
         assert hook.no_host_key_check is True
 
     @patch("airflow.providers.sftp.hooks.sftp.SFTPHook.get_connection")
@@ -726,6 +730,41 @@ class TestSFTPHook:
             )
             assert mock_build.call_count == workers
 
+    def 
test_concurrent_transfer_passes_effective_host_key_policy_to_worker(self, 
tmp_path):
+        connection = Connection(
+            conn_id="sftp_default",
+            conn_type="sftp",
+            host="connection.example.com",
+            login="user",
+            extra=json.dumps({"host_key": f"ssh-rsa {TEST_HOST_KEY}", 
"no_host_key_check": True}),
+        )
+        built_hooks = []
+        original_build = SFTPHook._build_worker_hook
+
+        def spy_build(hook_self):
+            worker_hook = original_build(hook_self)
+            built_hooks.append(worker_hook)
+            return worker_hook
+
+        with (
+            patch("airflow.providers.sftp.hooks.sftp.SFTPHook.get_connection", 
return_value=connection),
+            patch.object(SFTPHook, "get_managed_conn", 
return_value=nullcontext()),
+            patch.object(SFTPHook, "path_exists", return_value=False),
+            patch.object(SFTPHook, "create_directory"),
+            patch.object(SFTPHook, "get_conn", 
return_value=MagicMock(spec=SFTPClient)),
+            patch.object(SFTPHook, "_build_worker_hook", autospec=True, 
side_effect=spy_build) as mock_build,
+        ):
+            parent_hook = SFTPHook(ssh_conn_id="sftp_default", 
no_host_key_check=False)
+            parent_hook.store_directory_concurrently(
+                remote_full_path="/remote/target",
+                local_full_path=str(tmp_path),
+                workers=1,
+            )
+
+        mock_build.assert_called_once()
+        assert parent_hook.no_host_key_check is False
+        assert built_hooks[0].no_host_key_check is False
+
     def test_validate_within_directory_rejects_escape(self):
         base = os.path.join(self.temp_dir, "download")
         with pytest.raises(ValueError, match="outside the destination 
directory"):
@@ -822,6 +861,9 @@ class MockSSHClient:
         return MockSFTPClient()
 
 
+DEFAULT_KNOWN_HOSTS_PATH = os.path.expanduser("~/.ssh/known_hosts")
+
+
 class MockAirflowConnection:
     def __init__(self, known_hosts="~/.ssh/known_hosts"):
         self.host = "localhost"
@@ -942,13 +984,16 @@ class TestSFTPHookAsync:
                 22,
                 "ssh-ed25519 
AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY user@host",
             ),
-            (2222, 
"AAAAC3NzaC1lZDI1NTE5AAAAIFe8P8lk5HFfL/rMlcCMHQhw1cg+uZtlK5rXQk2C4pOY"),
+            (2222, TEST_HOST_KEY),
             (
                 2222,
                 "ecdsa-sha2-nistp256 
AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDDsXFe87LsBA1Hfi+mtw"
                 
"/EoQkv8bXVtfOwdMP1ETpHVsYpm5QG/7tsLlKdE8h6EoV/OFw7XQtoibNZp/l5ABjE=",
             ),
         ],
+        # TEST_HOST_KEY is generated at import time, so explicit ids keep 
collection
+        # identical across pytest-xdist workers.
+        ids=["ed25519", "ed25519-with-comment", "bare-rsa", "ecdsa"],
     )
     @patch("asyncssh.connect", new_callable=AsyncMock)
     @patch("asyncssh.import_private_key")
@@ -974,9 +1019,77 @@ class TestSFTPHookAsync:
         await hook._get_conn()
 
         host_key_parts = mock_host_key.split()
-        expected_host_key = " ".join(host_key_parts[:2]) if 
len(host_key_parts) >= 2 else mock_host_key
+        # A bare key is RSA; asyncssh does not accept the two-field `host key` 
form.
+        expected_host_key = (
+            " ".join(host_key_parts[:2]) if len(host_key_parts) >= 2 else 
f"ssh-rsa {mock_host_key}"
+        )
         assert hook.known_hosts == f"localhost {expected_host_key}".encode()
 
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def test_no_host_key_check_defaults_to_false(self, 
mock_get_connection, mock_connect):
+        """A connection that omits ``no_host_key_check`` keeps host key 
verification enabled."""
+
+        class MockAirflowConnectionWithoutHostKeyExtras:
+            host = "localhost"
+            port = 22
+            login = "username"
+            password = "password"
+            extra = "{}"
+            extra_dejson: dict = {}
+
+        mock_get_connection.return_value = 
MockAirflowConnectionWithoutHostKeyExtras()
+
+        hook = SFTPHookAsync()
+        await hook._get_conn()
+
+        assert hook.known_hosts != "none"
+        assert str(hook.known_hosts).endswith(".ssh/known_hosts")
+
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def test_parse_extras_honours_deprecated_alias(self, 
mock_get_connection, mock_connect):
+        """``ignore_hostkey_verification`` keeps working on the async path 
too."""
+
+        class MockAirflowConnectionWithAlias:
+            host = "localhost"
+            port = 22
+            login = "username"
+            password = "password"
+            extra = '{"ignore_hostkey_verification": true}'
+            extra_dejson = {"ignore_hostkey_verification": True}
+
+        mock_get_connection.return_value = MockAirflowConnectionWithAlias()
+
+        hook = SFTPHookAsync()
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="ignore_hostkey_verification"):
+            await hook._get_conn()
+
+        assert hook.known_hosts == "none"
+
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def test_parse_extras_canonical_key_wins_over_alias(self, 
mock_get_connection, mock_connect):
+        """``no_host_key_check`` takes precedence over the deprecated alias."""
+
+        class MockAirflowConnectionWithBothKeys:
+            host = "localhost"
+            port = 22
+            login = "username"
+            password = "password"
+            extra = '{"no_host_key_check": false, 
"ignore_hostkey_verification": true}'
+            extra_dejson = {"no_host_key_check": False, 
"ignore_hostkey_verification": True}
+
+        mock_get_connection.return_value = MockAirflowConnectionWithBothKeys()
+
+        hook = SFTPHookAsync()
+        await hook._get_conn()
+
+        assert hook.known_hosts != "none"
+
     @patch("asyncssh.connect", new_callable=AsyncMock)
     @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
     @pytest.mark.asyncio
@@ -1009,6 +1122,74 @@ class TestSFTPHookAsync:
         with pytest.raises(ValueError, match="Host key check was skipped, but 
`host_key` value was given"):
             await hook._get_conn()
 
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def test_constructor_no_host_key_check_applies_without_extras(
+        self, mock_get_connection, mock_connect
+    ):
+        """The constructor opt-out applies even when the connection has no 
extras at all."""
+        mock_get_connection.return_value = Connection(
+            conn_id="sftp_default", conn_type="sftp", host="localhost", 
login="username"
+        )
+
+        hook = SFTPHookAsync(no_host_key_check=True)
+        await hook._get_conn()
+
+        assert mock_connect.call_args.kwargs["known_hosts"] is None
+
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("asyncssh.import_private_key")
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def 
test_constructor_no_host_key_check_false_resolves_conflicting_extras(
+        self, mock_get_connection, mock_import_private_key, mock_connect
+    ):
+        """``no_host_key_check=False`` wins over the extra, so a connection's 
`host_key` is used."""
+        mock_get_connection.return_value = MockAirflowConnectionWithHostKey(
+            host_key=TEST_HOST_KEY, no_host_key_check=True
+        )
+
+        hook = SFTPHookAsync(no_host_key_check=False)
+        await hook._get_conn()
+
+        assert hook.known_hosts == f"localhost ssh-rsa 
{TEST_HOST_KEY}".encode()
+
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def test_constructor_no_host_key_check_true_with_host_key_raises(
+        self, mock_get_connection, mock_connect
+    ):
+        mock_get_connection.return_value = MockAirflowConnectionWithHostKey(
+            host_key=TEST_HOST_KEY, no_host_key_check=False
+        )
+
+        hook = SFTPHookAsync(no_host_key_check=True)
+        with pytest.raises(ValueError, match="Host key check was skipped, but 
`host_key` value was given"):
+            await hook._get_conn()
+
+    @patch("asyncssh.connect", new_callable=AsyncMock)
+    @patch("asyncssh.import_private_key")
+    @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @pytest.mark.asyncio
+    async def test_host_key_entry_uses_host_override(
+        self, mock_get_connection, mock_import_private_key, mock_connect
+    ):
+        """The known_hosts entry names the host actually connected to, not the 
connection's host."""
+        mock_get_connection.return_value = MockAirflowConnectionWithHostKey(
+            host_key=f"ssh-rsa {TEST_HOST_KEY}", no_host_key_check=False
+        )
+
+        hook = SFTPHookAsync(host="override.example")
+        await hook._get_conn()
+
+        assert mock_connect.call_args.kwargs["host"] == "override.example"
+        assert (
+            mock_connect.call_args.kwargs["known_hosts"]
+            == f"override.example ssh-rsa {TEST_HOST_KEY}".encode()
+        )
+
     @patch("paramiko.SSHClient.connect")
     @patch("asyncssh.import_private_key")
     @patch("asyncssh.connect", new_callable=AsyncMock)
@@ -1044,7 +1225,7 @@ class TestSFTPHookAsync:
             "username": "username",
             "password": "password",
             "client_keys": "~/keys/my_key",
-            "known_hosts": None,
+            "known_hosts": "~/.ssh/known_hosts",
             "passphrase": "mypassphrase",
         }
 
@@ -1073,16 +1254,16 @@ class TestSFTPHookAsync:
             "username": "username",
             "password": "password",
             "client_keys": ["test"],
-            "known_hosts": None,
             "passphrase": "mypassphrase",
         }
 
         mock_connect.assert_called_with(**expected_connection_details)
 
     @pytest.mark.asyncio
+    @patch("airflow.providers.sftp.hooks.sftp.os.path.isfile", 
return_value=False)
     @patch("asyncssh.connect", new_callable=AsyncMock)
     @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
-    async def test_connection_port_default_to_22(self, mock_get_connection, 
mock_connect):
+    async def test_connection_port_default_to_22(self, mock_get_connection, 
mock_connect, mock_isfile):
         from unittest.mock import Mock, call
 
         mock_get_connection.return_value = Mock(
@@ -1104,14 +1285,15 @@ class TestSFTPHookAsync:
                 port=22,
                 username="username",
                 password="password",
-                known_hosts=None,
             ),
         ]
+        mock_isfile.assert_called_once_with(DEFAULT_KNOWN_HOSTS_PATH)
 
     @pytest.mark.asyncio
+    @patch("airflow.providers.sftp.hooks.sftp.os.path.isfile", 
return_value=True)
     @patch("asyncssh.connect", new_callable=AsyncMock)
     @patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
-    async def test_init_argument_not_ignored(self, mock_get_connection, 
mock_connect):
+    async def test_init_argument_not_ignored(self, mock_get_connection, 
mock_connect, mock_isfile):
         from unittest.mock import Mock, call
 
         mock_get_connection.return_value = Mock(
@@ -1136,9 +1318,10 @@ class TestSFTPHookAsync:
                 port=25,
                 username="username-from-init",
                 password="password-from-init",
-                known_hosts=None,
+                known_hosts=DEFAULT_KNOWN_HOSTS_PATH,
             ),
         ]
+        mock_isfile.assert_called_once_with(DEFAULT_KNOWN_HOSTS_PATH)
 
     @pytest.mark.asyncio
     async def test_list_directory_path_does_not_exist(self, sftp_hook_mocked):
diff --git a/providers/sftp/tests/unit/sftp/operators/test_sftp.py 
b/providers/sftp/tests/unit/sftp/operators/test_sftp.py
index 60bec348499..bb58b4b7f17 100644
--- a/providers/sftp/tests/unit/sftp/operators/test_sftp.py
+++ b/providers/sftp/tests/unit/sftp/operators/test_sftp.py
@@ -740,6 +740,31 @@ class TestSFTPOperatorDeferrable:
             operator.execute(context={})
         assert exc.value.trigger.sftp_conn_id == "my_prod_sftp"
 
+    @mock.patch("asyncssh.connect", new_callable=mock.AsyncMock)
+    @mock.patch("airflow.providers.sftp.hooks.sftp.get_async_connection")
+    @mock.patch.dict("os.environ", {"AIRFLOW_CONN_MY_PROD_SFTP": 
"sftp://[email protected]"})
+    @pytest.mark.asyncio
+    async def test_sftp_operator_defer_keeps_hook_no_host_key_check(self, 
mock_get_connection, mock_connect):
+        """A host key opt-out set on the supplied hook survives deferral into 
the trigger's async hook."""
+        mock_get_connection.return_value = Connection(
+            conn_id="my_prod_sftp", conn_type="sftp", host="example.com", 
login="user"
+        )
+        operator = SFTPOperator(
+            task_id="test_sftp_defer_no_host_key_check",
+            sftp_hook=SFTPHook(ssh_conn_id="my_prod_sftp", 
no_host_key_check=True),
+            local_filepath="/tmp/test.txt",
+            remote_filepath="/remote/test.txt",
+            operation=SFTPOperation.PUT,
+            deferrable=True,
+        )
+        with pytest.raises(TaskDeferred) as exc:
+            operator.execute(context={})
+
+        trigger = exc.value.trigger
+        assert trigger.serialize()[1]["no_host_key_check"] is True
+        await trigger._get_async_hook()._get_conn()
+        assert mock_connect.call_args.kwargs["known_hosts"] is None
+
     def test_sftp_operator_defer_without_any_conn_id_raises(self):
         operator = SFTPOperator(
             task_id="test_sftp_defer_no_conn_id",
@@ -819,7 +844,9 @@ class TestSFTPTransferTrigger:
             remote_host="explicit-host.example.com",
         )
         trigger._get_async_hook()
-        mock_hook_async.assert_called_once_with(sftp_conn_id="ssh_default", 
host="explicit-host.example.com")
+        mock_hook_async.assert_called_once_with(
+            sftp_conn_id="ssh_default", host="explicit-host.example.com", 
no_host_key_check=None
+        )
 
     @mock.patch("airflow.providers.sftp.triggers.sftp.SFTPHookAsync", 
autospec=True)
     def test_get_async_hook_defaults_remote_host_to_none(self, 
mock_hook_async):
@@ -831,7 +858,7 @@ class TestSFTPTransferTrigger:
             operation="put",
         )
         trigger._get_async_hook()
-        mock_hook_async.assert_called_once_with(sftp_conn_id="ssh_default", 
host=None)
+        mock_hook_async.assert_called_once_with(sftp_conn_id="ssh_default", 
host=None, no_host_key_check=None)
 
     def test_run_success(self):
         """Test run() yields TriggerEvent with status success."""
diff --git a/providers/ssh/docs/changelog.rst b/providers/ssh/docs/changelog.rst
index fbba5ea1875..5efffb45d7d 100644
--- a/providers/ssh/docs/changelog.rst
+++ b/providers/ssh/docs/changelog.rst
@@ -27,6 +27,21 @@
 Changelog
 ---------
 
+.. warning::
+  The ``no_host_key_check`` connection extra now defaults to ``false``. A 
connection to a host that has
+  no entry in the known hosts file is refused unless host key verification is 
disabled explicitly or a
+  ``host_key`` is supplied in the connection's extra field.
+
+  Deployments that relied on the previous default can keep the earlier 
behaviour by adding the host key
+  to the known hosts file, supplying ``host_key`` on the connection, setting 
the ``no_host_key_check``
+  connection extra to ``true``, or -- when building ``SSHHook``/``SFTPHook`` 
directly rather than from a
+  connection -- passing the new ``no_host_key_check=True`` constructor 
argument. The constructor argument
+  takes precedence over the connection extra.
+
+  The previously undocumented ``ignore_hostkey_verification`` extra is now 
honoured as a deprecated alias
+  for ``no_host_key_check`` and emits a ``DeprecationWarning``. It had no 
effect before: connections that
+  set it were relying on the old permissive default rather than on the setting 
itself.
+
 6.1.0
 .....
 
diff --git a/providers/ssh/docs/connections/ssh.rst 
b/providers/ssh/docs/connections/ssh.rst
index 9ca7ce0dedf..9cfa3f88cdd 100644
--- a/providers/ssh/docs/connections/ssh.rst
+++ b/providers/ssh/docs/connections/ssh.rst
@@ -51,7 +51,7 @@ Extra (optional)
     * ``timeout`` - Deprecated - use conn_timeout instead.
     * ``cmd_timeout`` - Timeout (in seconds) for executing the command. The 
default is 10 seconds. `null` value means no timeout.
     * ``compress`` - ``true`` to ask the remote client/server to compress 
traffic; ``false`` to refuse compression. Default is ``true``.
-    * ``no_host_key_check`` - Set to ``false`` to restrict connecting to hosts 
with no entries in ``~/.ssh/known_hosts`` (Hosts file). This provides maximum 
protection against trojan horse attacks, but can be troublesome when the 
``/etc/ssh/ssh_known_hosts`` file is poorly maintained or connections to new 
hosts are frequently made. This option forces the user to manually add all new 
hosts. Default is ``true``, ssh will automatically add new host keys to the 
user known hosts files.
+    * ``no_host_key_check`` - Set to ``true`` to connect to hosts that have no 
entry in ``~/.ssh/known_hosts`` (Hosts file), accepting unknown host keys 
without requiring a known-hosts entry. Default is ``false``, which restricts 
connecting to hosts already present in the known hosts file. The default 
provides maximum protection against trojan horse attacks, but can be 
troublesome when the ``/etc/ssh/ssh_known_hosts`` file is poorly maintained or 
connections to new hosts are frequently m [...]
     * ``allow_host_key_change`` - Set to ``true`` if you want to allow 
connecting to hosts that has host key changed or when you get 'REMOTE HOST 
IDENTIFICATION HAS CHANGED' error.  This won't protect against 
Man-In-The-Middle attacks. Other possible solution is to remove the host entry 
from ``~/.ssh/known_hosts`` file. Default is ``false``.
     * ``look_for_keys`` - Set to ``false`` if you want to disable searching 
for discoverable private key files in ``~/.ssh/``
     * ``host_key`` - The base64 encoded ssh-rsa public key of the host or 
``"<key type> <key data>"`` (as you would find in the ``known_hosts`` file). 
Specifying this allows making the connection if and only if the public key of 
the endpoint matches this value. Supported key type strings are ``ssh-rsa``, 
``ecdsa-sha2-nistp256``, ``ecdsa-sha2-nistp384``, ``ecdsa-sha2-nistp521``, and 
``ssh-ed25519``; the legacy ``ssh-ecdsa`` form is also accepted for 
compatibility. DSA/DSS (``ssh-dss``) ho [...]
diff --git a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py 
b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
index 38f68d43131..cbfad4a1ecc 100644
--- a/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
+++ b/providers/ssh/src/airflow/providers/ssh/hooks/ssh.py
@@ -21,6 +21,7 @@ from __future__ import annotations
 
 import os
 import selectors
+import warnings
 from base64 import decodebytes
 from collections.abc import Sequence
 from functools import cached_property
@@ -31,6 +32,7 @@ import paramiko
 from paramiko.config import SSH_PORT
 from tenacity import Retrying, stop_after_attempt, wait_fixed, wait_random
 
+from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.providers.common.compat.connection import get_async_connection
 from airflow.providers.common.compat.sdk import AirflowException, BaseHook
 from airflow.providers.ssh.tunnel import AsyncSSHTunnel, SSHTunnel
@@ -92,6 +94,9 @@ class SSHHook(BaseHook):
         lifetime of the transport
     :param ciphers: list of ciphers to use in order of preference
     :param auth_timeout: timeout (in seconds) for the attempt to authenticate 
with the remote_host
+    :param no_host_key_check: Set to ``True`` to skip host key verification. 
Overrides the
+        connection's ``no_host_key_check`` extra. Defaults to ``None``, 
meaning the value is
+        taken from the connection, or ``False`` when the connection does not 
set it.
     :param conn_retry_attempts: number of times to attempt the initial SSH 
connection before
         giving up (default 3). Raising this helps when many tasks target the 
same SSH server at
         once and some connections are transiently refused (e.g. ``sshd`` 
``MaxStartups`` throttling).
@@ -146,6 +151,7 @@ class SSHHook(BaseHook):
         auth_timeout: int | None = None,
         host_proxy_cmd: str | None = None,
         conn_retry_attempts: int = 3,
+        no_host_key_check: bool | None = None,
     ) -> None:
         super().__init__()
         self.ssh_conn_id = ssh_conn_id
@@ -167,11 +173,20 @@ class SSHHook(BaseHook):
 
         # Default values, overridable from Connection
         self.compress = True
-        self.no_host_key_check = True
+        self.no_host_key_check = False
         self.allow_host_key_change = False
         self.host_key = None
         self.look_for_keys = True
 
+        # Captured before parsing the connection extras, which rebind the 
`no_host_key_check`
+        # name below. Without a separate binding the caller's value is 
silently discarded
+        # whenever the connection has extras that do not mention host key 
checking.
+        constructor_no_host_key_check = no_host_key_check
+
+        # Parsing a `host_key` extra forces `self.no_host_key_check` to False, 
so what the
+        # connection actually asked for is kept here and re-applied once 
parsing is done.
+        extra_no_host_key_check: bool | None = None
+
         # Placeholder for future cached connection
         self.client: paramiko.SSHClient | None = None
 
@@ -213,12 +228,18 @@ class SSHHook(BaseHook):
                 host_key = extra_options.get("host_key")
                 no_host_key_check = extra_options.get("no_host_key_check")
 
-                if no_host_key_check is not None:
-                    no_host_key_check = str(no_host_key_check).lower() == 
"true"
-                    if host_key is not None and no_host_key_check:
-                        raise ValueError("Must check host key when provided")
+                if no_host_key_check is None and "ignore_hostkey_verification" 
in extra_options:
+                    warnings.warn(
+                        "The `ignore_hostkey_verification` connection extra is 
deprecated; "
+                        "use `no_host_key_check` instead.",
+                        AirflowProviderDeprecationWarning,
+                        stacklevel=2,
+                    )
+                    no_host_key_check = 
extra_options["ignore_hostkey_verification"]
 
-                    self.no_host_key_check = no_host_key_check
+                if no_host_key_check is not None:
+                    extra_no_host_key_check = str(no_host_key_check).lower() 
== "true"
+                    self.no_host_key_check = extra_no_host_key_check
 
                 if (
                     "allow_host_key_change" in extra_options
@@ -262,6 +283,21 @@ class SSHHook(BaseHook):
                     self.host_key = key_constructor(data=decoded_host_key)
                     self.no_host_key_check = False
 
+        # An explicit constructor argument wins over the connection extra and 
the default.
+        # Without this there is no way to opt out of host key verification 
when the hook is
+        # built directly rather than from a Connection.
+        if constructor_no_host_key_check is not None:
+            self.no_host_key_check = constructor_no_host_key_check
+        elif extra_no_host_key_check is not None:
+            self.no_host_key_check = extra_no_host_key_check
+
+        # Validated on the effective value rather than on the extras alone, so 
that an explicit
+        # constructor argument can resolve a connection that sets both 
`host_key` and
+        # `no_host_key_check`, and so that skipping the check while a host key 
is configured is
+        # rejected whichever source asked for it.
+        if self.host_key is not None and self.no_host_key_check:
+            raise ValueError("Must check host key when provided")
+
         if self.cmd_timeout is NOTSET:
             self.cmd_timeout = CMD_TIMEOUT
 
@@ -625,7 +661,15 @@ class SSHHookAsync(BaseHook):
 
         host_key = extra_options.get("host_key")
         nhkc_raw = extra_options.get("no_host_key_check")
-        no_host_key_check = str(nhkc_raw).lower() == "true" if nhkc_raw is not 
None else True
+        if nhkc_raw is None and "ignore_hostkey_verification" in extra_options:
+            warnings.warn(
+                "The `ignore_hostkey_verification` connection extra is 
deprecated; "
+                "use `no_host_key_check` instead.",
+                AirflowProviderDeprecationWarning,
+                stacklevel=2,
+            )
+            nhkc_raw = extra_options["ignore_hostkey_verification"]
+        no_host_key_check = str(nhkc_raw).lower() == "true" if nhkc_raw is not 
None else False
 
         if host_key is not None and no_host_key_check:
             raise ValueError("Host key check was skipped, but `host_key` value 
was given")
@@ -643,7 +687,16 @@ class SSHHookAsync(BaseHook):
                 )
             if len(host_key_parts) >= 2:
                 host_key = " ".join(host_key_parts[:2])
-            self.known_hosts = f"{conn.host} {host_key}".encode()
+            else:
+                # A bare key is RSA, as on the sync hook; asyncssh needs the 
type spelled out.
+                host_key = f"ssh-rsa {host_key}"
+            self.known_hosts = f"{self.host or conn.host} {host_key}".encode()
+
+    def _should_use_known_hosts(self) -> bool:
+        """Leave a missing default file unset so AsyncSSH reports an untrusted 
host."""
+        if self.known_hosts == os.path.expanduser(self.default_known_hosts):
+            return os.path.isfile(self.known_hosts)
+        return True
 
     async def _get_conn(self):
         """
@@ -675,7 +728,7 @@ class SSHHookAsync(BaseHook):
         if self.known_hosts:
             if isinstance(self.known_hosts, str) and self.known_hosts.lower() 
== "none":
                 conn_config["known_hosts"] = None
-            else:
+            elif self._should_use_known_hosts():
                 conn_config["known_hosts"] = self.known_hosts
         if self.private_key:
             _private_key = asyncssh.import_private_key(self.private_key, 
self.passphrase)
diff --git a/providers/ssh/tests/unit/ssh/hooks/test_ssh.py 
b/providers/ssh/tests/unit/ssh/hooks/test_ssh.py
index bc8fa2ae4eb..14c734daa25 100644
--- a/providers/ssh/tests/unit/ssh/hooks/test_ssh.py
+++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh.py
@@ -567,25 +567,25 @@ class TestSSHHook:
             )
 
     def test_ssh_connection(self):
-        hook = SSHHook(ssh_conn_id="ssh_default")
+        hook = SSHHook(ssh_conn_id="ssh_default", no_host_key_check=True)
         with hook.get_conn() as client:
             (_, stdout, _) = client.exec_command("ls")
             assert stdout.read() is not None
 
     def test_ssh_connection_no_connection_id(self):
-        hook = SSHHook(remote_host="localhost")
+        hook = SSHHook(remote_host="localhost", no_host_key_check=True)
         assert hook.ssh_conn_id is None
         with hook.get_conn() as client:
             (_, stdout, _) = client.exec_command("ls")
             assert stdout.read() is not None
 
     def test_ssh_connection_old_cm(self):
-        with SSHHook(ssh_conn_id="ssh_default").get_conn() as client:
+        with SSHHook(ssh_conn_id="ssh_default", 
no_host_key_check=True).get_conn() as client:
             (_, stdout, _) = client.exec_command("ls")
             assert stdout.read() is not None
 
     def test_tunnel(self):
-        hook = SSHHook(ssh_conn_id="ssh_default")
+        hook = SSHHook(ssh_conn_id="ssh_default", no_host_key_check=True)
 
         import socket
         import subprocess
@@ -798,6 +798,54 @@ class TestSSHHook:
             assert ssh_client.return_value.connect.called is True
             assert ssh_client.return_value.set_missing_host_key_policy.called 
is True
 
+    def test_constructor_no_host_key_check_overrides_unrelated_extras(self):
+        """An explicit constructor value survives a connection whose extras 
omit host key settings."""
+        hook = SSHHook(ssh_conn_id=self.CONN_SSH_WITH_PRIVATE_KEY_EXTRA, 
no_host_key_check=True)
+        assert hook.no_host_key_check is True
+
+    @mock.patch("airflow.providers.ssh.hooks.ssh.SSHHook.get_connection")
+    def test_constructor_no_host_key_check_overrides_empty_extras(self, 
get_connection):
+        """An empty ``extra`` must not discard the constructor value."""
+        get_connection.return_value = Connection(
+            conn_id="ssh_empty_extra", host="localhost", conn_type="ssh", 
extra="{}"
+        )
+        hook = SSHHook(ssh_conn_id="ssh_empty_extra", no_host_key_check=True)
+        assert hook.no_host_key_check is True
+
+    def test_constructor_no_host_key_check_overrides_connection_extra(self):
+        """The constructor wins over a connection extra that sets the opposite 
value."""
+        hook = SSHHook(ssh_conn_id=self.CONN_SSH_WITH_EXTRA, 
no_host_key_check=False)
+        assert hook.no_host_key_check is False
+
+    def 
test_constructor_no_host_key_check_false_resolves_conflicting_extras(self):
+        """An explicit ``False`` makes a connection setting both ``host_key`` 
and the skip flag usable."""
+        hook = SSHHook(
+            
ssh_conn_id=self.CONN_SSH_WITH_HOST_KEY_AND_NO_HOST_KEY_CHECK_TRUE, 
no_host_key_check=False
+        )
+        assert hook.no_host_key_check is False
+        assert hook.host_key is not None
+
+    def 
test_constructor_no_host_key_check_true_rejects_connection_host_key(self):
+        """Skipping the check is rejected whichever source asks for it while a 
host key is configured."""
+        with pytest.raises(ValueError, match="Must check host key when 
provided"):
+            SSHHook(
+                
ssh_conn_id=self.CONN_SSH_WITH_HOST_KEY_AND_NO_HOST_KEY_CHECK_FALSE, 
no_host_key_check=True
+            )
+
+    @mock.patch("airflow.providers.ssh.hooks.ssh.paramiko.SSHClient")
+    def test_no_host_key_check_defaults_to_false(self, ssh_client):
+        """A connection that does not set ``no_host_key_check`` still verifies 
the host key."""
+        hook = SSHHook(ssh_conn_id=self.CONN_SSH_WITH_NO_EXTRA)
+        assert hook.no_host_key_check is False
+        with hook.get_conn():
+            assert ssh_client.return_value.load_system_host_keys.called is True
+            installed = [
+                call.args[0]
+                for call in 
ssh_client.return_value.set_missing_host_key_policy.call_args_list
+                if call.args
+            ]
+            assert not any(isinstance(policy, paramiko.AutoAddPolicy) for 
policy in installed)
+
     @mock.patch("airflow.providers.ssh.hooks.ssh.paramiko.SSHClient")
     def test_conn_retry_attempts_defaults_to_three(self, ssh_client):
         hook = SSHHook(ssh_conn_id="ssh_default")
@@ -984,6 +1032,7 @@ class TestSSHHook:
     def test_exec_ssh_client_command(self):
         hook = SSHHook(
             ssh_conn_id="ssh_default",
+            no_host_key_check=True,
             conn_timeout=30,
             banner_timeout=100,
         )
@@ -1000,6 +1049,7 @@ class TestSSHHook:
     def test_command_timeout_success(self):
         hook = SSHHook(
             ssh_conn_id="ssh_default",
+            no_host_key_check=True,
             conn_timeout=30,
             cmd_timeout=2,
             banner_timeout=100,
@@ -1072,6 +1122,7 @@ class TestSSHHook:
     def test_command_timeout_not_set(self, monkeypatch):
         hook = SSHHook(
             ssh_conn_id="ssh_default",
+            no_host_key_check=True,
             conn_timeout=30,
             cmd_timeout=None,
             banner_timeout=100,
@@ -1128,7 +1179,7 @@ class TestSSHHook:
                 assert ssh_mock.return_value.load_host_keys.called is False
 
     def test_connection_success(self):
-        hook = SSHHook(ssh_conn_id="ssh_default")
+        hook = SSHHook(ssh_conn_id="ssh_default", no_host_key_check=True)
         status, msg = hook.test_connection()
         assert status is True
         assert msg == "Connection successfully tested"
@@ -1141,14 +1192,14 @@ class TestSSHHook:
         assert msg == "Test failure case"
 
     def test_ssh_connection_client_is_reused_if_open(self):
-        hook = SSHHook(ssh_conn_id="ssh_default")
+        hook = SSHHook(ssh_conn_id="ssh_default", no_host_key_check=True)
         client1 = hook.get_conn()
         client2 = hook.get_conn()
         assert client1 is client2
         assert client2.get_transport().is_active()
 
     def test_ssh_connection_client_is_recreated_if_closed(self):
-        hook = SSHHook(ssh_conn_id="ssh_default")
+        hook = SSHHook(ssh_conn_id="ssh_default", no_host_key_check=True)
         client1 = hook.get_conn()
         client1.close()
         client2 = hook.get_conn()
@@ -1156,7 +1207,7 @@ class TestSSHHook:
         assert client2.get_transport().is_active()
 
     def test_ssh_connection_client_is_recreated_if_transport_closed(self):
-        hook = SSHHook(ssh_conn_id="ssh_default")
+        hook = SSHHook(ssh_conn_id="ssh_default", no_host_key_check=True)
         client1 = hook.get_conn()
         client1.get_transport().close()
         client2 = hook.get_conn()
diff --git a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py 
b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
index dbda9f5e0ab..5598f4ee288 100644
--- a/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
+++ b/providers/ssh/tests/unit/ssh/hooks/test_ssh_async.py
@@ -17,13 +17,21 @@
 # under the License.
 from __future__ import annotations
 
+import json
+import warnings
 from unittest import mock
 
 import pytest
 
+from airflow.exceptions import AirflowProviderDeprecationWarning
+from airflow.models import Connection
 from airflow.providers.ssh.hooks.ssh import SSHHookAsync
 
 
+def _connection(extra: dict, host: str = "test.host") -> Connection:
+    return Connection(conn_id="test_conn", conn_type="ssh", host=host, 
extra=json.dumps(extra))
+
+
 class TestSSHHookAsync:
     def test_init_with_conn_id(self):
         """Test initialization with connection ID."""
@@ -71,6 +79,44 @@ class TestSSHHookAsync:
         hook._parse_extras(mock_conn)
         assert hook.known_hosts == "none"
 
+    def test_parse_extras_no_host_key_check_defaults_to_false(self):
+        """A connection that omits ``no_host_key_check`` keeps host key 
verification enabled."""
+        hook = SSHHookAsync(ssh_conn_id="test_conn")
+
+        hook._parse_extras(_connection({}))
+        assert hook.known_hosts != "none"
+
+    def test_parse_extras_honours_deprecated_alias(self):
+        """``ignore_hostkey_verification`` keeps working on the async path 
too."""
+        hook = SSHHookAsync(ssh_conn_id="test_conn")
+
+        with pytest.warns(AirflowProviderDeprecationWarning, 
match="ignore_hostkey_verification"):
+            hook._parse_extras(_connection({"ignore_hostkey_verification": 
True}))
+        assert hook.known_hosts == "none"
+
+    def test_parse_extras_canonical_key_wins_over_alias(self):
+        """``no_host_key_check`` takes precedence and suppresses the 
deprecation warning."""
+        hook = SSHHookAsync(ssh_conn_id="test_conn")
+
+        with warnings.catch_warnings():
+            warnings.simplefilter("error", AirflowProviderDeprecationWarning)
+            hook._parse_extras(_connection({"no_host_key_check": False, 
"ignore_hostkey_verification": True}))
+        assert hook.known_hosts != "none"
+
+    def test_parse_extras_host_key_uses_host_override(self):
+        """The known_hosts entry names the host actually connected to, not the 
connection's host."""
+        hook = SSHHookAsync(ssh_conn_id="test_conn", host="override.example")
+
+        hook._parse_extras(_connection({"host_key": "ssh-ed25519 AAAAC3..."}, 
host="connection.example"))
+        assert hook.known_hosts == b"override.example ssh-ed25519 AAAAC3..."
+
+    def test_parse_extras_bare_host_key_is_rsa(self):
+        """A bare base64 host key is RSA, as on the sync hook, and asyncssh 
needs the type."""
+        hook = SSHHookAsync(ssh_conn_id="test_conn")
+
+        hook._parse_extras(_connection({"host_key": "AAAAB3NzaC1yc2E"}))
+        assert hook.known_hosts == b"test.host ssh-rsa AAAAB3NzaC1yc2E"
+
     def test_parse_extras_host_key(self):
         """Test parsing host_key from connection extras."""
         hook = SSHHookAsync(ssh_conn_id="test_conn")
@@ -151,6 +197,24 @@ class TestSSHHookAsync:
                 assert call_kwargs["username"] == "testuser"
                 assert result == mock_ssh_client
 
+    @mock.patch("airflow.providers.ssh.hooks.ssh.os.path.isfile", 
return_value=False)
+    @mock.patch("asyncssh.connect", new_callable=mock.AsyncMock)
+    @mock.patch(
+        "airflow.providers.ssh.hooks.ssh.get_async_connection",
+        new_callable=mock.AsyncMock,
+    )
+    @pytest.mark.asyncio
+    async def test_get_conn_omits_missing_default_known_hosts(
+        self, mock_get_connection, mock_connect, mock_isfile
+    ):
+        mock_get_connection.return_value = Connection(host="test.host", 
extra="{}")
+
+        hook = SSHHookAsync(ssh_conn_id="test_conn")
+        await hook._get_conn()
+
+        mock_isfile.assert_called_once_with(hook.known_hosts)
+        assert "known_hosts" not in mock_connect.call_args.kwargs
+
     @pytest.mark.asyncio
     async def test_run_command(self):
         """Test running a command."""

Reply via email to