| Server IP : 142.11.234.102 / Your IP : 216.73.217.78 Web 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 MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : OFF | Pkexec : OFF Directory : /opt/cloudlinux/venv/lib/python3.11/site-packages/ssa/ |
Upload File : |
# -*- 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
"""
This module contains contains classes implementing SSA Agent behaviour
"""
import atexit
import json
import logging
import pwd
import re
import socket as socket_module
import struct
from concurrent.futures import ThreadPoolExecutor
from threading import BoundedSemaphore, current_thread
from clcommon.cpapi import cpusers, domain_owner, get_main_username_by_uid
from .internal.constants import agent_sock
from .internal.exceptions import SSAError
from .internal.utils import canonical_stored_domain, create_socket
from .modules.processor import RequestProcessor
# Maximum number of concurrent worker threads for handling requests.
# Limits memory usage on high-traffic servers.
MAX_WORKERS = 50
# Upper bound on bytes accepted from a single connection.
# The PHP extension's JSON payload is always under 1 KB; 8 KB gives
# ample headroom while preventing unbounded reads.
MAX_MSG_SIZE = 8192
# Seconds to wait for a peer to finish sending its payload.
# Keeps slow or stalled connections from holding a worker thread open.
SOCKET_READ_TIMEOUT = 10
# Largest value storable in a SQLite signed 64-bit INTEGER column. Metric
# values above this raise OverflowError on bulk insert and would poison the
# shared flush buffer, so they must be rejected during input validation.
_MAX_SQLITE_INT = 2**63 - 1
# Bounded admission control. ThreadPoolExecutor.submit() enqueues into an
# UNBOUNDED queue.SimpleQueue, so a local connect-flood on the world-writable
# socket would back up accepted-but-unhandled connections without limit and
# grow the agent's memory toward MemoryMax=1G (OOM/restart of the telemetry
# daemon). Cap in-flight + queued work at the worker count plus a small queue:
# past that, the accept loop closes the connection instead of queueing it.
MAX_PENDING = MAX_WORKERS
MAX_INFLIGHT = MAX_WORKERS + MAX_PENDING
class SimpleAgent:
"""
SSA Simple Agent class
"""
def __init__(self):
self.logger = logging.getLogger('agent')
self.request_processor = RequestProcessor()
self.executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
# Counts in-flight + queued connections; bounds the executor's
# otherwise-unbounded work queue (admission control).
self._admission = BoundedSemaphore(MAX_INFLIGHT)
atexit.register(self._shutdown)
# start serving incoming connections
self.listen()
def _shutdown(self):
"""Gracefully shutdown the thread pool executor."""
self.executor.shutdown(wait=False)
def listen(self) -> None:
"""
Start listening socket
"""
_socket = create_socket(agent_sock)
while True:
connection, address = _socket.accept()
if not self._admission.acquire(blocking=False):
# Pool + bounded queue saturated: refuse rather than queue
# unbounded work. The peer (best-effort telemetry) just drops
# this sample; the agent's memory stays bounded under a flood.
self.logger.warning(
'[Admission] pool saturated (%d in-flight); '
'dropping connection', MAX_INFLIGHT)
connection.close()
continue
try:
future = self.executor.submit(self.handle, connection)
except RuntimeError as e:
# CPython's ThreadPoolExecutor.submit() enqueues the work
# item BEFORE calling _adjust_thread_count(); a RuntimeError
# from Thread.start() (e.g. the cgroup pids controller
# rejecting a new thread because TasksMax was hit,
# CLPRO-3118) therefore leaves the work item in the queue,
# and existing workers will drain it. Do NOT close the
# connection here — that would race with a worker that may
# yet process it and turn a real burst-induced failure into
# spurious "Bad file descriptor" errors. Just log and keep
# accepting; killing the agent would only trigger a restart
# loop.
self.logger.error(
'[ThreadPool] submit raised (work item queued for '
'existing workers): %s', str(e))
# No future to attach a release callback to; the work item is
# already queued for an existing worker, so free the slot now.
self._admission.release()
continue
# Free the admission slot once the connection has been handled.
future.add_done_callback(lambda _f: self._admission.release())
self.logger.debug('[ThreadPool] Submitted task')
# Fields that the PHP extension sends (see dump.c: ssa_agent_dump)
_REQUIRED_FIELDS = frozenset({
'timestamp', 'url', 'duration',
'hitting_limits', 'throttled_time', 'io_throttled_time', 'wordpress'
})
# 8190 matches Apache's default LimitRequestLine, which is the effective
# upper bound on URLs reaching the PHP extension in practice.
_MAX_URL_LENGTH = 8190
_URL_RE = re.compile(r'^https?\??://[^\x00-\x1f\s<>"{}|\\^`\[\]]+\Z')
@staticmethod
def _get_peer_uid(connection: 'socket object') -> int:
"""
Get the UID of the peer process using SO_PEERCRED.
:param connection: socket object
:return: UID of the connecting process
"""
cred = connection.getsockopt(
socket_module.SOL_SOCKET,
socket_module.SO_PEERCRED,
struct.calcsize('3i')
)
_pid, uid, _gid = struct.unpack('3i', cred)
return uid
@classmethod
def _validate_input(cls, data: dict) -> bool:
"""
Validate that input data contains exactly the expected metric fields
with the correct value types. The PHP extension always sends all 7
fields (see dump.c), so we require an exact key match and enforce
the types produced by the C formatter to reject both malformed and
spoofed payloads.
"""
if not isinstance(data, dict) or not data:
return False
if set(data.keys()) != cls._REQUIRED_FIELDS:
return False
if not isinstance(data['timestamp'], str) or not data['timestamp'].isascii() or not data['timestamp'].isdigit():
return False
if not isinstance(data['url'], str) or not data['url']:
return False
if len(data['url']) > cls._MAX_URL_LENGTH:
return False
if not cls._URL_RE.match(data['url']):
return False
if isinstance(data['duration'], bool) or \
not isinstance(data['duration'], int) or not 0 <= data['duration'] <= _MAX_SQLITE_INT:
return False
if not isinstance(data['hitting_limits'], bool):
return False
if isinstance(data['throttled_time'], bool) or \
not isinstance(data['throttled_time'], int) or not 0 <= data['throttled_time'] <= _MAX_SQLITE_INT:
return False
if isinstance(data['io_throttled_time'], bool) or \
not isinstance(data['io_throttled_time'], int) or not 0 <= data['io_throttled_time'] <= _MAX_SQLITE_INT:
return False
if not isinstance(data['wordpress'], bool):
return False
return True
def _authorize_sender(self, peer_uid: int, url: str) -> bool:
"""
Authenticate the sender against the domain it reports for.
The socket is world-writable by design (PHP workers run under
arbitrary tenant UIDs), so the peer UID is the only sender identity
available. The sender is classified by panel hosting-account
membership, NOT by a numeric UID threshold: a UID_MIN cutoff is wrong
panel/OS-dependently — DirectAdmin's `webapps` (uid 1001) and `admin`
(uid 1000) sit at/above UID_MIN yet own no per-tenant identity, and
cPanel/LiteSpeed on EL8/EL9 runs DSO/non-suEXEC PHP as `nobody`
(uid 65534, not 99), also ≥ UID_MIN. A threshold would false-reject
all of their legitimate metrics.
Instead the peer's UID is resolved to a username via the panel
(get_main_username_by_uid) and checked against the panel's
authoritative tenant set (cpusers(), via _panel_tenant_users).
A peer that is NOT a hosting account — root, apache, nobody=65534,
webapps, the LiteSpeed master, … — has no per-tenant identity to
enforce and is trusted for any domain. A peer that IS a hosting
account is rejected only on a POSITIVE cross-tenant mismatch: the
reported domain's owner resolves to a system UID different from the
peer UID. When ownership cannot be established (domain_owner raises,
returns nothing, or names a user absent from the passwd database —
panel DB/file unavailable, domain mid-provisioning, parked/alias
domains) we fail OPEN (accept + warn); we likewise fail OPEN when
cpusers() itself fails (the tenant set degrades to empty, so every
peer is treated as a non-tenant). This fail-open-on-unresolvable is a
deliberate product choice: dropping such payloads buys no security
over the confirmed spoof (which needs a resolvable owner that differs)
while silently losing legitimate metrics.
Ownership is compared by UID, NOT by username string (CLPRO-3231).
The peer identity is the kernel-trusted SO_PEERCRED UID; with per-user
PHP (lsphp / suEXEC / PHP-FPM-per-user) that UID is the domain owner's
UID by construction, so mapping the reported domain's owner to a UID
(pwd.getpwnam) and comparing UIDs can never false-reject a site's own
traffic. Comparing two independently-resolved name strings —
getpwuid(peer_uid).pw_name versus the owner recorded in the panel's
domain DB — is NOT safe: the live passwd DB and the panel DB can spell
the same account differently (case, account rename, duplicate/alias
passwd entry, reseller naming), which false-rejected entire legitimate
sites and flooded Sentry once 0.4-28 shipped the string comparison.
Inherent limit: on pure-DSO / shared-`nobody` configurations every PHP
worker runs under the same non-tenant UID, so there is no per-tenant
kernel identity to bind a payload to and authorization degrades to
trust. That is acceptable — such configs provide no tenant isolation
in the first place, and the threat model here is per-user PHP.
Lookups (cpusers() and domain_owner) are performed FRESH on every
payload — there is no cache. The previous owner/tenant-set memo was
removed: it guarded a security decision and produced repeated
staleness/race findings. Fresh lookups are obviously correct and
acceptable here because the panel layer (clcommon.cpapi) memoizes
internally (the DirectAdmin backend caches its domain DB), and this
runs on a background daemon (50-worker pool), not the PHP request hot
path.
The owner lookup MUST key on the SAME domain string the processor
stores, or a crafted URL can authorize as an unresolvable string
(fail-open) yet be stored under a victim's domain. To guarantee that
parity by construction, the lookup domain is derived from
canonical_stored_domain() — the single source of truth for
RequestProcessor's RequestResult.domain (netloc with every 'www.'
substring removed) — and then normalized to the panel's lookup form:
userinfo (user:pass@) and :port that survive in the netloc are
dropped, the trailing root-label dot is stripped, and the host is
lowercased (the panel keys ownership on the bare lowercase hostname).
Because this normalization is a pure function of the stored string,
every URL that stores as a given victim domain — 'www.' prefix or
suffix insertion, trailing dot, uppercase, embedded port — resolves to
the same owner lookup and is forced through the cross-tenant check.
Deriving from hostname or stripping the trailing dot BEFORE removing
'www.' broke this parity for the trailing-dot suffix case (e.g.
victim.comwww.) — see bugbot 95cac397, CLPRO-3190, !45 note 557376.
(url_split's substring replace can also rewrite domains that merely
contain 'www.' mid-name; that attribution quirk is pre-existing and
shared deliberately — parity is the security property.)
"""
peer_user = self._peer_panel_user(peer_uid)
if peer_user is None or peer_user not in self._panel_tenant_users():
return True
domain = self._owner_lookup_domain(url)
owner = self._resolve_domain_owner(domain, peer_user)
if not owner:
return True
try:
owner_uid = pwd.getpwnam(owner).pw_uid
except KeyError:
self.logger.warning(
'[%s] Owner %r of %r not in passwd (peer UID=%d), accepting',
current_thread().name, owner, domain, peer_uid)
return True
return owner_uid == peer_uid
@staticmethod
def _owner_lookup_domain(url: str) -> str:
"""
Panel owner-lookup key for url, derived from the SAME string the
processor stores (canonical_stored_domain), so authorization and
storage cannot diverge. Strips userinfo and :port that survive in the
netloc, removes the trailing root-label dot, lowercases, then removes
'www.' once more: the netloc-level strip is case-sensitive, so an
uppercase 'WWW.' prefix only collapses after lowercasing — this keeps
authorization at least as strict as the prior hostname-based path
while preserving stored-domain parity for every clean-apex variant.
"""
host = canonical_stored_domain(url)
if '@' in host:
host = host.rsplit('@', 1)[1]
if host.startswith('[') and ']' in host:
# IPv6 literal: keep [..] intact, drop any :port after it
host = host[:host.index(']') + 1]
else:
left, sep, right = host.rpartition(':')
if sep and right.isdigit():
host = left
return host.rstrip('.').lower().replace('www.', '')
@staticmethod
def _peer_panel_user(peer_uid: int):
"""
Resolve the peer UID to its panel main username, or None when it
cannot be mapped (no such panel user). An unmappable UID is not a
tenant and is trusted (fail open).
"""
try:
return get_main_username_by_uid(peer_uid)
except Exception:
return None
def _panel_tenant_users(self) -> frozenset:
"""Fresh frozenset(cpusers()). On failure -> empty frozenset, so every
peer is treated as a non-tenant and trusted (fail open), consistent with
the unresolvable-owner / nopanel behaviour."""
try:
return frozenset(cpusers())
except Exception as e:
self.logger.warning(
'[%s] Could not resolve panel hosting accounts, treating every '
'peer as non-tenant (fail open): %s',
current_thread().name, str(e))
return frozenset()
def _resolve_domain_owner(self, domain: str, peer_user: str):
"""
Resolve the owner username of a bare hostname via the panel API.
Returns the owner username, or None for every case where ownership
cannot be established (the fail-open cases documented in
_authorize_sender).
"""
try:
owner = domain_owner(domain)
except Exception as e:
self.logger.warning(
'[%s] Could not resolve owner of %r for user=%r, accepting: %s',
current_thread().name, domain, peer_user, str(e))
return None
if not owner:
self.logger.warning(
'[%s] No owner for %r (user=%r), accepting',
current_thread().name, domain, peer_user)
return None
return owner
def handle(self, connection: 'socket object') -> None:
"""
Handle incoming connection
:param connection: socket object usable to
send and receive data on the connection
"""
try:
peer_uid = self._get_peer_uid(connection)
except (OSError, struct.error) as e:
self.logger.error(
'[%s] Failed to get peer credentials: %s',
current_thread().name, str(e))
connection.close()
return
connection.settimeout(SOCKET_READ_TIMEOUT)
fileobj = connection.makefile(errors='ignore')
try:
input_data = self.read_input(fileobj)
if not self._validate_input(input_data):
self.logger.warning(
'[%s] Rejected invalid payload from UID=%d: keys=%s',
current_thread().name, peer_uid,
sorted(input_data.keys()) if isinstance(input_data, dict) else type(input_data).__name__)
return
if not self._authorize_sender(peer_uid, input_data['url']):
self.logger.warning(
'[%s] Rejected cross-tenant payload from UID=%d for url=%r',
current_thread().name, peer_uid, input_data['url'])
return
self.request_processor.handle(input_data)
self.logger.info('[%s] Accepted payload from UID %d',
current_thread().name, peer_uid)
except socket_module.timeout as e:
self.logger.warning('[%s] Connection timed out (peer UID=%d): %s',
current_thread().name, peer_uid, str(e))
except (SSAError, json.JSONDecodeError, ValueError) as e:
self.logger.error('Handled exception in [%s]: %s',
current_thread().name, str(e))
except Exception as e:
self.logger.exception('Unexpected exception in [%s]: %s',
current_thread().name, str(e))
finally:
fileobj.close()
connection.close()
def read_input(self, fileio: 'file object') -> dict:
"""
Read input data and return decoded json
:param fileio: a file-like object providing read method
"""
data = fileio.read(MAX_MSG_SIZE)
self.logger.info('[%s] I received %i bytes',
current_thread().name, len(data.encode()))
self.logger.debug('[%s] payload: %s',
current_thread().name, data)
if data:
return json.loads(data.strip())
else:
return {}