Compare commits

..
Author SHA1 Message Date
christianmanivong 7ec427c743 fix: never let root read the SSH configuration from stdin
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 24s
CI / test (3.10) (pull_request) Successful in 23s
CI / test (3.11) (pull_request) Successful in 25s
CI / test (3.12) (pull_request) Successful in 24s
A driver gains root with sudo -S and puts the password in front of stdin.
A sudo that does not prompt -- NOPASSWD, a cached timestamp -- leaves it
there, and the cat under sudo wrote it into the drop-in as its first line.
Seen on the first real run (NetOrk/netork#1027): sshd -t rejected the file
and the rollback removed it, but the password was in a 0644 file for a
moment and in the error the caller showed.

The content now goes without privilege to a mktemp file only the login user
can read; root installs that file (mode 644, root-owned) and removes it.
The fake host in the tests prepends the password to every privileged stdin,
as a non-prompting sudo does.

Bumps to 4.5.1. NetOrk/netork#1027, NetOrk/netork#1029
2026-10-11 23:48:51 +02:00
christianmanivong d43e15e8db Merge pull request 'feat: OpenSshServerMixin takes flagged algorithms out of what sshd offers' (#29) from feat/ssh-algorithm-fix into main
CI / test (3.10) (push) Successful in 23s
CI / test (3.11) (push) Successful in 22s
CI / test (3.12) (push) Successful in 23s
2026-10-11 21:14:06 +00:00
3 changed files with 60 additions and 9 deletions

No files matched your search

+21 -4
View File
@@ -285,11 +285,28 @@ class OpenSshServerMixin:
return result.stdout if result.exit_code == 0 else None
def _ssh_write(self, path: str, content: str) -> None:
script = (
f'tmp={quote(path + ".netork-tmp")} && cat > "$tmp" && chmod 644 "$tmp"'
f' && mv -f -- "$tmp" {quote(path)} || {{ s=$?; rm -f -- "$tmp"; exit $s; }}'
"""Replace *path* with *content*, as root, without root reading stdin.
A driver gains root with ``sudo -S`` and puts the password in front of
stdin. A sudo that does not prompt -- NOPASSWD, a cached timestamp --
leaves it there, and a ``cat`` under sudo writes it into the file. So the
content goes, without privilege, to a file only the login user can read;
root then installs that file (mode 644, owned by root) and removes it.
"""
staged = self.run_command(
'umask 077 && t=$(mktemp /tmp/netork-ssh.XXXXXX) && cat > "$t" && printf %s "$t"',
privileged=False,
timeout=30,
stdin=content.encode(),
)
tmp = staged.stdout.strip()
if staged.exit_code != 0 or not tmp.startswith("/tmp/netork-ssh."):
raise RuntimeError(f"could not stage {path}: {(staged.stderr or staged.stdout).strip()}")
part = path + ".netork-tmp"
result = self._ssh_run(
f"install -m 644 -- {quote(tmp)} {quote(part)} && mv -f -- {quote(part)} {quote(path)};"
f" s=$?; rm -f -- {quote(tmp)} {quote(part)}; exit $s"
)
result = self._ssh_run(script, stdin=content.encode())
if result.exit_code != 0:
raise RuntimeError(f"could not write {path}: {(result.stderr or result.stdout).strip()}")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "4.5.0"
version = "4.5.1"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
+38 -4
View File
@@ -188,10 +188,29 @@ class FakeHost(OpenSshServerMixin):
eff[kind] = parts[1].split(",")
return eff
#: What a real ``sudo -S`` leaves on stdin when it does not prompt (NOPASSWD,
#: or a cached timestamp): the password the driver put in front of the data.
SUDO_PASSWORD = "hunter2"
def run_command(self, command: str, *, privileged: bool = False, timeout: float = 60,
stdin: Optional[bytes] = None) -> CommandResult:
self.ran.append(command)
if privileged and stdin is not None:
stdin = f"{self.SUDO_PASSWORD}\n".encode() + stdin
m = re.search(r"mktemp", command)
if m:
assert not privileged, "the content must reach a file without sudo in the way"
path = f"/tmp/netork-ssh.{len(self.files)}"
self.files[path] = (stdin or b"").decode()
return CommandResult(path, "", 0)
assert privileged, command
m = re.search(r"install -m 644 -- '?([^' ]+)'? '?([^' ]+)'?", command)
if m:
src, tmp = m.group(1), m.group(2)
target = re.search(r"mv -f -- '?[^' ]+'? '?([^' ;]+)'?", command).group(1)
self.files[target] = self.files.pop(src)
assert tmp.endswith(".netork-tmp")
return CommandResult("", "", 0)
if command.startswith("sshd -T") or "sshd -T" in command:
eff = self._effective()
out = "".join(f"{d} {','.join(eff[k])}\n" for d, k in _DIRECTIVE.items())
@@ -204,10 +223,6 @@ class FakeHost(OpenSshServerMixin):
if path in self.files:
return CommandResult(self.files[path], "", 0)
return CommandResult("", "No such file", 1)
m = re.search(r"mv -f -- \"\$tmp\" '?([^' ]+)'?", command)
if m:
self.files[m.group(1)] = (stdin or b"").decode()
return CommandResult("", "", 0)
m = re.match(r"rm -f -- '?([^' ]+)'?$", command)
if m:
self.files.pop(m.group(1), None)
@@ -301,3 +316,22 @@ def test_main_config_second_fix_keeps_what_the_first_set():
eff = host._effective()
assert "ecdsa-sha2-nistp256" not in eff["hostkey"]
assert "hmac-sha1" not in eff["mac"]
def test_the_sudo_password_never_reaches_a_file():
"""napalm-linux puts the sudo password in front of stdin for ``sudo -S``. A
sudo that does not prompt leaves it there, and a ``cat`` under sudo wrote it
into the drop-in (NetOrk/netork#1027, first run on a real host). The content
now reaches a file of the login user first; root only installs that file."""
host = FakeHost()
host.harden_ssh_server({"mac": ["hmac-sha1"]})
assert host.SUDO_PASSWORD not in host.files[DROPIN_PATH]
assert host.files[DROPIN_PATH].startswith("# Written by netOrk")
assert not [p for p in host.files if p.startswith("/tmp/")]
def test_restoring_an_earlier_dropin_leaks_nothing_either():
host = FakeHost()
host.files[DROPIN_PATH] = "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
host.harden_ssh_server({"mac": ["hmac-sha1"]}, verify=lambda: False)
assert host.files[DROPIN_PATH] == "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"