Heray-Was-Here
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
Directory :  /opt/cloudlinux/venv/lib/python3.11/site-packages/xray/internal/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Current File : //opt/cloudlinux/venv/lib/python3.11/site-packages/xray/internal/phpinfo_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/LICENSE.TXT
import dataclasses
import pwd
import re
import secrets
import subprocess
from contextlib import contextmanager
from pathlib import Path
from typing import Optional

from urllib.parse import urlsplit, urlunsplit

import requests
import urllib3
from clcommon.clpwd import drop_privileges
from clcommon.cpapi import docroot
from requests.adapters import HTTPAdapter
from requests.exceptions import ChunkedEncodingError
from secureio import disable_quota
from urllib3.exceptions import ReadTimeoutError

from xray import gettext as _
from xray.internal import utils, exceptions
from xray.internal.constants import is_allowed_ini_path
from xray.internal.user_plugin_utils import verify_user_owns_domain

# long timeout is set because our tested
# sites may be really slow
TIMEOUT: int = 10
# The phpinfo probe targets a vhost hosted on THIS machine. The hostname is
# tenant-controlled and resolves via public DNS, so following DNS would let a
# tenant repoint their domain at an arbitrary host and turn the root daemon's
# probe into an SSRF primitive. Pin the connection to loopback and preserve the
# original Host header so the local webserver still routes to the right vhost.
_PIN_ADDRESS: str = '127.0.0.1'
# Cap the response body at 1 MiB. The phpinfo payload we ship in
# `_temporary_phpinfo_file` is well under 1 KiB, so 1 MiB leaves a
# generous margin while preventing a tenant-controlled webserver from
# inflating the root daemon's RSS via an unbounded response body.
_MAX_RESPONSE_BYTES: int = 1024 * 1024
_CHUNK_BYTES: int = 8192
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.13) '
                  'Gecko/20101209 CentOS/3.6-2.el5.centos Firefox/3.6.13'
}


class WebsiteNotResponding(exceptions.XRayManagerError):
    def __init__(self, url, details):
        self.url = url
        self.details = details


class _BoundedResponse:
    """A drop-in replacement exposing `.text` from a bounded streamed read."""

    __slots__ = ('text',)

    def __init__(self, text: str):
        self.text = text


class _LoopbackPinnedAdapter(HTTPAdapter):
    """requests adapter that pins every connection to loopback.

    The vhost is hosted on this machine; we must not follow the
    tenant-controlled public-DNS record. We rewrite the connect target to
    ``_PIN_ADDRESS`` while keeping the original hostname in the Host header so
    the local webserver routes to the correct vhost. The TLS SNI/cert mismatch
    that results is tolerated because the caller already requests verify=False.
    """

    def send(self, request, **kwargs):
        split = urlsplit(request.url)
        original_host = split.hostname
        if original_host is not None:
            request.headers['Host'] = split.netloc
            pinned_netloc = _PIN_ADDRESS
            if split.port is not None:
                pinned_netloc = f'{_PIN_ADDRESS}:{split.port}'
            request.url = urlunsplit(split._replace(netloc=pinned_netloc))
        return super().send(request, **kwargs)


def _pinned_session() -> requests.Session:
    session = requests.Session()
    adapter = _LoopbackPinnedAdapter()
    session.mount('http://', adapter)
    session.mount('https://', adapter)
    return session


