| Current Path : /opt/cloudlinux/venv/lib64/python3.11/site-packages/clcagefslib/webisolation/ |
| 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