fix: stage root writes in a mktemp file, never at a fixed /tmp path
CI / test (3.10) (push) Successful in 46s
CI / test (3.11) (push) Successful in 39s
CI / test (3.12) (push) Successful in 53s
CI / test (3.10) (pull_request) Successful in 44s
CI / test (3.11) (pull_request) Successful in 47s
CI / test (3.12) (pull_request) Successful in 47s

Now that a whole _sudo line runs as root, `mv /tmp/netork_snmpd.conf
/etc/snmp/snmpd.conf && chown root:root … && chmod 644 …` would have let a
local user plant a symlink at the fixed /tmp path and have root's chown and
chmod follow it (e.g. to /etc/shadow). The same held for 00proxy.

_install_file_as_root stages the content unprivileged in a fresh mktemp file
(umask 077), checks the path it got back, and root installs it as a new
regular file next to the target and renames it over: nothing root runs
follows a symlink or changes the owner or mode of an existing path. Nothing
runs as root when staging failed.

get_vpn_tunnels always reads `wg show all dump` from the exec channel; the
predictable /tmp/.netork_wg_<hash> file holding the private keys is gone.
This commit is contained in:
Christian Manivong committed 2026-10-12 22:56:06 +02:00
1 parent cf8ee38a90
commit 108f6fb3ca
3 files changed
+307 -28

No files matched your search