@utils.retry_on_exceptions(3, [ChunkedEncodingError])
def _request_url(url):
    """
    retry on:
     - ChunkedEncodingError -> sometimes error happens due to network issues/glitch
    """
    try:
        with _pinned_session() as session, \
                session.get(url, timeout=TIMEOUT, verify=False,
                            headers=HEADERS, stream=True) as response:
            response.raise_for_status()
            buf = bytearray()
            for chunk in response.iter_content(chunk_size=_CHUNK_BYTES):
                if not chunk:
                    continue
                if len(buf) + len(chunk) > _MAX_RESPONSE_BYTES:
                    raise exceptions.XRayManagerError(
                        _("phpinfo response for %s exceeds %d bytes; "
                          "refusing to buffer attacker-controlled body.")
                        % (url, _MAX_RESPONSE_BYTES))
                buf.extend(chunk)
            # NB: do not use response.apparent_encoding here — with stream=True
            # the body is already consumed by iter_content(), so accessing it
            # touches response.content and raises RuntimeError. The buffered
            # bytes are decoded with errors='replace', so utf-8 is a safe
            # fallback when the server sends no charset in Content-Type.
            encoding = response.encoding or 'utf-8'
            try:
                text = buf.decode(encoding, errors='replace')
            except LookupError:
                # response.encoding comes from the tenant-controlled
                # Content-Type charset; an unknown codec name (e.g.
                # charset=bogus) raises LookupError that errors='replace'
                # does not catch. requests' own .text falls back here, so
                # mirror that and decode as utf-8.
                text = buf.decode('utf-8', errors='replace')
    except ConnectionError as e:
        # really strange behavior of requests that wrap
        # errors inside of ConnectionError
        if e.args and isinstance(e.args[0], ReadTimeoutError):
            raise
        raise WebsiteNotResponding(url, details=str(e))
    except requests.RequestException as e:
        raise exceptions.XRayManagerError(
            _("Unable to detect php version for website "
              "because it is not accessible. "
              "Try again and contact an administrator if the issue persists. "
              "Original error: %s. ") % str(e))

    return _BoundedResponse(text)


@contextmanager
def _temporary_phpinfo_file(username: str, document_root: Path):
    php_file_contents = """
<?php

$php_ini_scan_dir = getenv("PHP_INI_SCAN_DIR");
if(!empty($php_ini_scan_dir)) {
  // get first non-empty path
  $php_ini_scan_dir = array_values(array_filter(explode(":", $php_ini_scan_dir)))[0];
}


echo "phpversion=" . phpversion() . "\n";
echo "ini_scan_dir=" . ($php_ini_scan_dir ? $php_ini_scan_dir: PHP_CONFIG_FILE_SCAN_DIR) . "\n";
echo "php_sapi_name=". php_sapi_name() . "\n";
echo "include_path=" . get_include_path() . "\n";

"""
    php_file_name = f'xray_info_{secrets.token_hex(8)}.php'
    php_file_path = document_root / php_file_name
    with drop_privileges(username), disable_quota():
        php_file_path.write_text(php_file_contents)

    try:
        yield php_file_name
    finally:
        php_file_path.unlink()

