Skip to content

Commit 8aa8cca

Browse files
committed
[IMP] storage_backend_sftp: add security and compatibility options
Add several improvements to the SFTP storage backend: Security: - Add optional host key verification to prevent MITM attacks - Support for known_hosts file format or direct key content Compatibility: - Add legacy SSH algorithms support for older servers (banks, etc.) - Disable rsa-sha2-256/512 to force ssh-rsa signing when needed - Force ssh-rsa key type priority for legacy servers Usability: - Accept file paths (~/..., /path/to) in addition to key content - Support bytes and file-like objects for key inputs - Normalize all key inputs through a common helper function Debugging: - Add optional verbose logging field for detailed diagnostics - Log server version, ciphers, and key fingerprints when enabled - Show accepted auth methods on authentication failure - Separate key exchange from authentication for better error reporting
1 parent 1a7787d commit 8aa8cca

3 files changed

Lines changed: 315 additions & 11 deletions

File tree

storage_backend_sftp/components/sftp_adapter.py

Lines changed: 268 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Copyright 2020 ACSONE SA/NV (<http://acsone.eu>)
55
# @author Simone Orsi <simahawk@gmail.com>
66
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
7+
import base64
78
import errno
89
import logging
910
import os
@@ -20,6 +21,64 @@
2021
_logger.debug(err)
2122

2223

24+
def normalize_key_input(value):
25+
"""Normalize key input to string content.
26+
27+
Accepts:
28+
- str: file path or direct key content
29+
- bytes: key content as bytes
30+
- file-like object: readable object with key content
31+
32+
Returns:
33+
str: the key content
34+
"""
35+
if value is None:
36+
return None
37+
38+
# Handle file-like objects (have read method)
39+
if hasattr(value, "read"):
40+
content = value.read()
41+
if hasattr(value, "seek"):
42+
value.seek(0) # Reset for potential reuse
43+
if isinstance(content, bytes):
44+
return content.decode("utf-8")
45+
return content
46+
47+
# Handle bytes
48+
if isinstance(value, bytes):
49+
return value.decode("utf-8")
50+
51+
# Handle string (path or content)
52+
if isinstance(value, str):
53+
value = value.strip()
54+
55+
# Check if it looks like a file path (not key content)
56+
is_path = value.startswith(("/", "~", "./", "../")) or (
57+
not value.startswith("-----") # Not PEM format
58+
and not value.startswith("ssh-") # Not SSH public key
59+
and len(value) < 500 # Paths are short
60+
and "\n" not in value # Keys have newlines
61+
)
62+
63+
if is_path:
64+
expanded_path = os.path.expanduser(value)
65+
if not os.path.isabs(expanded_path):
66+
# Relative paths from home directory
67+
expanded_path = os.path.join(os.path.expanduser("~"), expanded_path)
68+
69+
if os.path.exists(expanded_path):
70+
with open(expanded_path, "r") as f:
71+
return f.read()
72+
# If path doesn't exist but looks like a path, raise error
73+
if value.startswith(("/", "~", "./", "../")):
74+
raise FileNotFoundError(f"Key file not found: {expanded_path}")
75+
76+
# It's direct content
77+
return value
78+
79+
raise TypeError(f"Unsupported key input type: {type(value)}")
80+
81+
2382
def sftp_mkdirs(client, path, mode=511):
2483
try:
2584
client.mkdir(path, mode)
@@ -31,7 +90,18 @@ def sftp_mkdirs(client, path, mode=511):
3190
raise # pragma: no cover
3291

3392

34-
def load_ssh_key(ssh_key_buffer):
93+
def load_ssh_key(ssh_key_input):
94+
"""Load SSH private key from various input types.
95+
96+
Args:
97+
ssh_key_input: str (path or content), bytes, or file-like object
98+
99+
Returns:
100+
paramiko private key object
101+
"""
102+
key_content = normalize_key_input(ssh_key_input)
103+
ssh_key_buffer = StringIO(key_content)
104+
35105
# Build list of supported key classes.
36106
# Conditionally including DSSKey for backward compatibility with older
37107
# versions of paramiko
@@ -51,18 +121,208 @@ def load_ssh_key(ssh_key_buffer):
51121
raise Exception("Invalid ssh private key")
52122

53123

124+
def parse_hostkey(hostkey_input, hostname=None):
125+
"""Parse a host key from various input types.
126+
127+
Args:
128+
hostkey_input: str (path or content), bytes, or file-like object
129+
hostname: If provided, search for this host in known_hosts format
130+
131+
Returns:
132+
paramiko key object
133+
"""
134+
hostkey_str = normalize_key_input(hostkey_input)
135+
if not hostkey_str:
136+
return None
137+
138+
lines = hostkey_str.strip().split("\n")
139+
140+
for line in lines:
141+
line = line.strip()
142+
if not line or line.startswith("#"):
143+
continue
144+
145+
parts = line.split()
146+
147+
# known_hosts format: hostname key-type key-data [comment]
148+
# direct format: key-type key-data [comment]
149+
if len(parts) >= 3 and not parts[0].startswith("ssh-"):
150+
# known_hosts format
151+
host_field, key_type, key_data = parts[0], parts[1], parts[2]
152+
# Check if hostname matches (supports comma-separated hosts)
153+
if hostname:
154+
hosts = host_field.split(",")
155+
if not any(
156+
h == hostname or h.startswith(f"[{hostname}]") for h in hosts
157+
):
158+
continue
159+
elif len(parts) >= 2:
160+
# direct format: key-type key-data
161+
key_type, key_data = parts[0], parts[1]
162+
else:
163+
continue
164+
165+
try:
166+
key_bytes = base64.b64decode(key_data)
167+
except Exception:
168+
continue
169+
170+
try:
171+
if key_type == "ssh-rsa":
172+
return paramiko.RSAKey(data=key_bytes)
173+
elif key_type == "ssh-ed25519":
174+
return paramiko.Ed25519Key(data=key_bytes)
175+
elif key_type.startswith("ecdsa-"):
176+
return paramiko.ECDSAKey(data=key_bytes)
177+
elif key_type == "ssh-dss" and hasattr(paramiko, "DSSKey"):
178+
return paramiko.DSSKey(data=key_bytes)
179+
except paramiko.SSHException:
180+
continue
181+
182+
raise ValueError(f"No valid host key found for {hostname or 'server'}")
183+
184+
185+
def _log_verbose(backend, message, *args):
186+
"""Log message only if verbose logging is enabled."""
187+
if backend.sftp_verbose_logging:
188+
_logger.info(message, *args)
189+
190+
54191
@contextmanager
55192
def sftp(backend):
56-
transport = paramiko.Transport((backend.sftp_server, backend.sftp_port))
57-
if backend.sftp_auth_method == "pwd":
58-
transport.connect(username=backend.sftp_login, password=backend.sftp_password)
59-
elif backend.sftp_auth_method == "ssh_key":
60-
ssh_key_buffer = StringIO(backend.sftp_ssh_private_key)
61-
private_key = load_ssh_key(ssh_key_buffer)
62-
transport.connect(username=backend.sftp_login, pkey=private_key)
193+
_log_verbose(
194+
backend,
195+
"SFTP: Connecting to %s:%s as %s (auth=%s, legacy=%s, verify_hostkey=%s)",
196+
backend.sftp_server,
197+
backend.sftp_port,
198+
backend.sftp_login,
199+
backend.sftp_auth_method,
200+
backend.sftp_legacy_algorithms,
201+
backend.sftp_verify_hostkey,
202+
)
203+
204+
# Enable paramiko debug logging if verbose mode
205+
if backend.sftp_verbose_logging:
206+
logging.getLogger("paramiko").setLevel(logging.DEBUG)
207+
208+
# For legacy servers, disable newer rsa-sha2-* algorithms
209+
# so paramiko falls back to ssh-rsa (SHA-1) for signing
210+
disabled_algorithms = None
211+
if backend.sftp_legacy_algorithms:
212+
disabled_algorithms = {
213+
"pubkeys": ["rsa-sha2-256", "rsa-sha2-512"],
214+
}
215+
_log_verbose(backend, "SFTP: Disabling algorithms: %s", disabled_algorithms)
216+
217+
transport = paramiko.Transport(
218+
(backend.sftp_server, backend.sftp_port),
219+
disabled_algorithms=disabled_algorithms,
220+
)
221+
222+
# Configure legacy algorithms if enabled (for older servers like banks)
223+
if backend.sftp_legacy_algorithms:
224+
security_options = transport.get_security_options()
225+
_log_verbose(backend, "SFTP: Original key_types: %s", security_options.key_types)
226+
_log_verbose(backend, "SFTP: Original kex: %s", security_options.kex)
227+
# Force ssh-rsa at the beginning for both host key AND public key auth
228+
security_options.key_types = ("ssh-rsa",) + tuple(
229+
k for k in security_options.key_types if k != "ssh-rsa"
230+
)
231+
_log_verbose(backend, "SFTP: Modified key_types: %s", security_options.key_types)
232+
233+
# Prepare hostkey verification if enabled
234+
hostkey = None
235+
if backend.sftp_verify_hostkey and backend.sftp_hostkey:
236+
_log_verbose(backend, "SFTP: Parsing hostkey for %s", backend.sftp_server)
237+
hostkey = parse_hostkey(backend.sftp_hostkey, hostname=backend.sftp_server)
238+
_log_verbose(backend, "SFTP: Hostkey parsed: %s", type(hostkey).__name__)
239+
240+
# Start transport (key exchange) separately to inspect server capabilities
241+
try:
242+
_log_verbose(backend, "SFTP: Starting key exchange...")
243+
transport.start_client()
244+
245+
# Log server information AFTER key exchange
246+
_log_verbose(backend, "SFTP: Server version: %s", transport.remote_version)
247+
if hasattr(transport, "remote_cipher"):
248+
_log_verbose(backend, "SFTP: Remote cipher: %s", transport.remote_cipher)
249+
if hasattr(transport, "local_cipher"):
250+
_log_verbose(backend, "SFTP: Local cipher: %s", transport.local_cipher)
251+
252+
# Get the server's host key
253+
server_key = transport.get_remote_server_key()
254+
_log_verbose(
255+
backend,
256+
"SFTP: Server host key: %s (fingerprint: %s)",
257+
server_key.get_name(),
258+
server_key.get_fingerprint().hex(),
259+
)
260+
261+
# Verify hostkey if enabled
262+
if hostkey:
263+
if server_key.get_name() != hostkey.get_name():
264+
raise paramiko.SSHException(
265+
f"Host key type mismatch: expected {hostkey.get_name()}, "
266+
f"got {server_key.get_name()}"
267+
)
268+
if server_key.asbytes() != hostkey.asbytes():
269+
raise paramiko.SSHException(
270+
"Host key verification failed! "
271+
"Server key does not match expected key."
272+
)
273+
_log_verbose(backend, "SFTP: Host key verified successfully")
274+
275+
# Now authenticate
276+
if backend.sftp_auth_method == "pwd":
277+
_log_verbose(backend, "SFTP: Authenticating with password...")
278+
transport.auth_password(
279+
username=backend.sftp_login,
280+
password=backend.sftp_password,
281+
)
282+
elif backend.sftp_auth_method == "ssh_key":
283+
_log_verbose(
284+
backend,
285+
"SFTP: Loading private key from: %s",
286+
backend.sftp_ssh_private_key[:50] + "..."
287+
if len(backend.sftp_ssh_private_key or "") > 50
288+
else backend.sftp_ssh_private_key,
289+
)
290+
private_key = load_ssh_key(backend.sftp_ssh_private_key)
291+
_log_verbose(
292+
backend,
293+
"SFTP: Private key loaded: %s (fingerprint: %s)",
294+
type(private_key).__name__,
295+
private_key.get_fingerprint().hex(),
296+
)
297+
_log_verbose(backend, "SFTP: Authenticating with public key...")
298+
transport.auth_publickey(
299+
username=backend.sftp_login,
300+
key=private_key,
301+
)
302+
_log_verbose(backend, "SFTP: Authentication successful!")
303+
except paramiko.AuthenticationException as e:
304+
_logger.error("SFTP: Authentication failed: %s", e)
305+
# Try to get info about what the server accepts
306+
try:
307+
transport.auth_none(backend.sftp_login)
308+
except paramiko.BadAuthenticationType as auth_err:
309+
_logger.error(
310+
"SFTP: Server accepts auth methods: %s", auth_err.allowed_types
311+
)
312+
except Exception:
313+
pass
314+
transport.close()
315+
raise
316+
except Exception as e:
317+
_logger.error("SFTP: Connection failed: %s: %s", type(e).__name__, e)
318+
transport.close()
319+
raise
320+
63321
client = paramiko.SFTPClient.from_transport(transport)
322+
_log_verbose(backend, "SFTP: SFTP client created successfully")
64323
yield client
65324
transport.close()
325+
_log_verbose(backend, "SFTP: Connection closed")
66326

67327

68328
class SFTPStorageBackendAdapter(Component):

storage_backend_sftp/models/storage_backend.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,37 @@ class StorageBackend(models.Model):
2727
sftp_password = fields.Char(string="SFTP Password")
2828
sftp_ssh_private_key = fields.Text(
2929
string="SSH private key",
30-
help="It's recommended to not store the key here "
31-
"but to provide it via secret env variable. "
32-
"See `server_environment` docs.",
30+
help="SSH private key for authentication. Accepts:\n"
31+
"- Key content: paste the full private key\n"
32+
"- File path: '/path/to/id_rsa' or '~/.ssh/id_rsa'\n"
33+
"Note: It's recommended to use file paths or env variables "
34+
"instead of storing keys directly. See `server_environment` docs.",
35+
)
36+
sftp_verify_hostkey = fields.Boolean(
37+
string="Verify Host Key",
38+
default=False,
39+
help="Verify the server's host key against a known value. "
40+
"Recommended for security to prevent MITM attacks.",
41+
)
42+
sftp_hostkey = fields.Text(
43+
string="Server Host Key",
44+
help="Expected host key of the SFTP server. Accepts:\n"
45+
"- Key content: 'ssh-rsa AAAAB3...'\n"
46+
"- File path: '/path/to/known_hosts' or '~/.ssh/known_hosts'\n"
47+
"You can obtain the key with: ssh-keyscan -t rsa hostname",
48+
)
49+
sftp_legacy_algorithms = fields.Boolean(
50+
string="Enable Legacy SSH Algorithms",
51+
default=False,
52+
help="Enable ssh-rsa and other legacy algorithms for older SFTP servers "
53+
"that don't support modern key exchange algorithms.",
54+
)
55+
sftp_verbose_logging = fields.Boolean(
56+
string="Verbose Logging",
57+
default=False,
58+
help="Enable detailed logging of SFTP connection details including "
59+
"server capabilities, cipher negotiation, and key fingerprints. "
60+
"Useful for debugging connection issues.",
3361
)
3462

3563
@property
@@ -43,6 +71,10 @@ def _server_env_fields(self):
4371
"sftp_port": {},
4472
"sftp_auth_method": {},
4573
"sftp_ssh_private_key": {},
74+
"sftp_verify_hostkey": {},
75+
"sftp_hostkey": {},
76+
"sftp_legacy_algorithms": {},
77+
"sftp_verbose_logging": {},
4678
}
4779
)
4880
return env_fields

storage_backend_sftp/views/backend_storage_view.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,19 @@
2323
name="sftp_ssh_private_key"
2424
password="True"
2525
attrs="{'invisible': [('sftp_auth_method', '!=', 'ssh_key')]}"
26+
placeholder="Paste key content or enter path: ~/.ssh/id_rsa"
2627
/>
28+
<separator string="Security Options" />
29+
<field name="sftp_verify_hostkey" />
30+
<field
31+
name="sftp_hostkey"
32+
attrs="{'invisible': [('sftp_verify_hostkey', '=', False)]}"
33+
placeholder="ssh-rsa AAAAB3... or ~/.ssh/known_hosts"
34+
/>
35+
<separator string="Compatibility Options" />
36+
<field name="sftp_legacy_algorithms" />
37+
<separator string="Debugging" />
38+
<field name="sftp_verbose_logging" />
2739
</group>
2840
</group>
2941
</field>

0 commit comments

Comments
 (0)