+50 -28
View File
@@ -152,6 +152,15 @@ _HISTORY_OFF = " unset HISTFILE; set +o history 2>/dev/null; true"
#: a cached timestamp) leaves the password on stdin, and nothing may read it.
_NO_STDIN = "exec </dev/null; "
#: Where ``_install_file_as_root`` stages content: a fresh file from ``mktemp``
#: under ``umask 077``, never a fixed name another user could create first.
_STAGE_TEMPLATE = "/tmp/netork-stage.XXXXXX"
_STAGE_PREFIX = _STAGE_TEMPLATE[: -len("XXXXXX")]
_STAGE = (
f"umask 077 && t=$(mktemp {_STAGE_TEMPLATE}) && "
'{ cat > "$t" && printf %s "$t" || { s=$?; rm -f -- "$t"; exit $s; }; }'
)
#: What to do when sudo wants a password netOrk does not have.
_SUDO_PASSWORD_HINT = (
"sudo requires a password on this device but none is configured in netOrk. "
@@ -385,6 +394,29 @@ class LinuxDriver(
line, prefix = self._as_root(f"{_NO_STDIN}{command}")
return run_on_transport(self._transport(), f"{line} 2>&1", stdin=prefix, timeout=timeout)
def _install_file_as_root(self, path: str, content: str) -> CommandResult:
"""Replace *path* with *content*, as a new file owned by root, mode 644.
The content is staged without privilege in a fresh ``mktemp`` file only
the login user may read; its stdin is the content's, not a privileged
command's (``_PRIVILEGED_STDIN``). Root then ``install``s that file as a
new regular file next to *path* and renames it over *path*: nothing root
runs follows a symlink or changes the owner or mode of an existing path
(NetOrk/netork#1039). Nothing runs as root when staging failed.
"""
staged = self.run_command(_STAGE, timeout=30, stdin=content.encode())
lines = staged.stdout.strip().splitlines()
tmp = lines[-1] if lines else ""
if staged.exit_code != 0 or not tmp.startswith(_STAGE_PREFIX) or "/" in tmp[len(_STAGE_PREFIX):]:
reason = (staged.stderr or staged.stdout).strip() or f"could not stage {path}"
return CommandResult(reason, "", staged.exit_code or 1)
tmp_q, part_q, path_q = (_shlex_quote(p) for p in (tmp, f"{path}.netork-tmp", path))
return self._sudo_result(
f"install -m 644 -o root -g root -- {tmp_q} {part_q} && mv -f -- {part_q} {path_q};"
f" s=$?; rm -f -- {tmp_q} {part_q}; exit $s",
30,
)
def _sudo_status(self, command: str, read_timeout: float = 100) -> tuple[str, int | None]:
"""Run *command* as root and return ``(output, exit_status)``.
@@ -1988,25 +2020,24 @@ class LinuxDriver(
except Exception:
pass
# Write snmpd.conf:
# 1. Write to /tmp (no sudo needed, avoids stdin conflict with sudo -S)
# 2. sudo mv to /etc/snmp/snmpd.conf
# Write snmpd.conf: staged unprivileged, installed by root (#1039).
# agentAddress udp:161 overrides Debian's localhost-only default.
import base64 as _b64
conf_str = (
"agentAddress udp:161\n"
"rocommunity public\n"
"sysLocation Managed by netOrk\n"
"sysContact netork@localhost\n"
)
conf_b64 = _b64.b64encode(conf_str.encode()).decode()
self._send(f"echo {conf_b64} | base64 -d > /tmp/netork_snmpd.conf")
self._sudo("mv /tmp/netork_snmpd.conf /etc/snmp/snmpd.conf && chown root:root /etc/snmp/snmpd.conf && chmod 644 /etc/snmp/snmpd.conf")
try:
written = self._install_file_as_root("/etc/snmp/snmpd.conf", conf_str)
install_error = written.stdout.strip() if written.exit_code else ""
except Exception as exc:
install_error = str(exc)
verify = self._send("cat /etc/snmp/snmpd.conf 2>/dev/null").strip()
if "agentAddress" in verify and "rocommunity" in verify:
lines.append("[config] Wrote /etc/snmp/snmpd.conf — agentAddress udp:161, rocommunity public.")
else:
lines.append(f"[warn] snmpd.conf write may have failed: {verify[:100]}")
lines.append(f"[warn] snmpd.conf write may have failed: {(install_error or verify)[:100]}")
# 3. Open firewall for SNMP (UDP 161) — restrict to netOrk's source IP
if netork_ip:
@@ -2075,7 +2106,6 @@ class LinuxDriver(
def _action_fix_apt_proxy(self) -> DeviceActionResultDict:
"""Write /etc/apt/apt.conf.d/00proxy with the configured proxy URL."""
import base64 as _b64
proxy_url = self._apt_proxy_url
if not proxy_url:
return {"success": False, "output": "No apt_proxy_url configured."}
@@ -2083,18 +2113,16 @@ class LinuxDriver(
return {"success": False, "output": f"Package manager is {self._pkg_manager!r}, not apt — skipping."}
content = f'Acquire::http::Proxy "{proxy_url}";\n'
content_b64 = _b64.b64encode(content.encode()).decode()
self._send(f"echo {content_b64} | base64 -d > /tmp/netork_00proxy")
self._sudo(
"mv /tmp/netork_00proxy /etc/apt/apt.conf.d/00proxy && "
"chown root:root /etc/apt/apt.conf.d/00proxy && "
"chmod 644 /etc/apt/apt.conf.d/00proxy"
)
# Staged unprivileged, installed by root (#1039).
written = self._install_file_as_root("/etc/apt/apt.conf.d/00proxy", content)
verify = self._send("cat /etc/apt/apt.conf.d/00proxy 2>/dev/null").strip()
success = proxy_url in verify
if success:
return {"success": True, "output": f"Wrote /etc/apt/apt.conf.d/00proxy — proxy: {proxy_url}"}
return {"success": False, "output": f"Write may have failed. File content: {verify[:200]}"}
output = f"Write may have failed. File content: {verify[:200]}"
if written.exit_code:
output = f"{output}\n{written.stdout.strip()}"
return {"success": False, "output": output}
def get_vpn_tunnels(self) -> dict[str, Any]:
"""Return WireGuard status via ``wg show all dump`` (requires root/sudo).
@@ -2109,17 +2137,11 @@ class LinuxDriver(
tunnels: dict[str, Any] = {}
# ── Attempt 1: wg show all dump via sudo ─────────────────────────────
# With a sudo password it runs on the exec channel, which keeps the
# tabs. The PTY expands them to spaces, breaking split("\t"), so
# without one the output goes through a temp file.
if self._sudo_password:
raw = self._sudo("wg show all dump 2>&1 || true")
else:
import hashlib as _hashlib
_tmp = "/tmp/.netork_wg_" + _hashlib.md5(self.hostname.encode()).hexdigest()[:8]
self._send(f"sudo -n wg show all dump > {_tmp} 2>&1 || true")
raw = self._send(f"cat {_tmp} 2>/dev/null || true; rm -f {_tmp}").strip()
# ── Attempt 1: wg show all dump as root ──────────────────────────────
# On the exec channel, which keeps the tabs the PTY expands to spaces.
# It went through /tmp/.netork_wg_<hash> before: a predictable name,
# holding the interfaces' private keys (#1039).
raw = self._sudo("wg show all dump 2>&1 || true")
_perm_errors = (
"operation not permitted", "permission denied",