#!/usr/bin/env python3 """Path confinement for Simple NAS. Every filesystem path that reaches this add-on from a browser must pass through here first. The add-on runs as root with full_access, so an unchecked path is a read/write primitive over the whole Home Assistant host. Two rules drive the implementation: * Resolve symlinks *after* joining, then compare. Checking the string before resolution is useless: a symlink inside an allowed root can point anywhere. * Compare with ``real_root + os.sep``, never a bare ``startswith``. Otherwise ``/media/foobar`` counts as a hit for the root ``/media/foo``. """ import os __all__ = [ "PathError", "DENY_ROOTS", "HARD_DENY", "SOFT_DENY", "real", "is_within", "resolve_within", "resolve_in_roots", "resolve_new", "open_new_file", ] class PathError(Exception): """A path was rejected. The message is for the log, never for the client.""" # Never reachable, whatever the configuration says: /data holds this add-on's # own password hashes, and the kernel pseudo-filesystems are not files anyway. HARD_DENY = ("/data", "/proc", "/sys", "/dev") # Blocked by default, but the admin can unlock them in the Settings tab. SOFT_DENY = ("/ssl", "/etc", "/var", "/run", "/boot", "/root") DENY_ROOTS = HARD_DENY + SOFT_DENY def real(path: str) -> str: """Absolute path with every symlink resolved.""" return os.path.realpath(os.path.abspath(path)) def is_within(root: str, candidate: str) -> bool: """True when candidate is root itself or sits below it. Both must be real paths.""" return candidate == root or candidate.startswith(root.rstrip(os.sep) + os.sep) def _reject_denied(real_path: str, deny=None) -> None: # Resolve the deny roots too: on some systems /var is a symlink, and a raw # string compare would then miss it. for d in (DENY_ROOTS if deny is None else deny): if is_within(real(d), real_path): raise PathError(f"denied root: {real_path}") def _check_segments(rel: str) -> str: """Reject traversal and NUL before touching the filesystem.""" rel = (rel or "").replace("\\", "/") if "\x00" in rel: raise PathError("NUL in path") for seg in rel.split("/"): if seg in ("", "."): continue if seg == "..": raise PathError("traversal") return rel def resolve_within(root: str, rel: str) -> str: """Resolve a client-supplied path relative to root. The result is guaranteed to be root or below it, after full symlink resolution. Raises PathError otherwise. """ real_root = real(root) if not os.path.isdir(real_root): raise PathError(f"root unavailable: {root}") rel = _check_segments(rel) stripped = rel.strip("/") candidate = os.path.join(real_root, stripped) if stripped else real_root resolved = real(candidate) if not is_within(real_root, resolved): raise PathError(f"escapes root: {resolved}") return resolved def resolve_in_roots(roots, abs_path: str, deny=None) -> str: """Resolve an absolute path supplied by the admin UI against the allowed roots. Used by the admin file API, where the client sends whole paths rather than a path relative to one known root. `deny` overrides the default block list, so the admin can unlock the soft-denied system folders. """ if not abs_path or not str(abs_path).strip(): raise PathError("empty path") if "\x00" in str(abs_path): raise PathError("NUL in path") resolved = real(str(abs_path).strip()) _reject_denied(resolved, deny) for root in roots or (): real_root = real(root) if is_within(real_root, resolved): return resolved raise PathError(f"outside allowed roots: {resolved}") def resolve_new(root: str, rel_dir: str, leaf: str) -> str: """Destination for a file or directory that does not exist yet. The parent is resolved and confined; the leaf is validated separately because realpath() on a missing leaf would just append it literally. """ parent = resolve_within(root, rel_dir) if leaf in ("", ".", "..") or "/" in leaf or "\\" in leaf or "\x00" in leaf: raise PathError(f"bad name: {leaf!r}") dest = os.path.join(parent, leaf) if os.path.islink(dest): raise PathError("destination is a symlink") return dest def open_new_file(dest: str, mode: int = 0o644): """Create a file, failing if it exists or is a symlink. O_EXCL|O_NOFOLLOW closes the window in which a Samba client could swap the destination for a symlink between the check above and the write below. O_NOFOLLOW only exists on POSIX; on Windows (tests only) it is dropped. """ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) return os.open(dest, flags, mode)