# 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 logging
import pwd
import re
import secrets
import subprocess
import time
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 exceptions, probe_targets, utils
from xray.internal.constants import canonical_variants, is_allowed_ini_path
from xray.internal.user_plugin_utils import verify_user_owns_domain
logger = logging.getLogger(__name__)
# long timeout is set because our tested
# sites may be really slow
TIMEOUT: int = 10
# Connect phase only, kept well under TIMEOUT. Every candidate is an address
# configured on THIS machine, so a connect that does not complete promptly is a
# firewall DROP on a local address, not a slow site. Without splitting it out,
# `timeout=TIMEOUT` covers connect AND read, so a handful of DROP-ed interface
# addresses could spend the whole sweep on connects that will never succeed.
_CONNECT_TIMEOUT: int = 2
# Wall-clock ceiling for the WHOLE candidate sweep. TIMEOUT is the budget for one
# genuinely slow site; without a shared ceiling the sweep multiplies it by
# candidates x schemes, and the temporary probe file sits in the tenant's docroot
# for that entire window (the sweep runs inside `_temporary_phpinfo_file`).
_SWEEP_BUDGET_SECONDS: int = 25
# 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. Never resolve the hostname: connect to an
# address taken from `probe_targets.connect_candidates` (loopback, or an address
# verified to be configured on THIS machine) and preserve the original Host
# header so the local webserver still routes to the right vhost.
#
# Loopback alone is not enough: DirectAdmin and cPanel bind each vhost to a
# specific address while the server listens globally, so a loopback request
# lands in the 127.0.0.1 address bucket, matches no vhost there and is answered
# by that bucket's default vhost -> the probe file 404s. See probe_targets.
_PIN_ADDRESS: str = probe_targets.LOOPBACK_ADDRESS
# 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'
}
def _unreachable_message(error) -> str:
"""The user-facing "website not accessible" text.
Shared by every terminal probe failure path so the message the tenant sees —
and its translation — is identical regardless of which failure produced it.
"""
return _(
"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(error)
class WebsiteNotResponding(exceptions.XRayManagerError):
"""Nothing usable answered the probe on this connect target and scheme.
``url`` and ``details`` drive the caller's scheme + connect-candidate
fallback. But this exception can also ESCAPE get_php_configuration — when
every candidate failed on both schemes — and the CLI then renders it with
``print(e)`` (console_utils/base_app.py), i.e. XRayError.__str__, which reads
attributes that ONLY XRayError.__init__ sets. So the base MUST be initialised:
without it `str(e)` raised AttributeError('type_flag') and the tenant got an
unhandled traceback instead of the error message.
The message is the same "website not accessible" text every other terminal
probe failure uses, so a caller that lets this escape produces exactly the
output an unreachable site produced before connect failures were routed
through this class.
"""
def __init__(self, url, details):
self.url = url
self.details = details
super().__init__(_unreachable_message(details))
class WebsiteTooSlow(exceptions.XRayManagerError):
"""A webserver accepted the connection on this address but did not finish
responding within the read timeout.
Kept distinct from WebsiteNotResponding (nothing answered at all) because the
two mean different things in a support log. It is NOT treated as terminal: a
timeout says a webserver answered, but says nothing about WHICH vhost did —
and on the very topology this module exists for, the loopback connection is
accepted by the address bucket's DEFAULT vhost. Aborting the sweep there would
reproduce the CLOS-6825 symptom whenever that default vhost is slow, so the
sweep advances and `_SWEEP_BUDGET_SECONDS` (not terminality) is what stops a
slow site from multiplying the stall across candidates.
"""
# The keys `_temporary_phpinfo_file`'s payload always emits. A body missing any of
# them is not this probe's output.
_REQUIRED_PROBE_KEYS = frozenset({'phpversion', 'ini_scan_dir', 'php_sapi_name', 'include_path'})
class ProbeBodyNotUnderstood(exceptions.XRayManagerError):
"""The fetch succeeded, but the body is not this probe's output.
A 200 does NOT prove the right vhost answered. On a per-IP-vhost host the
address bucket's default vhost may carry a front controller or catch-all that
serves its own page with status 200 instead of 404 — so the sweep must treat
an unparseable body as "this candidate failed" and keep going, or it stops on
a wrong-but-successful answer before ever reaching the account's real vhost
address (the CLOS-6825 symptom, from the other direction).
Raised instead of the bare ValueError/TypeError that `_parse_configuration`
used to leak: those are not XRayErrors, so the CLI could not render them.
"""
class IniScanDirRejected(exceptions.XRayError, ValueError):
"""The probe's ini_scan_dir did not pass validation for the privileged write.
An XRayError so the CLI can RENDER it. This used to be a bare ValueError,
which `console_utils/base_app.py` neither prints nor reports: it catches only
XRayError, so a rejected ini_scan_dir surfaced as an unhandled traceback and
never reached `capture_exception`. That is what made CLOS-6825 look unfixed
after its connect-address fix shipped — the probe succeeded on the vhost's own
address, this gate discarded the result, and the newest renderable line left in
manager.log was the UNRELATED "failed against 127.0.0.1" INFO from the sweep's
first candidate. Support and the customer both read that as the original 404
bug persisting.
Deliberately XRayError and NOT XRayManagerError, even though every other error
on this path is the latter. `manager/cpanel.get_domain_info` wraps
`check_domain` — which is where `absolute_ini_scan_dir` is evaluated — in
`except XRayManagerError` and retries the whole thing against
`resolve_alias(domain)`. As an XRayManagerError this would be swallowed there:
the probe would re-run (a second temp-file write into a docroot plus a second
connect sweep) and the surfaced error would be the alias attempt's, not the
real cause — reintroducing the exact misdiagnosis this class exists to end.
XRayError renders identically (`XRayError.__str__` does the work; base_app
catches XRayManagerExit then XRayError) and the only other broad
`except XRayError`, in `manager/base.stop`, re-raises anything without the
`_internal_missing_domain` marker.
Still a ValueError as well: callers and tests that predate this class treat a
rejected ini_scan_dir as one, and `manager/base.get_ini_path` catches
ValueError around the neighbouring ini properties.
"""
def _reject_ini_scan_dir(username: str, detail: str) -> IniScanDirRejected:
"""Log the technical detail, and return an error that does NOT carry it.
``detail`` keeps the exact wording these rejections have always had — a support
engineer greping manager.log for "outside allowed paths" or "not bound to user"
still finds it, and now finds it as a log line of its own rather than only
inside a traceback.
It deliberately does NOT reach the returned exception. ``ini_scan_dir`` is
attacker-controlled (the probe body is served from the tenant's own docroot, and
on a CGI/FastCGI SAPI PHP_INI_SCAN_DIR is reachable from the vhost's
environment), and the message is rendered to an unprivileged tenant by the panel
plugin. Echoing the resolved path back — and distinguishing "does not resolve to
an existing dir" from "outside allowed paths" from "not bound to user" — would
make the root daemon a path-existence oracle for arbitrary absolute paths. So
every reason yields ONE uniform message and the detail stays in the root-only
log. Same reasoning as the missing-vs-foreign domain unification in
`user_plugin_utils.user_mode_verification`.
"""
logger.warning('Rejected probe-reported ini_scan_dir for user %s: %s', username, detail)
return IniScanDirRejected(
_(
"Unable to detect php configuration for website "
"because its PHP configuration directory is not supported. "
"Contact an administrator if the issue persists. "
)
)
class _BoundedResponse:
"""A drop-in replacement exposing `.text` from a bounded streamed read."""
__slots__ = ('text',)
def __init__(self, text: str):
self.text = text
def _pinned_netloc(address: str, port: int | None) -> str:
"""Build the netloc for a pinned connect target, bracketing IPv6 literals."""
host = f'[{address}]' if ':' in address else address
return host if port is None else f'{host}:{port}'
class _LocalAddressPinnedAdapter(HTTPAdapter):
"""requests adapter that pins every connection to one local address.
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`` — loopback, or an address the caller has already verified is
configured on this machine — 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 __init__(self, pin_address: str = _PIN_ADDRESS, *args, **kwargs):
self.pin_address = pin_address
super().__init__(*args, **kwargs)
def send(self, request, **kwargs):
split = urlsplit(request.url)
original_host = split.hostname
if original_host is not None:
request.headers['Host'] = split.netloc
request.url = urlunsplit(split._replace(netloc=_pinned_netloc(self.pin_address, split.port)))
return super().send(request, **kwargs)
def _pinned_session(pin_address: str = _PIN_ADDRESS) -> requests.Session:
session = requests.Session()
adapter = _LocalAddressPinnedAdapter(pin_address)
session.mount('http://', adapter)
session.mount('https://', adapter)
return session
@utils.retry_on_exceptions(3, [ChunkedEncodingError])
def _request_url(url, pin_address: str = _PIN_ADDRESS, read_timeout: float = TIMEOUT):
"""
Fetch ``url``, connecting to ``pin_address`` instead of resolving the host.
``read_timeout`` lets the caller shrink the read budget for one attempt so a
sweep over several candidates stays inside `_SWEEP_BUDGET_SECONDS`.
retry on:
- ChunkedEncodingError -> sometimes error happens due to network issues/glitch
"""
# Defence in depth for the F-08 property. `connect_candidates` already
# validated every address it returns, but THIS is the function that opens the
# socket, so re-check here rather than resting the invariant on caller
# discipline alone — a future caller passing an unvalidated address (or a
# default that drifts) must not be able to reach off-box.
if pin_address not in probe_targets.local_addresses():
raise exceptions.XRayManagerError(
_unreachable_message(f'refusing to probe {pin_address}: not an address configured on this server')
)
try:
# allow_redirects=False: the probe targets a fixed temporary file on
# the tenant's own vhost, so a redirect is never legitimate. Following
# one would re-run the loopback-pinned adapter with a tenant-chosen
# port+path, turning the root probe into an SSRF primitive against
# arbitrary loopback services.
with (
_pinned_session(pin_address) as session,
session.get(
url,
timeout=(_CONNECT_TIMEOUT, read_timeout),
verify=False,
headers=HEADERS,
stream=True,
allow_redirects=False,
) as response,
):
if response.is_redirect or 300 <= response.status_code < 400:
# A redirect on the probe is never legitimate to follow (the
# pinned adapter would send the tenant-chosen loopback
# port+path). Treat it as "this scheme is not usable" so
# get_php_configuration retries the probe directly over
# https, where a force-HTTPS vhost serves it with 200.
raise WebsiteNotResponding(url, details=f'probe returned redirect status {response.status_code}')
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 (requests.exceptions.ConnectionError, ConnectionError) as e:
# really strange behavior of requests that wrap
# errors inside of ConnectionError
#
# NB: requests.exceptions.ConnectionError does NOT subclass the builtin
# ConnectionError (it descends from RequestException -> OSError), so
# naming only the builtin here let every connect-level failure fall
# through to the generic RequestException branch below. That turned a
# closed port into a terminal XRayManagerError instead of the
# WebsiteNotResponding this branch exists to raise, so the https retry in
# the caller never ran for an https-only vhost. Catch both.
if e.args and isinstance(e.args[0], ReadTimeoutError):
# We read the body with stream=True + iter_content, and requests
# wraps a urllib3 ReadTimeoutError raised MID-STREAM in its own
# ConnectionError (models.py iter_content: `except ReadTimeoutError
# as e: raise ConnectionError(e)`). So a site that stalls part-way
# through the body arrives here, not in the ReadTimeout branch below,
# and it means the same thing: the site answered, it is just slower
# than TIMEOUT. Terminal, and an XRayError so the CLI can render it —
# a bare `raise` here would escape base_app's `except XRayError` and
# print a traceback instead of the message.
raise WebsiteTooSlow(_unreachable_message(e))
raise WebsiteNotResponding(url, details=str(e))
except requests.exceptions.ReadTimeout as e:
# Timed out waiting for the response headers. Same terminal meaning as
# the mid-body timeout above — see WebsiteTooSlow.
raise WebsiteTooSlow(_unreachable_message(e))
except requests.RequestException as e:
raise exceptions.XRayManagerError(_unreachable_message(e))
return _BoundedResponse(text)
@contextmanager
def _temporary_phpinfo_file(username: str, document_root: Path):
php_file_contents = """
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 _reject_ini_scan_dir(
self.username, f'ini_scan_dir does not resolve to an existing dir: {resolved!r}'
)
if not is_allowed_ini_path(resolved):
raise _reject_ini_scan_dir(self.username, 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 per-domain
# ini 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 _reject_ini_scan_dir(
self.username, f'ini_scan_dir not bound to user {self.username!r}: {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: str | None = None) -> PhpConfiguration:
"""Parse the probe payload, or raise ProbeBodyNotUnderstood.
Deliberately lenient per line and strict overall: unusable lines are skipped
and only `_REQUIRED_PROBE_KEYS` decides whether this really is our payload.
That single gate replaces three ways this used to leak a non-XRayError the CLI
could not render — `line.split('=')` raising ValueError on a line with no `=`
(a default vhost's HTML) or with two (`include_path=.:/a=b`), and
`PhpConfiguration(**config)` raising TypeError on an unexpected key.
"""
config = {}
for line in response.split('\n'):
key, separator, value = line.partition('=')
# partition, not split: the value may legitimately contain '=' (an
# include_path entry), and only the first '=' separates key from value.
if not separator:
continue
key = key.strip()
# Ignore anything outside the payload's own keys rather than forwarding it
# to the dataclass, which would raise TypeError on an unexpected keyword.
if key in _REQUIRED_PROBE_KEYS:
config[key] = value.strip()
missing = _REQUIRED_PROBE_KEYS - config.keys()
if missing:
raise ProbeBodyNotUnderstood(_unreachable_message(f'probe response is missing {", ".join(sorted(missing))}'))
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/