@dataclasses.dataclass
class PhpConfiguration:
    # 'user'
    username: str
    # '8.3.30'
    phpversion: str
    # '/etc/php.d/'
    ini_scan_dir: str
    # 'cgi-fcgi'
    php_sapi_name: str
    # '.:/opt/alt/php80/usr/share/pear'
    include_path: str
    # The account's REAL provisioned PHP HANDLER, as known to the panel — kept
    # in its FULL, distro-carrying shape (cPanel domain['version'] 'ea-php80',
    # Plesk resolve_lsphp_version(handler) 'plesk-php80'/'alt-php80', DirectAdmin
    # opts[...]['ver'] 'php80', custom domain_conf.php.version '80'). This is a
    # SERVER-side trust anchor, distinct from `phpversion` which is parsed from
    # the tenant-served probe body and is therefore attacker-controlled. NOT
    # stripped to a bare number: the distro family is exactly what binds branch
    # (3) to the user's OWN per-distro+version INI_LOCATION (a same-number dir of
    # a DIFFERENT distro family must be rejected). None — or a bare number that
    # carries no distro — disables the global per-version accept branch.
    trusted_php_version: Optional[str] = None

    @property
    def short_php_version(self) -> str:
        return ''.join(self.phpversion.split('.')[:2])

    def get_full_php_version(self, default_prefix: str):
        if '/opt/alt' in self.include_path:
            return f"alt-php{self.short_php_version}"
        return f"{default_prefix}{self.short_php_version}"

    @property
    def absolute_ini_scan_dir(self):
        # the only directory that we expect to be changed in cagefs
        # is our conf link which is managed by selectorctl
        if 'link/conf' in self.ini_scan_dir:
            resolved = _resolve_ini_path_in_cagefs(self.username, self.ini_scan_dir)
        else:
            resolved = self.ini_scan_dir

        if resolved:
            # Resolve symlinks/.. and require the directory to exist, so a
            # symlink or traversal escape cannot point the privileged xray.ini
            # write outside the realpath we then bind to the tenant.
            try:
                resolved = str(Path(resolved).resolve(strict=True))
            except (OSError, RuntimeError):
                raise ValueError(
                    f'ini_scan_dir does not resolve to an existing dir: '
                    f'{resolved!r}'
                )
            if not is_allowed_ini_path(resolved):
                raise ValueError(
                    f'ini_scan_dir outside allowed paths: {resolved!r}'
                )
            # The probe response is served from the tenant's own docroot, so
            # ini_scan_dir is tenant-controlled. ALLOWED_INI_PREFIXES is a
            # prefix-only allowlist that ALSO admits global root-owned dirs
            # (/etc/php.d, /etc/cl.php.d, /usr/share/cagefs[-skeleton],
            # /opt/cpanel/ea-php*, ...) and the shared CageFS root
            # (/var/cagefs/) — accepting any of those unconditionally would let a
            # tenant steer the root-performed xray.ini write into a dir loaded by
            # all PHP or another tenant's jail. Default-deny: accept ONLY paths
            # provably tied to self.username (own CageFS subtree, own /etc/users
            # dir) OR the EXACT global INI_LOCATION of the account's TRUSTED,
            # full provisioned handler (distro+version), not the probe's and not
            # a distro-blind same-number match.
            if not _ini_scan_dir_belongs_to_user(
                    self.username, resolved, self.trusted_php_version):
                raise ValueError(
                    f'ini_scan_dir not bound to user {self.username!r}: '
                    f'{resolved!r}'
                )
        return resolved

    @property
    def is_php_fpm(self):
        return self.php_sapi_name == 'fpm-fcgi'

def _parse_configuration(
        username: str, response: str,
        trusted_php_version: Optional[str] = None) -> PhpConfiguration:
    config = {}
    for line in response.split('\n'):
        if not line.strip():
            continue

        key, value = line.split('=')

        config[key] = value.strip()

    # trusted_php_version is normalised by PhpConfiguration.__post_init__.
    return PhpConfiguration(
        username=username,
        trusted_php_version=trusted_php_version,
        **config)


# Root of the shared CageFS tree. A resolved dir under here is the tenant's own
# area only when it sits inside /var/cagefs/<prefix>/<username>/...
_CAGEFS_ROOT = '/var/cagefs/'

# Root of the per-domain panel scan dirs (LiteSpeed/cPanel custom vhosts):
# /etc/users/<panel-account>/php/<vhost>, where <panel-account> is the same
# account passed as self.username to get_php_configuration. The branch accepts
# only /etc/users/<self.username>/... (separator boundary), so a probe pointing
# at another account's /etc/users/<other>/ subtree is rejected.
_USERS_INI_ROOT = '/etc/users/'

