| Current Path : /opt/cloudlinux/venv/lib/python3.11/site-packages/clcagefslib/webisolation/ |
| Current File : //opt/cloudlinux/venv/lib/python3.11/site-packages/clcagefslib/webisolation/mount_config.py |
#!/opt/cloudlinux/venv/bin/python3 -sbb
# -*- coding: utf-8 -*-
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENCE.TXT
#
"""
Mount configuration builder for website isolation.
The code handles all standard behavior (docroot isolation, home overlay, etc).
"""
import os
import os.path
from dataclasses import dataclass, field
from .docroot_validation import validate_docroot_no_symlinks
from .jail_config import MountEntry
def _source_looks_tenant_writable(source: str, raw_prefix: str,
resolved_prefix: str) -> bool:
"""Return True if the raw ``source`` string sits lexically under
the tenant's home (as raw or resolved) and therefore counts as a
tenant-writable bind source that must be revalidated at the mount
sink.
The classification is deliberately lexical, not realpath-based:
a symlink swap that makes ``realpath(source)`` escape the home
tree is exactly the case the sink revalidation must catch, so
the classifier cannot itself dereference the symlink or the
swapped-out source would be misclassified as operator-controlled
and skipped. The subsequent validator run performs the safe
fd-walked resolution.
"""
for prefix in (resolved_prefix, raw_prefix):
marker = prefix.rstrip("/") + "/"
if source == prefix or source.startswith(marker):
return True
return False
@dataclass
class IsolatedRootConfig:
"""
Configuration for a directory overlay.
Closes access to a directory by mounting a fake/empty directory over it,
then selectively exposing only whitelisted paths.
Storage is computed as: {storage_base}/{name}
"""
# Path to the root of this storage (e.g. ~/.clcagefs/website/123/home)
root_path: str
# Real directory to close
target: str
# Use temporary tmpfs for storage (default: real directory)
persistent: bool = True
# List of mounts made inside of this root (dynamically)
mounts: list[MountEntry] = field(default_factory=list)
# F-36 (CLOS-5423): defense-in-depth path-traversal guard at the mount
# sink. All callers reach mount() via write_jail_mounts_config, which
# runs validate_docroot (regex allowlist rejecting whitespace, `[`,
# `]`, `,`, `;`, quotes) and validate_docroot_no_symlinks (O_NOFOLLOW
# component walk from the resolved user home) before this point.
# However, those checks compare *resolved* paths while mount() below
# composes a mount-target string from the *raw* spellings via
# os.path.relpath - so a benign operator alias (e.g. `/home -> /home2`
# with panel-returned `/home2/user/public_html` against a raw
# `self.target = /home/user`) would produce a relpath prefixed with
# `..`, and the emitted `{root_path}/{relative_path}` string would
# lexically escape root_path. Canonicalise both paths here before
# composing the relative segment and reject any residual escape.
# Any new caller must uphold the trust-boundary contract above.
def mount(self, type_, source, target, opts: tuple = tuple()):
"""Mounts whatever asked into the root of isolated storage"""
# Resolve both sides so an aliased-but-equivalent target
# (e.g. `/home2/user/public_html` vs raw `self.target=/home/user`
# under `/home -> /home2`) produces a clean tail segment instead
# of a `..`-prefixed escape.
resolved_self = os.path.realpath(self.target)
resolved_target = os.path.realpath(target)
# F-16 (CLOS-5954) DiD: mount() only canonicalises the target
# here; the caller-supplied `source` is otherwise stored raw in
# MountEntry and later handed to a root-run bind mounter that
# follows symlinks. If the source is a path under the tenant's
# home (i.e. under self.target), revalidate it with the
# component-walking no-symlink check right at this sink so a
# tenant swap between config-gen and the final persist is
# rejected. Sources rooted outside the tenant's home
# (`/etc/...`, `/var/...`, `/proc/...`, etc.) are operator-
# controlled and are not revalidated - the operator boundary
# is trusted. Full TOCTOU closure still requires the C-side
# mount consumer to bind through an fd (see F-11 / F-14).
if _source_looks_tenant_writable(source, self.target, resolved_self):
try:
validate_docroot_no_symlinks(source, self.target)
except ValueError as exc:
# Re-raise with a class-neutral generic message; the
# inner detail is preserved via __cause__ for logs but
# never surfaces to callers.
raise ValueError("Invalid bind source in jail configuration") from exc
relative_path = os.path.relpath(resolved_target, resolved_self)
# os.path.relpath emits `..` (or a `../`-prefixed string) when the
# resolved target does not lie under the resolved overlay root -
# exactly the case that would produce a mount-target string
# escaping root_path. Refuse: a well-formed jail cannot contain
# such an entry.
if relative_path == ".." or relative_path.startswith("../"):
raise ValueError(
"Invalid mount target: resolved target "
f"{resolved_target!r} escapes overlay root "
f"{resolved_self!r} (target={target!r}, "
f"self.target={self.target!r})"
)
self.mounts.append(MountEntry(type_, source, f"{self.root_path}/{relative_path}", opts))
def overlay_path(self, path: str) -> str:
"""Where ``path`` lands inside this overlay's skeleton.
The same mapping ``mount()`` applies to its target, exposed so a
caller can name a mount this overlay has ALREADY made rather
than the real directory behind it - see the CLOS-6824 alias
mirroring in jail_config_builder, which must bind the skeleton's
own docroot mount (blanked children and all) instead of
re-binding the real docroot.
"""
resolved_self = os.path.realpath(self.target)
resolved_path = os.path.realpath(path)
relative_path = os.path.relpath(resolved_path, resolved_self)
if relative_path == ".." or relative_path.startswith("../"):
raise ValueError(
f"Invalid overlay path: resolved path {resolved_path!r} "
f"escapes overlay root {resolved_self!r} (path={path!r}, "
f"self.target={self.target!r})"
)
return f"{self.root_path}/{relative_path}"
def mount_alias(self, type_, source, alias, opts: tuple = tuple()):
"""Mount ``source`` at ``alias``, keeping the alias name unresolved.
CLOS-6824. ``mount()`` above canonicalises its target, which is
right for every other caller - an aliased-but-equivalent target
must fold onto one clean tail segment. It is precisely wrong
here: ``realpath`` would fold DirectAdmin's ``private_html``
back onto ``public_html``, so the entry would silently duplicate
the canonical docroot mount and the alias path would never be
created inside the skeleton - the very gap this exists to close.
Only the alias PARENT is canonicalised; the leaf is kept as
supplied. The emitted target therefore still cannot escape the
overlay root - the escape check below runs against the resolved
parent - while the alias name survives into the mount config.
The leaf must be a single path component: it comes from a
directory entry, so it can contain no separator, and ``.`` /
``..`` are refused rather than silently normalised.
"""
parent, leaf = os.path.split(alias)
if leaf in ("", ".", ".."):
raise ValueError(f"Invalid mount alias leaf: {alias!r}")
resolved_self = os.path.realpath(self.target)
resolved_target = os.path.join(os.path.realpath(parent), leaf)
# Same sink revalidation as mount(): the source is a tenant path
# handed to a root-run bind mounter that follows symlinks.
if _source_looks_tenant_writable(source, self.target, resolved_self):
try:
validate_docroot_no_symlinks(source, self.target)
except ValueError as exc:
raise ValueError("Invalid bind source in jail configuration") from exc
relative_path = os.path.relpath(resolved_target, resolved_self)
if relative_path == ".." or relative_path.startswith("../"):
raise ValueError(
"Invalid mount alias: resolved target "
f"{resolved_target!r} escapes overlay root {resolved_self!r} "
f"(alias={alias!r}, self.target={self.target!r})"
)
self.mounts.append(MountEntry(type_, source, f"{self.root_path}/{relative_path}", opts))