HEX
Server: LiteSpeed
System: Linux baran.dnsprotection.me 4.18.0-553.83.1.lve.el8.x86_64 #1 SMP Wed Nov 12 10:04:12 UTC 2025 x86_64
User: ddir12 (3618)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: //opt/cloudlinux/venv/lib64/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 os
import pwd
import secrets
import subprocess
from contextlib import contextmanager
from pathlib import Path

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 ALLOWED_INI_PREFIXES

# 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

    @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:
            resolved = os.path.realpath(resolved)
            if not any(
                (resolved + '/').startswith(p) for p in ALLOWED_INI_PREFIXES
            ):
                raise ValueError(
                    f'ini_scan_dir outside allowed paths: {resolved!r}'
                )
        return resolved

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

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

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

        config[key] = value.strip()

    return PhpConfiguration(username=username, **config)


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)
        if prefix is None:
            raise ValueError(
                _('CageFS prefix resolved as None, but should be a number'))
        return f'/var/cagefs/{prefix}/{username}{resolved_path}'

    return resolved_path



def get_php_configuration(username: str, domain: str) -> PhpConfiguration:
    """
    Writes temporary phpinfo-like file to document root
    and executes request to website to retrieve the current
    php version and configuration
    """
    # 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)
    return configuration