# Per-distro INI_LOCATION templates, keyed by the TRUSTED handler's distro
# family. Each maps the FULL handler 'family-phpNN' (or DirectAdmin's 'phpNN')
# to the ONE global, root-owned, server-wide INI_LOCATION that the user's
# provisioned per-distro+version PHP scans (see docs/design/xray-ini-placement.md
# "Server-wide locations" and base.py VERSIONS / the per-panel VERSIONS_* maps,
# which carry the same alt-php→/opt/alt/phpNN/link/conf,
# ea-php→/opt/cpanel/ea-phpNN/root/etc/php.d, plesk-php→/opt/plesk/php/X.Y/etc/php.d,
# php→/usr/local/phpNN/lib/php.conf.d mappings).
#
# The expected dir is selected BY the trusted handler's distro family, never by
# the dir's own family — so a tenant whose trusted handler is ea-php80 maps ONLY
# to /opt/cpanel/ea-php80/root/etc/php.d, and a probe reporting the same-number
# alt-php dir /opt/alt/php80/link/conf does NOT match (cross-distro spoof closed).
#
# A handler that carries no distro family (a bare number, e.g. custom's
# domain_conf.php.version '80') matches no template and yields None, so branch
# (3) default-denies for it — the expected dir cannot be uniquely derived.
#
# The cagefs skeleton/mirror roots (/usr/share/cagefs/.cpanel.multiphp/...,
# /usr/share/cagefs-skeleton/...) and any other-shape subdir (xray.so modules,
# /opt/alt/phpNN/etc/php.d, ...) are excluded for free: they are not equal to the
# single derived INI_LOCATION, so the equality gate rejects them (default-deny).
_HANDLER_INI_LOCATION_TEMPLATES = (
    # cPanel ea-php: 'ea-php80' -> /opt/cpanel/ea-php80/root/etc/php.d
    (re.compile(r'^ea-php(?P<v>\d{2})$'),
     '/opt/cpanel/ea-php{v}/root/etc/php.d'),
    # alt-php (cl.selector / Plesk lsphp): 'alt-php80' -> /opt/alt/php80/link/conf
    (re.compile(r'^alt-php(?P<v>\d{2})$'),
     '/opt/alt/php{v}/link/conf'),
    # Plesk native: 'plesk-php80' -> /opt/plesk/php/8.0/etc/php.d (dotted X.Y)
    (re.compile(r'^plesk-php(?P<maj>\d)(?P<min>\d)$'),
     '/opt/plesk/php/{maj}.{min}/etc/php.d'),
    # DirectAdmin native / usr-local: 'php80' -> /usr/local/php80/lib/php.conf.d
    (re.compile(r'^php(?P<v>\d{2})$'),
     '/usr/local/php{v}/lib/php.conf.d'),
)


def _expected_ini_location(trusted_handler: Optional[str]) -> Optional[str]:
    """Build the single global INI_LOCATION for a TRUSTED, FULL PHP handler.

    ``trusted_handler`` is the account's REAL provisioned handler carrying its
    distro family ('ea-php80', 'alt-php80', 'plesk-php80', 'php80'). Returns the
    ONE server-wide per-distro+version dir that handler's PHP scans, keyed BY the
    distro family — so the derived dir's family always equals the trusted
    handler's family, never the family of whatever dir the probe reported.

    Returns None when ``trusted_handler`` is empty or carries no distro family
    (a bare number such as custom's '80'): the expected dir cannot be uniquely
    derived, so the caller default-denies the global per-version accept branch.
    """
    if not trusted_handler:
        return None
    for pattern, template in _HANDLER_INI_LOCATION_TEMPLATES:
        match = pattern.match(str(trusted_handler))
        if match:
            return template.format(**match.groupdict())
    return None


def _is_within(root: str, resolved: str) -> bool:
    """True if ``resolved`` is ``root`` itself or a path strictly inside it.

    The trailing-separator boundary stops /var/cagefs/01/aliceEVIL from being
    treated as inside alice's /var/cagefs/01/alice subtree.
    """
    root = root.rstrip('/')
    return resolved == root or resolved.startswith(root + '/')


