haxorqt
Home
Console
Upload
information
Create File
Create Folder
About
:
/
opt
/
cloudlinux
/
venv
/
lib
/
python3.11
/
site-packages
/
clcagefslib
/
webisolation
/
Filename :
jail_config_builder.py
back
Copy
#!/opt/cloudlinux/venv/bin/python3 -sbb # -*- coding: utf-8 -*- # # Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2025 All Rights Reserved # # Licensed under CLOUD LINUX LICENSE AGREEMENT # http://cloudlinux.com/docs/LICENCE.TXT # """ Builder for website isolation jail mount configurations. Collects user docroots and isolation settings, then generates the complete jail mount configuration. """ import logging from pathlib import Path from clcommon import ClPwd from clcommon.cpapi import userdomains from clcommon.cpapi.cpapiexceptions import NoPanelUser from ..io import write_via_tmp from . import config, jail_utils from .docroot_validation import validate_docroot, validate_docroot_no_symlinks from .jail_config import MountConfig from .mount_config import IsolatedRootConfig from .mount_ordering import build_docroot_tree, process_ordered_mounts from .mount_types import MountType class JailMountsConfigBuilder: """ Builder for generating jail mount configuration files. Collects docroots and isolation settings, then generates the mount configuration string for the jail.c implementation. """ def __init__(self, user: str): self.user = user self._all_docroots: set[str] = set() self._isolated_docroots: set[str] = set() self._phpselector_docroots: set[str] = set() # F-14 (CLOS-5952) DiD bookkeeping: overlay paths whose # symlink-safety was verified during build(). Kept so callers # can re-run validation immediately before persisting the # generated config to disk (see revalidate() below and its # call site in write_jail_mounts_config). self._validated_paths: list[tuple[str, str, str]] = [] def add_docroot(self, docroot: str) -> None: """Register a docroot path for the user.""" self._all_docroots.add(docroot) def enable_isolation(self, docroot: str) -> None: """Mark a docroot as requiring isolation.""" self._isolated_docroots.add(docroot) def enable_phpselector(self, docroot: str) -> None: """Enable per-domain PHP selector for a docroot.""" self._phpselector_docroots.add(docroot) def build(self) -> str: """ Generate the complete mount configuration. Returns: Configuration string in jail.c mount syntax. """ pw = ClPwd().get_pw_by_name(self.user) homedir = pw.pw_dir uid, gid = pw.pw_uid, pw.pw_gid # Build docroot tree once for all isolated docroots tree = build_docroot_tree(self._all_docroots) # ~/.clwpos becomes a BIND source under the root-run jail mounter # (see mount_config.py:47 -> isolates.mounts -> bind(2) with # MS_BIND, which dereferences symlinks on the source). The tenant # owns their home and can replace .clwpos with a symlink aimed at # / or another tenant's home, escaping the isolated tree. Refuse # to bind .clwpos when the O_NOFOLLOW component walk under the # resolved homedir rejects any path component as a symlink or # a non-directory; skipping this single entry is safe (it only # exposes the AWP redis.sock) and leaves the rest of isolation # intact for the user. Same validator as # validate_docroot_no_symlinks used for panel docroots. awp_path = f"{homedir}/.clwpos" try: validate_docroot_no_symlinks(awp_path, homedir) awp_path_safe = True except ValueError as exc: logging.warning( "Skipping .clwpos bind mount for user %s: %s", self.user, exc, ) awp_path_safe = False # Generate config for each isolated docroot generated_configs = [] for docroot in sorted(self._isolated_docroots, key=len): split_storage_base = jail_utils.full_website_path(homedir, docroot) # F-10 (CLOS-5397): the per-website overlay base # `<homedir>/.cagefs/websites/<hash>` and its two ancestors # (`.cagefs`, `websites`) are all created uid-owned mode 0o750 # inside `drop_privileges(user)` by create_overlay_storage_directory # (jail_utils.py:_mkdir_nofollow_under's O_NOFOLLOW discipline # only pins the creation moment). The tenant can therefore # `rmdir` / `rm -rf` any component and replace it with a # symlink at any subsequent moment. The IsolatedRootConfig # below feeds `<split_storage_base>/home` (not the base # itself) to the root-run jail mounter as a bind(2) source # string, and MS_BIND dereferences symlinks on the source. # Validate BOTH the base AND the `/home` bind-source child # independently: `os.path.realpath` only canonicalises the # exact path it is given (with strict=False any non-existent # tail is left in place), so a guard on the base alone # misses a tenant-planted symlink at the `home` child # (`ln -s /etc <hash>/home`). Same realpath+prefix shape as # the .clwpos guard above; refuse the whole isolated # docroot when either the resolved storage base or the # resolved home-overlay source escapes the resolved home # tree (skipping isolation for this one docroot is safe - # the rest of the user's websites continue to be isolated). home_overlay_source = f"{split_storage_base}/home" try: validate_docroot_no_symlinks(split_storage_base, homedir) validate_docroot_no_symlinks(home_overlay_source, homedir) except ValueError as exc: logging.warning( "Skipping isolated docroot %s for user %s: unsafe overlay storage path: %s", docroot, self.user, exc, ) continue # F-14 (CLOS-5952) DiD: remember the paths that passed the # symlink-safety check so write_jail_mounts_config() can # re-run the check immediately before persisting the # generated config. Shrinks the TOCTOU between config-gen # and root-side mount consumption; a full close of the # window requires the root-run jail mounter (jail.c) to # itself bind via openat2(RESOLVE_NO_SYMLINKS) / an # O_PATH-pinned /proc/self/fd source — tracked as F-11 # (CLOS-5949), F-15 (CLOS-5953), F-16 (CLOS-5954). self._validated_paths.append( (docroot, split_storage_base, home_overlay_source) ) home_overlay = IsolatedRootConfig( root_path=home_overlay_source, target=homedir, persistent=True ) # Process ordered mounts for this isolated docroot docroot_mounts = process_ordered_mounts( active_docroot=docroot, tree=tree, uid=uid, gid=gid ) jail_config = MountConfig(uid=uid, gid=gid) # Add storage for the overlay'ed dir jail_config.add_overlay(home_overlay) # open .clwpos directory to make redis.sock available if awp_path_safe: home_overlay.mount(MountType.BIND, awp_path, awp_path, ("mkdir",)) # Add docroot mounts (from tree processing) # Mount them into already created overlay for mount in docroot_mounts: home_overlay.mount(mount.type, mount.source, mount.target, mount.options) # Apply mounts from isolated root and close target directory jail_config.close_overlay(home_overlay) # Home directory is already overlayed, we can apply per-domain mounts directly jail_config.add(MountType.USER_MOUNTS, "/") # php selector mounts (only when per-domain PHP selector is enabled) if docroot in self._phpselector_docroots: # CLOS-4351: bind the user-level cl.selector dir over # /usr/selector and /usr/selector.etc so per-domain symlinks # of the form `lsphp -> /usr/selector/lsphp` (written when # per-domain selector is 'native') resolve to the user's # account-default alt-php binary instead of the 0-byte # placeholder file in cagefs-skeleton. Done before the # /etc/cl.selector replacement below so the source string # still resolves to the user-level dir at mount time; # subsequent re-mounts of /etc/cl.selector do not disturb # the established /usr/selector mount. jail_config.add( MountType.BIND, source="/etc/cl.selector", target="/usr/selector" ) jail_config.add( MountType.BIND, source="/etc/cl.selector", target="/usr/selector.etc" ) jail_config.add( MountType.BIND, source=f"/etc/cl.selector/{jail_utils.get_website_id(docroot)}", target="/etc/cl.selector" ) jail_config.add( MountType.BIND, source=f"/etc/cl.php.d/{jail_utils.get_website_id(docroot)}", target="/etc/cl.php.d" ) # Override proxyexec token with website specific folder jail_config.add( MountType.BIND, source=f"/var/.cagefs/website/{jail_utils.get_website_id(docroot)}", target="/var/.cagefs", ) generated_configs.append(jail_config.render(docroot)) return "\n".join(generated_configs) def revalidate(self, homedir: str) -> None: """Re-run the symlink-safety check on every overlay path that passed validation during ``build()``. F-14 (CLOS-5952) DiD: paths under ``<homedir>/.cagefs/websites`` are tenant-owned and can be swapped for a symlink between the check in ``build()`` and persistence. Re-run the walk here to shrink that window; the primary defence is jail.c's ``open_directory_nofollow`` on the C bind sink. Missing overlay / split-storage / `.clwpos` leaves are treated as safe (the C mounter materialises them via its ``mkdir`` opt); the symlink- at-leaf race is caught at the C sink regardless. Raises: ValueError: If any previously validated path now fails the symlink-safety check (symlink planted, or path escapes the tenant home). """ for _docroot, split_storage_base, home_overlay_source in self._validated_paths: validate_docroot_no_symlinks(split_storage_base, homedir) validate_docroot_no_symlinks(home_overlay_source, homedir) def write_jail_mounts_config(user: str, user_config: config.UserConfig | None) -> None: """ Write or remove the jail mounts configuration file for a user. If user_config is None or has no enabled websites, the config file is removed. Otherwise, builds and writes the mount configuration. Args: user: Username to generate config for user_config: User's isolation configuration, or None to remove config """ jail_config_path = Path(jail_utils.get_jail_config_path(user)) if user_config is None or not user_config.enabled_websites: jail_config_path.unlink(missing_ok=True) return builder = JailMountsConfigBuilder(user) try: domain_to_docroot_map = dict(userdomains(user)) except NoPanelUser: logging.warning("Cannot regenerate mount configuration, no panel user=%s", user) return # Resolved user home is the allowed prefix for the on-disk # symlink-rejection check below. Resolve here once so a benign # operator-installed symlink like /home -> /home2 does not produce # false negatives for every domain. user_home = ClPwd().get_pw_by_name(user).pw_dir # Defense-in-depth at the second trust boundary: docroot values come # back from the panel and flow straight into mount-line source/target # (mount_types.py) and the bracketed jail section header # (jail_config.py). Drop entries that do not pass the strict allowlist # so a single malformed panel record cannot corrupt the mount file. # Also drop entries whose on-disk path escapes the user's home - the # docroot becomes a BIND source (mount_ordering.py:120,127) consumed # by the root-run jail mounter, and MS_BIND dereferences symlinks # on the source. See validate_docroot_no_symlinks. for domain, docroot in list(domain_to_docroot_map.items()): try: validate_docroot(docroot) validate_docroot_no_symlinks(docroot, user_home) except ValueError as exc: logging.warning( "Skipping domain %s with invalid docroot for user %s: %s", domain, user, exc, ) del domain_to_docroot_map[domain] # add docroot information for isolations for docroot in domain_to_docroot_map.values(): builder.add_docroot(docroot) # add information about which websites should have isolation enabled for domain in user_config.enabled_websites: try: docroot = domain_to_docroot_map[domain] except KeyError: logging.warning("Docroot not found for domain %s", domain) continue builder.enable_isolation(docroot) # PHP Selector is enabled for all isolated websites for domain in user_config.enabled_websites: try: docroot = domain_to_docroot_map[domain] except KeyError: logging.warning("Docroot not found for domain %s", domain) continue builder.enable_phpselector(docroot) result = builder.build() # F-07 (CLOS-5945) DiD: revalidate every panel docroot ONCE MORE # at the moment the config is committed to disk, so the # check-then-write window inside this function is closed. The # earlier docroot sweep runs before builder.build() (~140 lines of # pure Python); a final sweep pins the last observed state right # before write_via_tmp. The true C-side check-then-mount gap is # handled by jail.c's openat2(RESOLVE_NO_SYMLINKS) bind path — # this Python sweep is defence-in-depth for early rejection. for domain, docroot in list(domain_to_docroot_map.items()): try: validate_docroot(docroot) validate_docroot_no_symlinks(docroot, user_home) except ValueError as exc: logging.warning( "Refusing to write mount config for user %s: docroot " "for domain %s became invalid before disk commit: %s", user, domain, exc, ) return # F-14 (CLOS-5952) DiD: F-07 above covers panel docroots; this # sweep covers the overlay / split-storage / .clwpos bind sources # tracked separately inside the builder. If any of those was # swapped for a symlink between build() and here, refuse to # persist so the previously persisted safe config stays in place. try: builder.revalidate(user_home) except ValueError as exc: logging.warning( "Refusing to persist jail mounts config for user %s: overlay path changed after generation: %s", user, exc, ) return jail_config_path.parent.mkdir(exist_ok=True, mode=0o755) write_via_tmp(str(jail_config_path.parent), str(jail_config_path), result)