| Server IP : 142.11.234.102 / Your IP : 216.73.217.70 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 : /proc/self/root/opt/cloudlinux/venv/lib64/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
import time
from concurrent.futures import ThreadPoolExecutor
from threading import BoundedSemaphore, Lock, current_thread
from clcommon.cpapi import (
cpusers,
domain_owner,
get_domains_php_info,
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
# Pre-auth read budget: seconds a peer has to finish sending its payload
# before the worker+admission slot it holds is reclaimed. The whole read in
# handle() runs BEFORE any sender validation, so on the world-writable socket
# an idle/drip-feeding peer can pin a worker for this long with O(1) effort
# (slow-read / Slowloris primitive). The per-UID admission cap bounds how many
# such slots one UID holds concurrently, but not how long each is held, so this
# budget is kept tight. The legitimate PHP client writes its <1 KB payload in
# one shot and closes (see src/processors.c: ssa_process_request), so the read
# returns on EOF within milliseconds; the client's own SO_SNDTIMEO/SO_RCVTIMEO
# is SSA_IO_TIMEOUT_SEC=2, so a well-behaved sender never needs more than 2s.
SOCKET_READ_TIMEOUT = 2
# 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
# Per-UID admission cap. The socket is world-writable by design, so without
# this a single local UID can hold every MAX_INFLIGHT slot before any sender
# validation runs (the peer UID is only checked inside handle(), after a worker
# and slot are already committed), starving every other tenant's telemetry —
# a cross-principal denial of monitoring. Cap one peer UID at half the worker
# pool so no single UID can monopolise the executor; the remainder always stays
# available to other UIDs. Over-cap connections are dropped (best-effort
# telemetry). Privileged service peers (_PRIVILEGED_SERVICE_UIDS — root
# collectors, the LiteSpeed/Apache master) have no single-tenant identity and
# are exempt.
MAX_INFLIGHT_PER_UID = MAX_WORKERS // 2
# Sentinel default for handle()'s admission-release argument. It distinguishes
# "this connection went through the accept loop and holds an admission slot to
# release" (admitted_uid is an int or None) from "handle() was invoked directly
# without going through listen() and holds no slot" (admitted_uid is
# _NO_ADMISSION), so the finally block never over-releases the semaphore.
_NO_ADMISSION = object()
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)
# Per-UID in-flight counters (peer UID -> count), guarded by a lock:
# incremented by the accept loop at admission and decremented by
# handle()'s finally when a worker completes the connection. Bounds how
# many admission slots any single UID can hold (MAX_INFLIGHT_PER_UID).
self._per_uid_lock = Lock()
self._per_uid_inflight = {}
# Shared-web service UIDs (nobody/apache) resolved from pwd on first use
# and cached for the process lifetime; None until resolved. Used to gate
# the shared-handler owner-mismatch exemption in _authorize_sender.
self._shared_web_uids_cache = None
# Rate-limiting state for repetitive per-peer decision logs (accept/
# reject), keyed by peer UID and guarded by its own lock so one local
# peer cannot flood the agent log.
self._log_rate_lock = Lock()
self._log_rate_state = {}
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 _try_admit_uid(self, peer_uid: int) -> bool:
"""
Reserve a per-UID admission slot for peer_uid. Privileged service
peers (_PRIVILEGED_SERVICE_UIDS — root collectors / the LiteSpeed/
Apache master) have no single-tenant identity and legitimately serve
every domain, so they are exempt. Returns False when peer_uid already
holds MAX_INFLIGHT_PER_UID in-flight connections, so one local UID
cannot monopolise the executor and deny other tenants' telemetry.
"""
if peer_uid in self._PRIVILEGED_SERVICE_UIDS:
return True
with self._per_uid_lock:
if self._per_uid_inflight.get(peer_uid, 0) >= MAX_INFLIGHT_PER_UID:
return False
self._per_uid_inflight[peer_uid] = self._per_uid_inflight.get(peer_uid, 0) + 1
return True
def _release_uid(self, peer_uid: int) -> None:
"""Release a per-UID admission slot reserved by _try_admit_uid."""
if peer_uid in self._PRIVILEGED_SERVICE_UIDS:
return
with self._per_uid_lock:
remaining = self._per_uid_inflight.get(peer_uid, 0) - 1
if remaining > 0:
self._per_uid_inflight[peer_uid] = remaining
else:
self._per_uid_inflight.pop(peer_uid, None)
def _release_admission(self, peer_uid) -> None:
"""Release the global admission slot and, when the connection was
attributed to a UID, its per-UID slot."""
self._admission.release()
if peer_uid is not None:
self._release_uid(peer_uid)
# Usernames under which shared/DSO PHP runs as the SHARED web UID rather
# than the per-account UID (cPanel/LiteSpeed DSO 'nobody', Apache-module
# 'apache'). Resolved to numeric UIDs once, lazily, from pwd.
_SHARED_WEB_USERNAMES = ('nobody', 'apache')
def _shared_web_uids(self) -> frozenset:
"""
Numeric UIDs of the shared web service accounts (nobody/apache),
resolved from pwd and cached for the process lifetime. Usernames absent
from the passwd database are skipped (KeyError tolerated), so this is
robust on panels/OSes that ship only one of them. The shared-handler
owner-mismatch exemption in _authorize_sender applies ONLY when the peer
UID is one of these — a per-user tenant peer (its own account UID) can
never claim it, closing the cross-tenant spoof where one tenant reports
another tenant's dso/mod_php/module domain.
"""
if self._shared_web_uids_cache is None:
uids = set()
for name in self._SHARED_WEB_USERNAMES:
try:
uids.add(pwd.getpwnam(name).pw_uid)
except KeyError:
pass
self._shared_web_uids_cache = frozenset(uids)
return self._shared_web_uids_cache
# Repetitive per-peer decision logs (accept/reject) are rate-limited so a
# single local peer cannot flood the agent log: at most _LOG_RATE_MAX lines
# per key per _LOG_RATE_WINDOW seconds, then a one-line suppression summary
# when the window next rolls over. Error/exception logs are NEVER routed
# through this — only the per-request accept/reject lines are.
_LOG_RATE_WINDOW = 60
_LOG_RATE_MAX = 20
def _log_rate_limited(self, key, level: str, msg: str, *args) -> None:
"""
Emit ``self.logger.<level>(msg, *args)`` unless *key* (a peer UID) has
already produced _LOG_RATE_MAX lines in the current _LOG_RATE_WINDOW;
suppressed lines are counted and reported in a single summary line when
the next window opens. Thread-safe (guarded by _log_rate_lock), matching
the module's lock discipline.
"""
now = time.monotonic()
emit = False
summary = 0
with self._log_rate_lock:
state = self._log_rate_state.get(key)
if state is None or now - state['start'] >= self._LOG_RATE_WINDOW:
summary = state['suppressed'] if state else 0
self._log_rate_state[key] = {'start': now, 'emitted': 1, 'suppressed': 0}
emit = True
elif state['emitted'] < self._LOG_RATE_MAX:
state['emitted'] += 1
emit = True
else:
state['suppressed'] += 1
if summary:
getattr(self.logger, level)(
'[%s] suppressed %d repetitive log line(s) for peer key %r', current_thread().name, summary, key
)
if emit:
getattr(self.logger, level)(msg, *args)
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
# Resolve the kernel-trusted peer UID cheaply (SO_PEERCRED, no
# payload read) and enforce a per-UID cap BEFORE a worker is
# committed: sender validation only happens later inside handle(),
# so without this one local UID could hold every slot with idle
# connections and deny all other tenants' telemetry. A connection
# whose credentials cannot be read is not dropped here (it cannot
# be attributed to a UID); handle() closes it on the same failure.
try:
peer_uid = self._get_peer_uid(connection)
except (OSError, struct.error) as e:
self.logger.debug('[Admission] peer credentials unreadable, skipping per-UID cap: %s', str(e))
peer_uid = None
if peer_uid is not None and not self._try_admit_uid(peer_uid):
self.logger.warning(
'[Admission] per-UID cap (%d) reached for UID=%d; dropping connection',
MAX_INFLIGHT_PER_UID,
peer_uid,
)
connection.close()
self._admission.release()
continue
try:
# Pass peer_uid so handle() releases exactly the admission
# slot(s) reserved above (global + per-UID) in its finally,
# once per handled connection.
self.executor.submit(self.handle, connection, peer_uid)
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. Do NOT release the
# admission slot here either: the slot is released by handle()'s
# finally when a worker actually runs the queued item. If
# thread-starts keep failing and no worker ever runs it, the
# slot stays held and the gate correctly REFUSES new connections
# (bounded, fail-safe) rather than letting the executor's queue
# grow past the admission bound. Killing the agent would only
# trigger a restart loop, so just log and keep accepting.
self.logger.error('[ThreadPool] submit raised (work item queued for existing workers): %s', str(e))
continue
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_module.socket) -> 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
# Peer UIDs that legitimately serve every tenant and therefore have no
# single per-tenant identity to enforce: uid 0 (root) — the LiteSpeed/
# Apache master and root-owned telemetry collectors. Such a peer is
# trusted for any domain; every OTHER non-tenant peer (a shared DSO
# worker such as nobody/apache, or an unmappable UID) is still subjected
# to the cross-tenant owner check below.
_PRIVILEGED_SERVICE_UIDS = frozenset({0})
# PHP handlers under which PHP runs as the SHARED web UID (nobody/apache)
# rather than the per-account UID. Enumerated across every clcommon.cpapi
# backend: cPanel 'dso' (WHM php_get_handlers current_handler; mod_php),
# DirectAdmin 'mod_php' (custombuild php{N}_mode), and Plesk 'module' (the
# Apache-module handler — plesk.get_domains_php_info passes the raw <type>
# through verbatim, so PHP runs as the shared `apache` UID). For a domain on
# one of these a shared-UID peer (nobody/apache) IS the legitimate server,
# so an owner-UID mismatch is expected and must NOT be treated as a spoof.
# Every other handler (suphp/cgi/lsapi/fpm/fastcgi/php-fpm/x-httpd-*lsphp)
# runs PHP as the per-user account UID.
_SHARED_UID_HANDLERS = frozenset({'dso', 'mod_php', 'module'})
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 whose UID is a privileged root-owned service
(_PRIVILEGED_SERVICE_UIDS — uid 0: the LiteSpeed/Apache master,
root-run collectors) has no per-tenant identity to enforce and is
trusted for any domain. Every OTHER peer — a hosting account, a
shared DSO worker (apache, nobody=65534), a service account
(webapps), or a UID that maps to no panel user — is subjected to a
POSITIVE cross-tenant mismatch check: the reported domain's owner
resolves to a system UID different from the peer UID. A mismatch is NOT
rejected unconditionally, because it is also the legitimate shape of
DSO/mod_php self-telemetry: on a DSO domain PHP runs as the shared web
UID (nobody) while the domain's OWNER is the account, so owner_uid !=
peer_uid is expected. The mismatch is therefore disambiguated by the
REPORTED domain's own PHP handler (get_domains_php_info keyed on the
reported domain, see _reported_domain_allows_shared_peer): handlers are
PER-DOMAIN, so get_domains_php_info maps every domain — main, addon, sub
and alias — to its own handler_type. A shared/DSO handler ('dso'/
'mod_php'/'module') on the reported domain means the shared-UID peer
legitimately serves it -> accept (DSO self-telemetry preserved); a
per-user handler (suphp/cgi/lsapi/fpm/…) means PHP runs as the account
UID, so a shared/service peer reporting it is a spoof -> reject. Keying
on the REPORTED domain rather than the account's main domain is required:
a mixed account can serve a shared handler on its main domain yet a
per-user handler on a secondary/addon domain, so classifying by the main
domain would admit a spoof on the account's per-user secondary domains.
This closes the mixed-config spoof where a shared/service UID reports
metrics for a per-user tenant's domain it does not own, without dropping
legitimate DSO metrics. 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).
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. If cpusers() itself fails the tenant set degrades
to empty, but the owner check still runs for non-privileged peers, so
a positive cross-tenant mismatch on a per-user-handler domain is still
rejected.
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.
The shared-handler mismatch exemption is bound to a VERIFIED shared-web
peer UID: on a positive owner-UID mismatch the reported-domain handler
check runs only when peer_uid is itself a shared-web service UID
(nobody/apache, resolved by _shared_web_uids); every other peer — a
per-user tenant reporting a domain it does not own, even a DSO one — is
REJECTED. This closes the cross-tenant spoof where a legitimate per-user
tenant reports telemetry for another tenant's dso/mod_php/module domain.
Residual limit: fail-open on a mismatch is reserved for the case where
the OWNER cannot be resolved (domain_owner returns nothing / raises, or
the owner is not in passwd) — no distinct per-tenant kernel identity to
bind to, and the threat model here is per-user PHP. Once the owner IS
resolved to a distinct account, classification keys on the REPORTED
domain's own handler and errs toward REJECT: a reported domain that is
absent from an available map, or whose handler is None/unknown, is
rejected, so an unknown handler no longer fails open. When the panel
handler API itself is unavailable (get_domains_php_info raises — e.g.
InterWorx/ispmanager where it is unsupported, or a transient panel-DB
outage), the classifier now fails CLOSED: a shared-web peer's
owner-mismatch self-telemetry is DROPPED (rejected) rather than risk a
spoof, since a resolved distinct owner plus an unavailable classifier
must not authorize. This narrow residual FLIP is documented in
docs/design/ssa-agent-socket.md.
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()
) and peer_uid in self._PRIVILEGED_SERVICE_UIDS:
# Privileged root-owned service peer: no per-tenant identity to
# enforce, trusted for any domain. Every other non-tenant or
# unmappable peer falls through to the cross-tenant owner check
# below, so a shared/service UID cannot report a per-user
# tenant's domain it does not own.
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
if owner_uid == peer_uid:
return True
# Positive owner-UID mismatch. The shared-handler exemption applies ONLY
# when the PEER is itself a shared web service UID (nobody/apache) — the
# UID under which shared/DSO PHP actually runs. A per-user tenant peer
# (its OWN account UID, present in cpusers) can never legitimately serve
# another tenant's domain, so it is REJECTED here even if that domain
# runs a shared handler: this closes the cross-tenant spoof where tenant
# A reports telemetry for tenant B's dso/mod_php/module domain. Only a
# verified shared-web peer falls through to the per-reported-domain
# handler check (which keys on the REPORTED domain's own handler, not the
# owner account's main domain, so a mixed account's per-user secondary
# domain cannot be spoofed), preserving legitimate DSO self-telemetry.
if peer_uid in self._shared_web_uids():
return self._reported_domain_allows_shared_peer(domain)
return False
def _reported_domain_allows_shared_peer(self, domain: str) -> bool:
"""
True if the REPORTED domain serves PHP as a shared web UID, judged by
that domain's OWN handler in get_domains_php_info. Handlers are
per-domain: get_domains_php_info maps every domain — main, addon, sub and
alias — to its own handler_type (a mixed account can serve a shared
handler on its main domain and a per-user handler on a secondary), so the
reported domain must be classified directly.
This method is reached ONLY for a peer that is itself a verified shared-
web service UID (nobody/apache) on a positive owner-UID mismatch — the
caller (_authorize_sender) gates it on that. Returns True (accept) only
when the reported domain's handler is a shared-UID handler
(_SHARED_UID_HANDLERS). A per-user handler, a None/unknown handler, or a
reported domain absent from an available map returns False (reject): the
owner is a resolved DISTINCT per-user account, so even a shared-web peer
reporting it is a spoof.
Fails CLOSED (False, reject) when the panel cannot yield the handler map
at all — get_domains_php_info() raises (e.g. InterWorx/ispmanager where
it is unsupported, or the panel DB is momentarily down). RESIDUAL FLIP
(documented in docs/design/ssa-agent-socket.md): previously this branch
returned True, so a shared-UID self-report was accepted on API-unavailable
panels and a spoof was possible there. It now DROPS (rejects) a shared-web
peer's owner-mismatch self-telemetry on those backends rather than risk a
spoof — with the peer-UID gate above, this branch is reached only for a
shared-web peer whose reported domain resolves to a distinct owner, so a
resolved distinct owner plus an unavailable classifier must not authorize.
"""
try:
info = get_domains_php_info()
except Exception as e:
self.logger.warning(
'[%s] Panel handler API unavailable for %r, rejecting (fail closed): %s',
current_thread().name,
domain,
str(e),
)
return False
handler = (info.get(domain) or {}).get('handler_type')
return handler in self._SHARED_UID_HANDLERS
@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_module.socket, admitted_uid=_NO_ADMISSION) -> None:
"""
Handle incoming connection.
:param connection: socket object usable to send and receive data on the
connection.
:param admitted_uid: the peer UID this connection was admitted under in
the accept loop (an int; or None when its peer credentials were
unreadable there but a global admission slot was still taken). The
admission slot(s) reserved in listen() are released here in the
finally block — exactly once per handled connection — so a
queued-but-not-yet-run work item keeps its slot until a worker
actually runs handle() (F-02 admission accounting). The
_NO_ADMISSION sentinel means handle() was invoked directly (unit
tests) without going through the accept loop, so there is no
admission slot to release.
"""
try:
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
try:
input_data = self.read_input(connection)
if not self._validate_input(input_data):
self._log_rate_limited(
peer_uid,
'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._log_rate_limited(
peer_uid,
'warning',
'[%s] Rejected cross-tenant payload from UID=%d for url=%r',
current_thread().name,
peer_uid,
input_data['url'],
)
return
# The sustained-insert / on-disk storage vector is bounded
# OUTSIDE this dispatch, not by an insert quota here:
# RETENTION_TIME_DAYS=1 plus the daily cleanup_old_data + VACUUM
# (py/ssa/db.py) cap on-disk growth, and RequestProcessor's
# per-sender queue fairness cap (PER_SENDER_QUEUE_SHARE, keyed on
# this same peer UID) bounds the in-memory backlog. So the only
# per-request abuse surface left here is log volume, which the
# rate-limited accept/reject lines below address.
self.request_processor.handle(input_data, peer_uid)
self._log_rate_limited(
peer_uid, '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:
connection.close()
finally:
if admitted_uid is not _NO_ADMISSION:
self._release_admission(admitted_uid)
def read_input(self, connection: socket_module.socket) -> dict:
"""
Read the pre-auth payload from *connection* under a TOTAL wall-clock
deadline of SOCKET_READ_TIMEOUT seconds, then return the decoded JSON.
connection.settimeout() is only a PER-recv inactivity timeout, so a peer
that drips one byte just before each timeout could otherwise pin the
worker (and the admission slot it holds) indefinitely — a slow-read /
Slowloris primitive on the world-writable socket. The whole read is
therefore bounded by an absolute monotonic deadline: before every recv
the remaining budget is computed and installed as the socket timeout,
and a non-positive remainder raises socket.timeout. At most MAX_MSG_SIZE
bytes are read; an empty recv (EOF) ends the read. A legitimate client
sends its <1 KB payload in one recv well within the budget, so this is
transparent to well-behaved senders. Decoding / json.loads behaviour is
unchanged from the previous makefile-based read.
"""
deadline = time.monotonic() + SOCKET_READ_TIMEOUT
chunks = []
total = 0
while total < MAX_MSG_SIZE:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise socket_module.timeout('pre-auth read budget exceeded')
connection.settimeout(remaining)
chunk = connection.recv(MAX_MSG_SIZE - total)
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
data = b''.join(chunks).decode(errors='ignore')
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())
return {}