def _ini_scan_dir_belongs_to_user(
        username: str, resolved: str,
        trusted_php_version: Optional[str] = None) -> bool:
    """Confirm a resolved (realpath'd) ini_scan_dir is one the tenant may drive.

    ``resolved`` has already been realpath'd and prefix-checked against
    ALLOWED_INI_PREFIXES. That prefix list is intentionally broad and admits
    global root-owned dirs, so this is the default-deny binding gate: accept
    ONLY paths provably tied to the principal, reject everything else.

    In phpinfo mode the legitimate ini_scan_dir is one of:
      * the tenant's own CageFS subtree (the link/conf branch is re-rooted there
        by _resolve_ini_path_in_cagefs(self.username, ...));
      * the tenant's own per-domain /etc/users/<self.username>/... dir;
      * for a NON-CageFS tenant (ea-php / Plesk / native), the global, root-owned
        per-distro+version INI_LOCATION for the account's PHP handler — e.g.
        /opt/cpanel/ea-php80/root/etc/php.d (see xray-ini-placement.md). This is
        legitimate, so rejecting it outright would regress those tenants.

    The danger is that ``ini_scan_dir`` AND the probe ``phpversion`` are both
    served from the tenant's own docroot, so a tenant could report a global dir
    its account does NOT actually run. A bare two-digit version compare is not
    enough: it is distro-BLIND, so an ea-php80 tenant could report the same-number
    alt-php dir /opt/alt/php80/link/conf and steer the root xray.ini write into a
    DIFFERENT distro family's server-wide, all-tenant-shared dir (cross-distro
    spoof) — on a mixed alt-php+ea-php server that dir exists, so strict resolve
    passes. To close that AND the cross-version spoof, the global branch accepts
    ONLY the EXACT INI_LOCATION derived from ``trusted_php_version`` — here the
    account's REAL provisioned FULL handler carrying its distro family (cPanel
    domain['version'] 'ea-php80', Plesk resolve_lsphp_version(handler), DA
    opts[...]['ver'] 'php80'), NOT the probe-reported version and NOT a
    distro-blind number — and requires ``resolved`` to equal it.

    Accepted:
      * the tenant's OWN CageFS subtree (/var/cagefs/<prefix>/<username>/...);
      * the tenant's OWN per-domain panel dir (/etc/users/<username>/...);
      * the SINGLE INI_LOCATION == _expected_ini_location(trusted_php_version).

    Rejected (default-deny): global config dirs with no version segment
    (/etc/php.d, /etc/php.scan.d, /etc/cl.php.d, /usr/share/cagefs[-skeleton]
    roots), a per-version dir of a DIFFERENT distro family (cross-distro spoof) or
    a DIFFERENT version (cross-version spoof) than the trusted handler, a trusted
    handler that carries no distro family (a bare number — expected dir cannot be
    derived), another tenant's CageFS subtree, another account's
    /etc/users/<other>/ subtree, and any other dir not matched above.
    """
    # The tenant's own per-user CageFS area, derived for self.username.
    if _is_own_cagefs_subtree(username, resolved):
        return True

    # The tenant's own per-domain panel scan dir
    # (/etc/users/<self.username>/php/<vhost>). The username segment is bound to
    # self.username (separator boundary), so /etc/users/<other>/ — including a
    # <self.username>EVIL lookalike — is rejected.
    if _is_within(_USERS_INI_ROOT + username, resolved):
        return True

    # A global, root-owned per-distro+version INI_LOCATION (legitimate for
    # non-CageFS tenants) — accepted ONLY when ``resolved`` is EXACTLY the one dir
    # derived from the account's TRUSTED FULL handler (distro+version). Equality,
    # not a number match: a same-number dir of another distro family, a
    # different version, or a trusted handler with no derivable dir is rejected.
    expected = _expected_ini_location(trusted_php_version)
    if expected is not None:
        try:
            expected = str(Path(expected).resolve(strict=True))
        except (OSError, RuntimeError):
            # The trusted handler's INI_LOCATION does not exist on this server,
            # so the user does not actually run it here — nothing to accept.
            return False
        if resolved == expected:
            return True

    return False


