Skip to content

Commit 1400f05

Browse files
committed
[IMP] storage_backend_sftp: add security and compatibility options
- Add sftp_legacy_algorithms field to enable ssh-rsa for older servers - Add sftp_verify_hostkey and sftp_hostkey fields for host key verification - Support file paths (e.g., ~/.ssh/id_rsa) in addition to direct key content - Support bytes and file-like objects for key inputs - Add normalize_key_input() helper for flexible input handling - Add parse_hostkey() to support known_hosts format This improves security by allowing host key verification to prevent MITM attacks, and improves compatibility with legacy SFTP servers (like bank servers) that require older SSH algorithms.
1 parent 1a7787d commit 1400f05

3 files changed

Lines changed: 196 additions & 8 deletions

File tree

storage_backend_sftp/components/sftp_adapter.py

Lines changed: 159 additions & 5 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,15 +121,99 @@ 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+
54185
@contextmanager
55186
def sftp(backend):
56187
transport = paramiko.Transport((backend.sftp_server, backend.sftp_port))
188+
189+
# Configure legacy algorithms if enabled (for older servers like banks)
190+
if backend.sftp_legacy_algorithms:
191+
security_options = transport.get_security_options()
192+
if "ssh-rsa" not in security_options.key_types:
193+
security_options.key_types = ("ssh-rsa",) + tuple(
194+
security_options.key_types
195+
)
196+
197+
# Prepare hostkey verification if enabled
198+
hostkey = None
199+
if backend.sftp_verify_hostkey and backend.sftp_hostkey:
200+
hostkey = parse_hostkey(backend.sftp_hostkey, hostname=backend.sftp_server)
201+
202+
# Connect with appropriate auth method
57203
if backend.sftp_auth_method == "pwd":
58-
transport.connect(username=backend.sftp_login, password=backend.sftp_password)
204+
transport.connect(
205+
username=backend.sftp_login,
206+
password=backend.sftp_password,
207+
hostkey=hostkey,
208+
)
59209
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)
210+
# load_ssh_key handles path/content/bytes/file-object
211+
private_key = load_ssh_key(backend.sftp_ssh_private_key)
212+
transport.connect(
213+
username=backend.sftp_login,
214+
pkey=private_key,
215+
hostkey=hostkey,
216+
)
63217
client = paramiko.SFTPClient.from_transport(transport)
64218
yield client
65219
transport.close()

storage_backend_sftp/models/storage_backend.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,30 @@ 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.",
3354
)
3455

3556
@property
@@ -43,6 +64,9 @@ def _server_env_fields(self):
4364
"sftp_port": {},
4465
"sftp_auth_method": {},
4566
"sftp_ssh_private_key": {},
67+
"sftp_verify_hostkey": {},
68+
"sftp_hostkey": {},
69+
"sftp_legacy_algorithms": {},
4670
}
4771
)
4872
return env_fields

storage_backend_sftp/views/backend_storage_view.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,17 @@
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" />
2737
</group>
2838
</group>
2939
</field>

0 commit comments

Comments
 (0)