Ctrl K
ComunidadGuías…

simple_nas/app/safepath.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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)