Your IP : 216.73.216.228


Current Path : /opt/cloudlinux/venv/lib64/python3.11/site-packages/clcagefslib/webisolation/
Upload File :
Current File : //opt/cloudlinux/venv/lib64/python3.11/site-packages/clcagefslib/webisolation/jail_utils.py

# -*- 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
#
import os
import pathlib
import secrets
import shutil
from pathlib import Path

from clcommon import ClPwd
from clcommon.clpwd import drop_privileges

from clcagefslib.fs import get_user_var_cagefs_path
from clcagefslib.io import apply_metadata_nofollow, write_via_tmp

# FNV-1a 64-bit hash constants (must match jail C implementation)
_FNV_OFFSET_BASIS = 14695981039346656037
_FNV_PRIME = 1099511628211


def get_jail_config_path(user):
    cagefs_dir = get_user_var_cagefs_path(user)
    return pathlib.Path(f"{cagefs_dir}/.cagefs/isolates.mounts")


def get_website_id(document_root: str):
    """
    Generates unique id for an isolate website using FNV-1a 64-bit hash.
    FNV-1a has excellent avalanche properties and distribution.
    Must match the docroot_hash() function in jail C code.
    """
    # CLOS-4642: normalize trailing slash so registration and the lsphp
    # lookup agree. LiteSpeed's $DOC_ROOT vhost variable (used to feed
    # per-domain DedicatePhpHandler pools) carries a trailing slash, while
    # the canonical docroot from cpapi.docroot() does not.
    document_root = document_root.rstrip("/") or "/"
    hash_value = _FNV_OFFSET_BASIS
    for char in document_root.encode("utf-8"):
        hash_value ^= char
        hash_value = (hash_value * _FNV_PRIME) & 0xFFFFFFFFFFFFFFFF  # Keep 64-bit
    return f"{hash_value:016x}"


def create_website_token_directory(user: str, document_root: str):
    """
    Create website token directory structure and files in /var/cagefs.

    Creates:
    - /var/cagefs/<user>/.cagefs/website/<website_id>/ - token directory
    """
    pw = ClPwd().get_pw_by_name(user)
    website_id = get_website_id(document_root)

    # Token directory in /var/cagefs
    website_base_dir = Path(get_user_var_cagefs_path(user)) / ".cagefs/website"
    website_dir = website_base_dir / website_id

    # mode to prevent directory listing by other users
    website_base_dir.mkdir(exist_ok=True, parents=True, mode=0o751)
    # mode to allow user list /var/.cagefs when it's mounted for website
    website_dir.mkdir(exist_ok=True, mode=0o755)
    # Force-correct ownership and mode in case the directory was created
    # earlier by the per-domain ns-cache code with a different uid/gid context
    # (e.g. as root:nobody from an Apache-spawned lsphp request that hit the
    # jail before this site's isolation was enabled). mkdir(exist_ok=True)
    # is a no-op for an existing directory and would not heal those perms.
    # Use the O_NOFOLLOW helper so a symlink swapped in at <website_dir>
    # between mkdir and chmod/chown raises ELOOP rather than being followed.
    apply_metadata_nofollow(str(website_dir), 0o755, 0, 0)

    token = _generate_password(32)

    # create token file read-only for owner, others cannot read it
    # this replicates behavior of regular token
    # note: user can still use chown to change it
    # token with NON 400 mode would be rejected by cagefs.server.c:check_tokenfile_perms()
    token_file_path = f"{website_dir}/.cagefs.token"
    write_via_tmp(website_dir, token_file_path, token)
    apply_metadata_nofollow(token_file_path, 0o400, pw.pw_uid, 0)

    # create file with document root path
    # that we can use as trusted source in proxyexec
    docroot_file_path = f"{website_dir}/.cagefs.website"
    write_via_tmp(website_dir, docroot_file_path, document_root)
    # only read permissions, without modification
    # we use this marker as a trusted source of document root
    # it should not be modifiable by user in any way
    apply_metadata_nofollow(docroot_file_path, 0o444, 0, 0)


def _mkdir_nofollow_under(parent_fd: int, name: str, mode: int) -> int:
    """Create or open ``name`` under ``parent_fd`` rejecting any symlink.

    mkdirat(name, parent_fd) followed by openat(name, parent_fd,
    O_NOFOLLOW | O_DIRECTORY) — the open raises ELOOP if the path was
    pre-planted as a symlink and ENOTDIR if it is a non-directory inode.
    EEXIST on the mkdir is benign (idempotent re-run); other errors
    propagate. Returns an fd opened on the real directory inode that
    the caller is responsible for closing.
    """
    try:
        os.mkdir(name, mode=mode, dir_fd=parent_fd)
    except FileExistsError:
        pass
    return os.open(
        name,
        os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY | os.O_CLOEXEC,
        dir_fd=parent_fd,
    )


