| Server IP : 142.11.234.102 / Your IP : 216.73.217.70 Web Server : Apache System : Linux dal-shared-66.hostwindsdns.com 4.18.0-513.24.1.lve.1.el8.x86_64 #1 SMP Thu May 9 15:10:09 UTC 2024 x86_64 User : krnuyqrm ( 1183) PHP Version : 7.4.33 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : OFF | Pkexec : OFF Directory : /opt/cloudlinux/venv/lib64/python3.11/site-packages/clcagefslib/webisolation/ |
Upload File : |
#!/opt/cloudlinux/venv/bin/python3 -sbb
# -*- coding: utf-8 -*-
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2026 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENCE.TXT
#
"""Trust-boundary validation for panel-supplied document root strings.
The docroot value originates from the hosting panel (cPanel /
DirectAdmin / Plesk) and is consumed by privileged code that writes
jail.c mount configuration files read by root. The jail.c mount syntax
is whitespace-delimited (MountEntry.render in mount_types.py joins
source/target/options with spaces) and section headers are bracketed
(`[<docroot>]` in jail_config.MountConfig.render), so any whitespace,
control character, newline, or bracket inside the docroot corrupts the
parser. A sibling module already rejects newlines/carriage-returns on
the analogous crontab write path (crontab/libhooks.py and
crontab/parser.py); this module is the equivalent guard for the jail
mount config write path.
Validation is centralized at the trust boundary - call sites are
`enable_website_isolation` (where a tenant-owned domain is resolved to
a docroot for the first time) and `write_jail_mounts_config` (where
the docroot map is re-read from the panel for every regeneration). The
helper raises ValueError on rejection, matching the sibling crontab
pattern.
"""
from __future__ import annotations
import errno
import os
import re
import stat
# Strict allowlist: alnum, `_`, `-`, `.`, `/`. This excludes whitespace
# (which would split mount-line tokens), brackets (which would close the
# jail section header), quotes, and any control character. Production
# docroots in shared-hosting deployments are conventionally of the form
# `/home/<user>/<subdir>` and well inside this allowlist; anything
# outside it is either a panel misconfiguration or an injection attempt.
# `\Z` (not `$`) anchors the match at the true end of the string.
# Python's default `$` also matches immediately before a terminating
# `\n`, so `^/[A-Za-z0-9_./-]*$` accepts a trailing newline even though
# `\n` is not in the character class - a docroot like
# `/home/u/public_html\n` would slip past this filter and later split
# the `[<docroot>]` section header written by MountConfig.render. `\Z`
# closes that gap without loosening the character class.
_DOCROOT_ALLOWED_RE = re.compile(r"^/[A-Za-z0-9_./-]*\Z")
# Hard cap to bound the size of strings that flow into mount lines and
# section headers. A 4 KiB ceiling is well above any realistic docroot
# (Linux PATH_MAX is 4096) and well below pathological mount-line
# sizes.
_DOCROOT_MAX_LEN = 4096
def validate_docroot(docroot: str) -> str:
"""Validate a panel-supplied document root before it reaches the
jail mount config writer.
Args:
docroot: Document root string returned by the panel (e.g. from
``clcommon.cpapi.docroot`` or ``clcommon.cpapi.userdomains``).
Returns:
The validated docroot string, unchanged.
Raises:
ValueError: If the docroot is empty, not an absolute path, too
long, contains a path-traversal segment, or contains any
character outside the strict allowlist (alnum, `_`, `-`,
`.`, `/`).
"""
if not isinstance(docroot, str):
raise ValueError(f"Invalid docroot (not a string): {docroot!r}")
if not docroot:
raise ValueError("Invalid docroot: empty string")
if len(docroot) > _DOCROOT_MAX_LEN:
raise ValueError(
f"Invalid docroot (length {len(docroot)} exceeds {_DOCROOT_MAX_LEN}): {docroot!r}"
)
if not docroot.startswith("/"):
raise ValueError(f"Invalid docroot (not absolute): {docroot!r}")
if not _DOCROOT_ALLOWED_RE.match(docroot):
raise ValueError(f"Invalid docroot (disallowed characters): {docroot!r}")
# Reject empty path segments (``//``). The character-class allowlist
# permits ``/`` freely, so ``/home/user//evil`` passes the regex. But
# empty segments break the O_NOFOLLOW tail walk in
# ``_reject_symlinks_in_tail``: ``tail.split('/')`` yields an empty-
# string component, and ``os.open('', dir_fd=...)`` returns ENOENT,
# which the walk treats as a safe short-circuit — the leaf symlink
# is then never O_NOFOLLOWed. Reject at the entry so downstream code
# never sees a docroot with empty segments.
if "//" in docroot:
raise ValueError(f"Invalid docroot (empty path segment): {docroot!r}")
# Reject traversal segments. The allowlist permits `.` and `..` as
# path components even though it forbids most metacharacters, so
# screen explicitly: `..` lets a tenant's owned-domain docroot point
# at a sibling tenant's tree once it is bind-mounted as the jail
# source.
for segment in docroot.split("/"):
if segment == "..":
raise ValueError(f"Invalid docroot (parent traversal): {docroot!r}")
return docroot
def validate_docroot_no_symlinks(docroot: str, allowed_prefix: str) -> str:
"""Reject a docroot whose on-disk path contains an attacker-plantable
symlink under ``allowed_prefix``.
The lexical ``validate_docroot`` above pins the *string shape* of a
panel-supplied docroot, but the value still flows verbatim into
jail.c mount-line ``source`` fields and is later consumed by a
root-run mount executor that calls ``bind(2)``. Per Linux mount(2)
semantics, MS_BIND on a symlinked source dereferences the symlink
and bind-mounts the resolved path - which lets a tenant who can
write anywhere along the docroot's ancestor chain
(typically ``/home/<user>/public_html/<sub>`` on cPanel / DA / Plesk)
pre-aim the bind source at an arbitrary host path
(``ln -s / /home/<user>/public_html/evil``). The root-run jail then
happily bind-mounts ``/`` (or any other operator path the tenant
chose) inside the tenant's isolated namespace, exposing
``/etc/shadow``, other tenants' homes, etc.
The check strategy has two layers. First, ``allowed_prefix`` is
canonicalised once via ``realpath()`` so an operator-installed
symlink *above* the tenant tree (e.g. ``/home -> /home2``) does
not produce false rejects - only the operator controls that layer.
Second, from the resolved prefix downward, each remaining path
component of the docroot is opened with ``O_NOFOLLOW`` under a
``dir_fd``, mirroring the sibling ``create_overlay_storage_directory``
in ``jail_utils.py``. Any symlink planted in the tenant-writable
tree - even one whose current target happens to resolve back inside
the home - is rejected at check time, because a tenant can swap
the symlink's target between this check and the eventual bind(2).
An absent component (``ENOENT``) is treated as safe and stops the
walk: the root-run mount consumer sees the same non-existent path
and its bind(2) fails cleanly with no privilege escalation.
Note: closing the underlying TOCTOU fully requires the root-run
mount consumer to itself use an fd-bound bind source (openat2 with
RESOLVE_NO_SYMLINKS or /proc/self/fd/N off an O_PATH|O_NOFOLLOW
walk). The rejection here is defense-in-depth at the Python
boundary; it shrinks the attacker's state space (they must swap
a real directory for a symlink between the check and the mount)
but does not fully close the C-side dereference window.
Args:
docroot: Document root string returned by the panel. Must
already have passed the lexical ``validate_docroot``
check (so it is absolute, ``..``-free, and within the
character allowlist) - this function does *not* re-run
those checks.
allowed_prefix: Absolute path the docroot must be inside.
Typically the user's home directory; passing the
unresolved value is fine because the function
canonicalises it once.
Returns:
The docroot string, unchanged.
Raises:
ValueError: If the docroot is not under ``allowed_prefix``,
if any component under the resolved prefix is a symlink
or a non-directory, or if the resolved prefix cannot be
opened.
"""
try:
resolved_prefix = os.path.realpath(allowed_prefix, strict=False)
except OSError as exc:
raise ValueError(
f"Invalid docroot (cannot resolve prefix "
f"{allowed_prefix!r}: {exc})"
) from exc
# Docroot may equal the home itself (some panels emit that), or
# start with either the raw allowed_prefix (operator-symlink form
# ``/home/u/...``) or the resolved form (``/home2/u/...``). Anything
# else is outside the tenant's home tree lexically - reject.
if docroot == resolved_prefix or docroot == allowed_prefix:
return docroot
for prefix in (resolved_prefix, allowed_prefix):
marker = prefix.rstrip("/") + "/"
if docroot.startswith(marker):
relative = docroot[len(marker):]
break
else:
raise ValueError(
f"Invalid docroot (resolves outside allowed prefix "
f"{resolved_prefix!r}): {docroot!r}"
)
components = [c for c in relative.split("/") if c]
if not components:
return docroot
# Open the resolved prefix as an O_DIRECTORY anchor for the walk.
# No O_NOFOLLOW here: the operator layer above the tenant tree
# may legitimately be a symlink and we have already canonicalised
# through it via realpath().
try:
prefix_fd = os.open(
resolved_prefix,
os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC,
)
except FileNotFoundError:
# Prefix itself does not exist. bind(2) will fail-safe on any
# path anchored inside it; matches the strict=False realpath
# contract that a not-yet-materialised tree is not an error.
return docroot
except OSError as exc:
raise ValueError(
f"Invalid docroot (cannot open resolved prefix "
f"{resolved_prefix!r}: {exc}): {docroot!r}"
) from exc
try:
_walk_no_symlinks(prefix_fd, components, docroot, resolved_prefix)
finally:
os.close(prefix_fd)
return docroot
def _walk_no_symlinks(
base_fd: int,
components: list[str],
docroot: str,
resolved_prefix: str,
) -> None:
"""Walk ``components`` under ``base_fd`` rejecting any symlink or
non-directory. Uses an lstat-then-openat-with-O_NOFOLLOW sequence:
the lstat classifies the inode without following, and the follow-up
open (with O_NOFOLLOW as belt-and-suspenders) captures a fd bound
to the classified directory before descending. ``O_DIRECTORY`` and
``O_NOFOLLOW`` together return ``ENOTDIR`` (not ``ELOOP``) on a
trailing symlink, which is why the type check runs before the open.
An absent component (``ENOENT``) is safe: the root-run mount
consumer sees the same non-existent path and bind(2) fails cleanly
with no privilege escalation.
"""
current_fd = base_fd
opened: list[int] = []
try:
for comp in components:
try:
st = os.stat(comp, dir_fd=current_fd, follow_symlinks=False)
except OSError as exc:
if exc.errno == errno.ENOENT:
return
raise ValueError(
f"Invalid docroot (cannot walk component {comp!r}: "
f"{exc}): {docroot!r}"
) from exc
if stat.S_ISLNK(st.st_mode):
raise ValueError(
f"Invalid docroot (symlink at component {comp!r} "
f"resolves outside allowed prefix "
f"{resolved_prefix!r}): {docroot!r}"
)
if not stat.S_ISDIR(st.st_mode):
raise ValueError(
f"Invalid docroot (non-directory at component "
f"{comp!r}): {docroot!r}"
)
try:
fd = os.open(
comp,
os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY | os.O_CLOEXEC,
dir_fd=current_fd,
)
except OSError as exc:
# Race: comp was swapped to a symlink between lstat and
# open. O_NOFOLLOW surfaces this as ELOOP; treat it as
# the symlink-rejection path above.
if exc.errno == errno.ELOOP:
raise ValueError(
f"Invalid docroot (symlink at component {comp!r} "
f"resolves outside allowed prefix "
f"{resolved_prefix!r}): {docroot!r}"
) from exc
raise ValueError(
f"Invalid docroot (cannot walk component {comp!r}: "
f"{exc}): {docroot!r}"
) from exc
opened.append(fd)
current_fd = fd
finally:
for fd in opened:
os.close(fd)