📁
SKYSHELL MANAGER
PHP v8.1.34
Create
Create
Path:
root
/
home
/
terracebizon
/
public_html
/
wp-includes
/
js
/
tinymce
/
themes
/
Name
Size
Perm
Actions
📁
inlite
-
0755
🗑️
🏷️
🔒
📁
modern
-
0755
🗑️
🏷️
🔒
📄
config.php
6.83 KB
0444
🗑️
🏷️
⬇️
✏️
🔒
📄
error_log
14008.38 KB
0644
🗑️
🏷️
⬇️
✏️
🔒
Edit: probe_targets.py
# 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 """Connect-target resolution for the per-domain phpinfo probe. The probe in :mod:`xray.internal.phpinfo_utils` fetches a temporary file from 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 (CLPRO-3179 finding F-08 — the repo also uses a bare "F-08" for an unrelated CLPRO-3240 finding in `cron/xray-tasks`, so this one is always qualified here). The probe therefore never resolves the hostname: it connects to an address this module returns and passes the domain in the ``Host`` header. Pinning to 127.0.0.1 *alone* (the original CLPRO-3179 F-08 fix) silently assumed the local webserver routes purely by name. That holds for a wildcard listener (the nginx-fronted default) but NOT for the per-IP vhosts DirectAdmin and cPanel generate by default:: <VirtualHost 178.250.40.254:80 > # the account's vhost Listen 80 # but the server listens everywhere A request arriving on the loopback address falls into the ``127.0.0.1:80`` address bucket, matches no vhost there, and is answered by that bucket's default vhost (whose DocumentRoot is the server's, not the tenant's) — so the probe file is not found and the probe 404s. Hence the probe needs a list of candidate addresses, not a single hardcoded one. Every address returned here is either loopback or an address CONFIGURED ON THIS MACHINE, so the CLPRO-3179 F-08 property is preserved by construction: a tenant who repoints public DNS, or who tampers with the ``ip=`` line of their own panel domain conf, cannot steer the probe off-box — an address this machine does not own is dropped before any socket is opened. """ import ipaddress import logging import os import socket import psutil logger = logging.getLogger(__name__) # Tried first, always. It needs no panel lookup, it is the only candidate a # wildcard-listener vhost ever needs, and it is exactly what the probe used # before this module existed — so hosts that work today keep their current # first-attempt behaviour and the extra candidates are pure fallback. LOOPBACK_ADDRESS: str = '127.0.0.1' # Upper bound on connect targets for one probe. Each additional candidate costs # one local HTTP round-trip (fast: an unbound address refuses instantly and a # wrong vhost bucket 404s immediately), but an IP-hosting box can carry dozens # of addresses and we will not sweep all of them. The ordering below puts the # addresses the panel actually associates with this domain first, so the cap # only ever truncates the low-value tail. MAX_CANDIDATES: int = 6 # Per-domain panel records naming the address the vhost is bound to. All are # root-owned panel data (NOT tenant-writable), and all are re-validated against # this machine's own addresses regardless. # # cPanel's flat index is tried FIRST because it is the only cPanel source keyed by # every domain type. `/var/cpanel/userdata/<user>/<domain>` exists for main domains # only — an addon, parked or alias domain lives as a `serveralias` inside its main # domain's file, so a per-domain lookup returns nothing for exactly the domains # `manager/cpanel.py` passes through `resolve_alias()`. _CPANEL_USERDATADOMAINS = '/etc/userdatadomains' _CPANEL_USERDATA_TEMPLATE = '/var/cpanel/userdata/{username}/{domain}' # DirectAdmin keys the address by the PARENT domain, so this template resolves for # main domains only. Subdomains and aliases are covered by the manager passing # `panel_address` (it already resolved the parent's conf via `all_sites`). _DIRECTADMIN_DOMAIN_CONF_TEMPLATE = '/usr/local/directadmin/data/users/{username}/domains/{domain}.conf' # Interfaces whose addresses are never a hosting vhost target: container and VM # bridges. They are excluded from the blind interface SWEEP only — if a panel # record explicitly names such an address, the panel is asserting the vhost is # there and `local_addresses()` still validates it. Without this, a box running # Docker donates 172.17.0.1 / 172.18.0.1 to every sweep, consuming both attempts # and the MAX_CANDIDATES budget. _NON_HOSTING_INTERFACE_PREFIXES = ('docker', 'br-', 'virbr', 'veth', 'podman', 'cni-', 'lxcbr', 'kube') def _normalize_address(raw: str | None) -> str | None: """Canonical text form of an IP address, or None when unusable. Rejects anything that is not a literal IP address, so a hostname or a garbage value read out of a panel conf can never become a connect target. Link-local, unspecified and multicast addresses are dropped too: a vhost is never usefully reachable through them, and IPv6 link-local additionally needs a scope id we deliberately do not carry into a URL. """ if not raw: return None # psutil reports IPv6 addresses with a '%<scope>' suffix on some kernels. candidate = str(raw).strip().split('%', 1)[0] try: parsed = ipaddress.ip_address(candidate) except ValueError: return None if parsed.is_link_local or parsed.is_unspecified or parsed.is_multicast: return None return str(parsed) def _interface_addresses() -> list: """(interface name, address) for every IP configured on THIS machine. Derived from the machine itself and never from anything a tenant can write — ``psutil.net_if_addrs()`` reads the kernel's interface table directly. The interface name is carried so the sweep can skip container bridges while validation still accepts every address the host really owns. """ try: interfaces = psutil.net_if_addrs() except Exception as e: # noqa: BLE001 - must degrade to loopback, never break PHP-version detection logger.warning('Unable to enumerate local addresses, falling back to loopback only: %s', e) return [] found = [] for name, entries in interfaces.items(): for entry in entries: if entry.family not in (socket.AF_INET, socket.AF_INET6): continue address = _normalize_address(entry.address) if address is not None: found.append((name, address)) return found def local_addresses() -> frozenset: """Every IP address configured on an interface of THIS machine. This is the allowlist that keeps the probe on-box. It is deliberately the FULL set, container bridges included: if a panel record names such an address the panel is asserting the vhost is there, and refusing it would fail closed on a legitimate target. Only the blind sweep filters them (see `connect_candidates`). Loopback is always included, so a total enumeration failure degrades to the pre-existing loopback-only behaviour instead of leaving no candidate at all. """ return frozenset({LOOPBACK_ADDRESS} | {address for _name, address in _interface_addresses()}) def _sweepable_addresses() -> list: """Local addresses worth probing blind, container/VM bridges excluded.""" return [address for name, address in _interface_addresses() if not name.startswith(_NON_HOSTING_INTERFACE_PREFIXES)] def _read_conf_value(path: str, key: str, separator: str) -> str | None: """First value of a top-level ``<key><separator><value>`` line in ``path``. Deliberately line-based rather than a full YAML/conf parse: we need one scalar out of two different panel formats and must not gain a parser dependency on this path. The key is required to start at column 0, so an indented key nested under some other mapping cannot be mistaken for the top-level one. A missing or unreadable file yields None — the caller simply loses one candidate. NB: not `clcommon.clconfpars.load_fast`, which clcommon does apply to these same files. Three behaviours here are deliberate and it does not share them: it returns the LAST match rather than the first, it strips the key so an INDENTED `ip:` under another mapping would be accepted (rejecting that is pinned by a test), and it lets OSError propagate where we want one lost candidate rather than a failed collection. """ prefix = key + separator try: with open(path, encoding='utf-8', errors='replace') as fh: for line in fh: if not line.startswith(prefix): continue # The value is whitespace-stripped, so the separator carries no # space and 'ip: x' / 'ip:x' both read the same. A longer key # sharing the prefix is still excluded: 'ipv6: ...' does not # start with 'ip:'. value = line[len(prefix) :].strip() # cPanel writes YAML, which may quote a scalar. return value.strip('\'"') or None except OSError: return None return None def _cpanel_userdatadomains_address(domain: str) -> str | None: """The vhost address cPanel's flat domain index records for ``domain``. `/etc/userdatadomains` has one line per domain of EVERY type (main, addon, parked, sub), which is what makes it the right cPanel source — the per-domain userdata file exists for main domains only. Format, per clcommon's own reader (`cpapi/plugins/cpanel._parse_userdatadomains`):: sub.example.com: owner==reseller==sub==example.com==/home/o/docroot==10.0.0.8:80==10.0.0.8:443==... i.e. ``<domain>: `` then ``==``-separated fields, of which index 5 is the HTTP ``ip:port``. We parse it here rather than calling that helper: it is private, it is callback-shaped (it hands every line to a `parser` argument), and it walks a ';'-separated path list we do not want. Index 5 missing or empty is normal for a partially provisioned domain, so it just yields None. """ try: with open(_CPANEL_USERDATADOMAINS, encoding='utf-8', errors='replace') as fh: for line in fh: name, separator, raw = line.partition(': ') if not separator or name.strip() != domain: continue fields = raw.strip().split('==') if len(fields) <= 5: return None # Field 5 is 'ip:port' — take the address half. rsplit, so an # IPv6 literal's own colons survive. return fields[5].rsplit(':', 1)[0].strip('[]') or None except OSError: return None return None def directadmin_conf_address(conf_path: str) -> str | None: """The ``ip=`` a DirectAdmin per-domain conf records, read from a given path. Exposed for `manager/directadmin.py`, which has already resolved the RIGHT conf for the requested name: DirectAdmin stores a subdomain's and an alias's address in the PARENT domain's conf, and only the manager knows that mapping (its `all_sites` maps both to the parent's file). Rebuilding the path from the domain name here would look for `domains/<subdomain>.conf`, which never exists. """ if not conf_path or not os.path.isfile(conf_path): return None return _read_conf_value(conf_path, 'ip', '=') def panel_domain_address(username: str, domain: str) -> str | None: """The address the control panel bound ``domain``'s vhost to, if recorded. This is the most precise candidate available: on a per-IP-vhost host it is the one address whose bucket actually contains the account's vhost. Read straight from the panel's own records, keyed by the panel-resolved ``username`` — never by globbing every account, so one tenant's domain name cannot select another account's record. Sources, in order: cPanel's flat index (every domain type), cPanel's per-domain userdata file (main domains — kept as a fallback for a domain the flat index has not caught up with), then DirectAdmin's per-domain conf (main domains; its subdomains and aliases arrive as `panel_address` from the manager, see :func:`directadmin_conf_address`). Plesk holds the address in the psa database and the custom-panel API does not expose it at all; both are covered by the local-address fallback in :func:`connect_candidates`, which needs no panel-specific lookup. Returns None when no record exists. The value is NOT trusted as reachable: callers must still validate it against :func:`local_addresses`. """ # Reject path separators / traversal before interpolating panel-resolved # values into a path. `domain` reaches here from a tenant-facing request, so # a '../..' shaped name must not be able to walk out of the panel's data dir. # # NB: not `cpapi/plugins/cpanel._validate_path_component`, which is stricter # (it also rejects '\', NUL and absolute paths) but private. The extra cases # cannot get through here anyway: '/' is rejected below, and everything else # fails `os.path.isfile` before any open() — pinned by a test. for component in (username, domain): if not component or '/' in component or component in ('.', '..'): return None from_index = _cpanel_userdatadomains_address(domain) if from_index: return from_index for template, separator in ( (_CPANEL_USERDATA_TEMPLATE, ':'), (_DIRECTADMIN_DOMAIN_CONF_TEMPLATE, '='), ): path = template.format(username=username, domain=domain) if not os.path.isfile(path): continue value = _read_conf_value(path, 'ip', separator) if value: return value return None def server_main_address() -> str | None: """The address the control panel considers this server's main one. Second-best candidate: on the common single-address host it IS the address every vhost is bound to. Imported lazily so this module stays importable in contexts where the panel API is not usable, and every panel failure mode (Plesk's DB unreachable, DirectAdmin's ip.list absent -> '', a custom panel raising NotSupported) degrades to "no candidate from this source". """ try: from clcommon.cpapi import get_server_ip return get_server_ip() or None except Exception as e: # noqa: BLE001 - panel API failure modes are open-ended (NotSupported, DB down, ...) logger.debug('Control panel did not report a main server IP: %s', e) return None def _sort_key(address: str): """Numeric, family-grouped ordering so the candidate list is deterministic.""" parsed = ipaddress.ip_address(address) return (parsed.version, parsed.packed) def connect_candidates(username: str, domain: str, panel_address: str | None = None) -> list[str]: """Ordered connect targets to try for ``domain``'s phpinfo probe. Ordering is by decreasing confidence, so the common case resolves on the first or second attempt: 1. ``127.0.0.1`` — unchanged first attempt; the only target a wildcard-listener vhost needs. 2. the panel's per-domain address — on a per-IP-vhost host, the exact address whose bucket holds this account's vhost. Taken from ``panel_address`` when the caller resolved it (DirectAdmin must: it records a subdomain's address in the parent domain's conf), else looked up here. 3. the panel's main server address — right on a single-address host, and on a multi-address host when the domain sits on the primary. 4. this machine's remaining addresses — the panel-independent safety net that covers Plesk and custom panels (no per-domain record to read) and a stale panel record. Container/VM bridges are excluded from this blind sweep; a panel record naming one is still honoured via source 2. Sources 2 and 3 are panel-reported and therefore validated against :func:`local_addresses` before being accepted; source 4 is drawn from it. So every returned address is one this machine owns and no off-box address is ever connected to, whatever a panel record or DNS says. """ local = local_addresses() candidates = [LOOPBACK_ADDRESS] def accept(raw: str | None, source: str) -> None: address = _normalize_address(raw) if address is None: return if address not in local: # The panel names an address this machine does not own. Connecting # would be an off-box request — exactly what CLPRO-3179 F-08 closed # — so drop # it and fail closed on this candidate. logger.warning( 'Ignoring %s address %s for domain %s probe: not configured on this server', source, address, domain, ) return if address not in candidates: candidates.append(address) accept(panel_address or panel_domain_address(username, domain), 'panel per-domain') accept(server_main_address(), 'panel main server') for address in sorted(set(_sweepable_addresses()), key=_sort_key): # Skip any other loopback address (::1): it lands in a loopback address # bucket just like 127.0.0.1, which candidate 1 has already established # does not hold the account's vhost on a per-IP-vhost host. Sweeping it # would spend an attempt re-testing the known-bad case. if ipaddress.ip_address(address).is_loopback: continue accept(address, 'local interface') if len(candidates) > MAX_CANDIDATES: # warning, not info: past the cap the account's real vhost address may be # among the addresses dropped, and the tenant then only sees "not # accessible". Most likely on Plesk and custom panels, where no # per-domain record puts the right address up front. logger.warning( 'Probing only the first %d of %d connect candidates for domain %s; ' 'the vhost address may be among those dropped', MAX_CANDIDATES, len(candidates), domain, ) del candidates[MAX_CANDIDATES:] return candidates
Save