def create_overlay_storage_directory(user: str, document_root: str):
    """
    Create overlay storage directory in user's home.

    Creates:
    - <homedir>/.cagefs/websites/<website_id>/ - storage base for overlays

    Drops privileges to user before creating to ensure proper ownership.
    """
    pw = ClPwd().get_pw_by_name(user)
    if not Path(pw.pw_dir).exists():
        return

    # Walk the path component-by-component with O_NOFOLLOW on every
    # openat. Path.mkdir(parents=True, exist_ok=True) is a no-op when a
    # symlink already sits on the path, so the same path string would
    # later be embedded verbatim as a bind-mount source in
    # isolates.mounts and dereferenced by the root-run jail consumer —
    # letting the tenant pre-aim the storage path away from their home.
    # Rejecting symlinks on each component (including .cagefs and
    # .cagefs/websites, which both live in tenant-writable space)
    # closes that. Done under drop_privileges so the mkdirs land with
    # the tenant's uid/gid; the resolved fds are opened against the
    # real inode, not via path resolution after the fact.
    home_fd = os.open(pw.pw_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
    cagefs_fd = None
    websites_fd = None
    leaf_fd = None
    try:
        # Ensure ``.cagefs`` exists as the tenant so CageFS proper's own
        # tenant-side flows keep working (see the .cagefs-must-stay-
        # user-owned note below). ``.cagefs/websites`` also starts here
        # tenant-owned on the very first call; after the seal below it
        # becomes root:root 0755 and subsequent calls open it as root
        # (traversal permitted for other via 0755).
        with drop_privileges(user):
            cagefs_fd = _mkdir_nofollow_under(home_fd, ".cagefs", 0o750)
            websites_fd = _mkdir_nofollow_under(cagefs_fd, "websites", 0o750)
        # Post-review Bugbot HIGH on !215: create the per-website leaf
        # as ROOT (outside drop_privileges). Once ``websites`` is sealed
        # root:root 0755, the tenant cannot mkdir a new leaf under it —
        # a second-site provisioning would otherwise fail with EACCES
        # and multi-site isolation would break. Creating as root works
        # on both the first call (websites still tenant-owned 0o750,
        # tenant-writable, but we skip drop_privileges) and every call
        # after (websites is root:root 0o755, only root can mkdir).
        # The leaf mode 0o755 matches the sealed shape it will settle
        # into so a legitimate root:root leaf exists whether or not the
        # fchown below succeeds (dev/unit-test env).
        leaf_fd = _mkdir_nofollow_under(
            websites_fd, get_website_id(document_root), 0o755
        )
        # F-15 (CLOS-5953) DiD: seal ``.cagefs/websites`` and the per-
        # website leaf as root:root mode 0755. The pre-mkdir walk above
        # already rejects a symlink planted BEFORE this call, and
        # sealing the leaf alone was not enough: rename/unlink/create-
        # child permissions live on the PARENT inode, so a tenant-owned
        # ``.cagefs/websites`` parent would still let the tenant `mv`
        # the leaf aside and put a symlink in its place, at which point
        # MS_BIND on a child of the replacement leaf would follow the
        # symlink and mount an arbitrary host path into the isolated
        # namespace. Sealing ``websites`` blocks the rename/replace at
        # that boundary.
        #
        # Note (Bugbot HIGH follow-up): do NOT seal ``.cagefs`` itself —
        # CageFS proper owns that as user:user mode 0771 for its own
        # model (``update_status``, ``configure_alt_php``, PHP selector
        # paths, ``cagefs_enter``). Sealing ``.cagefs`` to root breaks
        # those tenant-side flows. ``websites`` is a
        # website-isolation-only subdir, so re-owning it is safe.
        # Uses fchown/fchmod on the fds captured during the walk (bound
        # to the real inodes) — no path re-resolve. Non-root callers
        # (unit tests, dev harness) get EPERM and the seal is a no-op;
        # the production entry point runs as root via cagefsctl so this
        # branch always applies there. Sealed order is inside-out (leaf
        # → websites) so a partial failure still leaves the leaf
        # sealed. Mode 0755 lets the tenant traverse to descendants
        # that root subsequently creates (bind mount points still work)
        # but blocks unlink/rename/create at each sealed level.
        for fd_to_seal in (leaf_fd, websites_fd):
            if fd_to_seal is None:
                continue
            try:
                os.fchown(fd_to_seal, 0, 0)
                os.fchmod(fd_to_seal, 0o755)
            except PermissionError:
                pass
    finally:
        for fd in (leaf_fd, websites_fd, cagefs_fd):
            if fd is not None:
                os.close(fd)
        os.close(home_fd)


def seal_overlay_storage_ancestors(user: str, document_root: str) -> None:
    """
    Re-apply the F-15 (CLOS-5953) DiD seal on the overlay-storage
    ancestor chain and the per-website leaf.

    Idempotent by design: it re-runs the same fd-bound fchown/fchmod
    the initial ``create_overlay_storage_directory`` did, plus the same
    O_NOFOLLOW walk. Callers use it AFTER any provisioning step that
    can re-chown the leaf back to the tenant. In particular
    ``cagefsctl --rebuild-alt-php-ini`` (called during
    ``enable_website_isolation``) reaches
    ``selector.configure.configure_alt_php`` which invokes
    ``make_userdir(<.cagefs>/websites/<website_id>, 0o771, uid, gid)``
    -- that resets the leaf to tenant:tenant 0o771 immediately before
    ``write_jail_mounts_config`` runs, undoing the initial seal. Re-
    calling this after the alt_php step restores root:root 0o755 on
    the leaf so a tenant symlink swap between then and the root-run
    bind mounter cannot land.

    Silent no-op when the overlay tree does not exist (isolation not
    enabled for this website, or teardown path). Non-root callers
    (unit tests, dev harness) get EPERM and the seal becomes a no-op;
    the production entry point runs as root via cagefsctl.
    """
    pw = ClPwd().get_pw_by_name(user)
    if not pw or not Path(pw.pw_dir).exists():
        return
    website_id = get_website_id(document_root)
    try:
        home_fd = os.open(
            pw.pw_dir, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC
        )
    except OSError:
        return
    cagefs_fd = websites_fd = leaf_fd = None
    try:
        try:
            cagefs_fd = os.open(
                ".cagefs",
                os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY | os.O_CLOEXEC,
                dir_fd=home_fd,
            )
        except OSError:
            return
        try:
            websites_fd = os.open(
                "websites",
                os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY | os.O_CLOEXEC,
                dir_fd=cagefs_fd,
            )
        except OSError:
            return
        try:
            leaf_fd = os.open(
                website_id,
                os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY | os.O_CLOEXEC,
                dir_fd=websites_fd,
            )
        except OSError:
            leaf_fd = None
        for fd_to_seal in (leaf_fd, websites_fd):
            if fd_to_seal is None:
                continue
            try:
                os.fchown(fd_to_seal, 0, 0)
                os.fchmod(fd_to_seal, 0o755)
            except PermissionError:
                pass
    finally:
        for fd in (leaf_fd, websites_fd, cagefs_fd):
            if fd is not None:
                os.close(fd)
        os.close(home_fd)


def remove_website_token_directory(user: str, document_root: str):
    """
    Remove website token directory structure and files.
    """
    website_base_dir = Path(get_user_var_cagefs_path(user)) / ".cagefs/website"

    website_dir = website_base_dir / get_website_id(document_root)

    if website_dir.exists():
        shutil.rmtree(website_dir)


def website_suffix_with_hash(docroot):
    """
    Returns path: websites/<document_root_hash>
    """
    return os.path.join("websites", get_website_id(docroot))


def full_website_path(homedir, docroot):
    """
    Returns <homedir>/.cagefs/websites/<document_root_hash>
    """
    return os.path.join(homedir, ".cagefs", website_suffix_with_hash(docroot))


def invalidate_ns_cache(user: str, document_root: str):
    """
    Removes cached namespace from disk
    """
    website_base_dir = Path(get_user_var_cagefs_path(user)) / ".cagefs/website"
    website_dir = website_base_dir / get_website_id(document_root)
    (website_dir / ".cagefs.mnt").unlink(missing_ok=True)


def _generate_password(length):
    """
    Generate a random password/token using the same algorithm as the C function.
    Uses cryptographically secure random bytes and converts them to alphanumeric characters.
    """
    if length == 0 or length > 256:
        raise ValueError("Invalid buffer length requested")

    charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    charset_size = len(charset)

    # Generate random bytes
    random_bytes = secrets.token_bytes(length)

    # Convert bytes to alphanumeric characters
    result = "".join(charset[b % charset_size] for b in random_bytes)
    return result