def _is_own_cagefs_subtree(username: str, resolved: str) -> bool:
    """True if ``resolved`` is inside the tenant's OWN /var/cagefs subtree.

    Prefer the exact /var/cagefs/<prefix>/<username> root when the server can
    supply the CageFS prefix (cagefsctl). When the prefix is unavailable, fall
    back to a structural check that still binds to the principal: the path must
    be /var/cagefs/<shard>/<username>[/...], i.e. ``username`` must occupy the
    per-user segment directly under a single shard directory. This never accepts
    another tenant's subtree (the username segment is pinned and realpath has
    already been applied) and avoids failing closed on a legitimate own-subtree
    path when a transient cagefsctl lookup fails. Note such a /var/cagefs path
    only reaches here when the prefix WAS resolvable in
    _resolve_ini_path_in_cagefs, so the fallback covers a mid-request race, not
    a routine "no prefix" case.
    """
    if not _is_within(_CAGEFS_ROOT, resolved):
        return False
    try:
        prefix = utils.cagefsctl_get_prefix(username)
    except Exception:
        prefix = None
    if prefix:
        # `if prefix:` not `is not None`: cagefsctl_get_prefix() returns
        # stdout.strip(), so success-with-empty-stdout yields '' (not None).
        # Treating '' as a usable prefix would build '/var/cagefs//<user>' and
        # falsely reject the realpath'd own subtree; fall through to the
        # structural check instead.
        return _is_within(f'{_CAGEFS_ROOT}{prefix}/{username}', resolved)
    # Prefix unavailable: accept only /var/cagefs/<shard>/<username>[/...], i.e.
    # the username must be the second path segment under the cagefs root.
    rel = resolved[len(_CAGEFS_ROOT):].lstrip('/')
    parts = rel.split('/')
    return len(parts) >= 2 and parts[1] == username


def _resolve_ini_path_in_cagefs(username: str, path: str):
    """
    ini path inside cagefs can be a symlink
    and as cagefs has different namespace for each user,
    the only way to know that for sure is to dive into cage
    and resolve path there
    """
    try:
        pwd.getpwnam(username)
    except KeyError:
        return None
    cmd = ['/sbin/cagefs_enter_user', username, '/usr/bin/realpath', path]
    try:
        resolved_path = subprocess.check_output(
            cmd, text=True, stderr=subprocess.DEVNULL).strip()
    except subprocess.CalledProcessError:
        return None

    if resolved_path.startswith('/etc/cl.php.d/'):
        prefix = utils.cagefsctl_get_prefix(username)
        # `not prefix` covers both None and '' (stdout.strip() on empty output):
        # an empty prefix would build a malformed '/var/cagefs//<user>...' path.
        if not prefix:
            raise ValueError(
                _('CageFS prefix resolved as empty, but should be a number'))
        return f'/var/cagefs/{prefix}/{username}{resolved_path}'

    return resolved_path



def get_php_configuration(
        username: str, domain: str,
        trusted_php_version: Optional[str] = None) -> PhpConfiguration:
    """
    Writes temporary phpinfo-like file to document root
    and executes request to website to retrieve the current
    php version and configuration

    ``trusted_php_version`` is the account's REAL provisioned PHP version as
    known to the panel (server-side, not the probe). The caller passes it so a
    global per-version ini_scan_dir can be version-bound in
    absolute_ini_scan_dir; None disables that accept branch for the panel.
    """
    # Authorize the user-mode caller before any side effect: this is the single
    # sink every panel manager funnels into, so guarding here covers Plesk,
    # cPanel, DirectAdmin and custom panels at once. `username` is the
    # panel-resolved domain owner; a user-mode caller (XRAYEXEC_UID set) who is
    # not that owner is rejected with the uniform "cannot be found" error before
    # the temp PHP file is written into the target docroot and HTTP-fetched.
    # Root / non-user-mode callers are unaffected (verify_user_owns_domain is a
    # no-op when no XRAYEXEC_UID is set).
    verify_user_owns_domain(domain, username)

    # if certificate is bad, but the site itself works,
    # we consider it ok
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    with _temporary_phpinfo_file(username, Path(docroot(domain)[0])) as php_info_file:
        domain_phpinfo_file_path = domain + '/' + php_info_file

        try:
            http_url = 'http://' + domain_phpinfo_file_path
            response = _request_url(http_url)
        except WebsiteNotResponding:
            # Some websites did not enable HTTP to HTTPS redirection.
            # Try connecting with HTTPS protocol.
            https_url = 'https://' + domain_phpinfo_file_path
            response = _request_url(https_url)

    # you may think that we can use json, but we can't because it;s
    # optional php module on older php versions
    configuration = _parse_configuration(
        username, response.text, trusted_php_version=trusted_php_version)
    return configuration


Hry