diff --git a/docs/changelog.270.txt b/docs/changelog.270.txt index 6854e238d..4bcc16aac 100644 --- a/docs/changelog.270.txt +++ b/docs/changelog.270.txt @@ -7,6 +7,7 @@ Bug fixes: - templates can use |sprintf without a Smarty 4 deprecation notice: XoopsTpl registers sprintf as a modifier, as it already does for strstr, trim and file_exists (class/template.php) (#264) Libraries and assets: +- update the bundled XMF library to 1.3.3: the proxy_env header is only honoured when the direct peer is listed in the new $xoopsConfig['proxy_trusted'] (addresses or CIDR ranges, documented in xoops_data/configs/xoopsconfig.dist.php) — sites that set proxy_env must add proxy_trusted, until then the peer address is used; Forwarded headers are parsed per RFC 7239; Xmf\Mail\SendmailRunner has a delivery timeout; Xmf\Database\Tables keeps CURRENT_TIMESTAMP defaults and refuses tables with functional index parts; Xmf\Security\Serializer accepts every legacy payload; Xmf\Jwt\TokenReader accepts Bearer tokens on custom headers again (xoops_lib/composer.dist.json, xoops_lib/vendor/xoops/xmf) - HTML Purifier 4.19.1, firebase/php-jwt 7.2.1, Monolog 3.12.1, Punic 3.8.2, Smarty 4.5.8, symfony/polyfill-mbstring 1.43.0, symfony/var-dumper 7.4.18, symfony/yaml 7.4.20, TCPDF 6.11.4, webmozart/assert 2.5.0 (xoops_lib/composer.dist.lock, xoops_lib/vendor) (#265) =================================== diff --git a/htdocs/xoops_data/configs/xoopsconfig.dist.php b/htdocs/xoops_data/configs/xoopsconfig.dist.php index fbe2782cc..96ad6f4bf 100644 --- a/htdocs/xoops_data/configs/xoopsconfig.dist.php +++ b/htdocs/xoops_data/configs/xoopsconfig.dist.php @@ -74,4 +74,14 @@ * The default if proxy_env is not specified is no proxy ip address is considered. */ //'proxy_env' => 'HTTP_FORWARDED', + + /** + * proxy_trusted lists the proxy servers or load balancers allowed to set + * the proxy_env header. The header is only used when the direct peer + * (REMOTE_ADDR) is in this list; entries in the header that are trusted + * proxies are skipped, and the first untrusted address is the client. + * Accepts an array or a comma separated string of IP addresses and CIDR + * ranges. If proxy_trusted is not specified, the proxy_env header is ignored. + */ + //'proxy_trusted' => ['10.0.0.1', '192.168.0.0/16', '2001:db8::/32'], ]; diff --git a/htdocs/xoops_lib/composer.dist.json b/htdocs/xoops_lib/composer.dist.json index 053cba667..1364fb45a 100644 --- a/htdocs/xoops_lib/composer.dist.json +++ b/htdocs/xoops_lib/composer.dist.json @@ -33,7 +33,7 @@ "xoops/helpers": "^1.0", "xoops/regdom": "^2.1.0", "xoops/smartyextensions": "^1.3", - "xoops/xmf": "^1.3.2" + "xoops/xmf": "^1.3.3" }, "suggest": { "ext-sodium": "Required for two-factor enrolment and authenticator-code verification" diff --git a/htdocs/xoops_lib/composer.dist.lock b/htdocs/xoops_lib/composer.dist.lock index af9e42a69..b6d29c33f 100644 --- a/htdocs/xoops_lib/composer.dist.lock +++ b/htdocs/xoops_lib/composer.dist.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1519108191e313200e6a370e4888cec8", + "content-hash": "e1feffdc17804ff281ac1b93767049b8", "packages": [ { "name": "boenrobot/money_format_polyfill", @@ -1817,16 +1817,16 @@ }, { "name": "xoops/xmf", - "version": "v1.3.2", + "version": "v1.3.3", "source": { "type": "git", "url": "https://github.com/XOOPS/xmf.git", - "reference": "ca3d0cefff69c78e89355de6c706918a5677736d" + "reference": "503968036436a44b2fd32d90f3c9e6651b834ed9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/XOOPS/xmf/zipball/ca3d0cefff69c78e89355de6c706918a5677736d", - "reference": "ca3d0cefff69c78e89355de6c706918a5677736d", + "url": "https://api.github.com/repos/XOOPS/xmf/zipball/503968036436a44b2fd32d90f3c9e6651b834ed9", + "reference": "503968036436a44b2fd32d90f3c9e6651b834ed9", "shasum": "" }, "require": { @@ -1887,7 +1887,7 @@ "type": "opencollective" } ], - "time": "2026-10-06T11:29:49+00:00" + "time": "2026-10-14T04:09:40+00:00" } ], "packages-dev": [], diff --git a/htdocs/xoops_lib/vendor/composer/installed.json b/htdocs/xoops_lib/vendor/composer/installed.json index 0f19bb5a8..eac3dcc7a 100644 --- a/htdocs/xoops_lib/vendor/composer/installed.json +++ b/htdocs/xoops_lib/vendor/composer/installed.json @@ -1886,17 +1886,17 @@ }, { "name": "xoops/xmf", - "version": "v1.3.2", - "version_normalized": "1.3.2.0", + "version": "v1.3.3", + "version_normalized": "1.3.3.0", "source": { "type": "git", "url": "https://github.com/XOOPS/xmf.git", - "reference": "ca3d0cefff69c78e89355de6c706918a5677736d" + "reference": "503968036436a44b2fd32d90f3c9e6651b834ed9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/XOOPS/xmf/zipball/ca3d0cefff69c78e89355de6c706918a5677736d", - "reference": "ca3d0cefff69c78e89355de6c706918a5677736d", + "url": "https://api.github.com/repos/XOOPS/xmf/zipball/503968036436a44b2fd32d90f3c9e6651b834ed9", + "reference": "503968036436a44b2fd32d90f3c9e6651b834ed9", "shasum": "" }, "require": { @@ -1912,7 +1912,7 @@ "roave/security-advisories": "dev-latest", "squizlabs/php_codesniffer": "^3.11 || ^4.0" }, - "time": "2026-10-06T11:29:49+00:00", + "time": "2026-10-14T04:09:40+00:00", "type": "library", "installation-source": "dist", "autoload": { diff --git a/htdocs/xoops_lib/vendor/composer/installed.php b/htdocs/xoops_lib/vendor/composer/installed.php index ed127e47c..79a9e69c0 100644 --- a/htdocs/xoops_lib/vendor/composer/installed.php +++ b/htdocs/xoops_lib/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'xoopscore27/libraries', 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => '97110d0d50fbcd040dd05fe597861967b54e061d', + 'reference' => '3b9bf84a8f6fe8b170148626b32942afc9088d0b', 'type' => 'metapackage', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -260,9 +260,9 @@ 'dev_requirement' => false, ), 'xoops/xmf' => array( - 'pretty_version' => 'v1.3.2', - 'version' => '1.3.2.0', - 'reference' => 'ca3d0cefff69c78e89355de6c706918a5677736d', + 'pretty_version' => 'v1.3.3', + 'version' => '1.3.3.0', + 'reference' => '503968036436a44b2fd32d90f3c9e6651b834ed9', 'type' => 'library', 'install_path' => __DIR__ . '/../xoops/xmf', 'aliases' => array(), @@ -271,7 +271,7 @@ 'xoopscore27/libraries' => array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => '97110d0d50fbcd040dd05fe597861967b54e061d', + 'reference' => '3b9bf84a8f6fe8b170148626b32942afc9088d0b', 'type' => 'metapackage', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Migrate.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Migrate.php index b592e1fec..3a022fd09 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Migrate.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Migrate.php @@ -93,6 +93,8 @@ function ($match) { * @internal intended for module developers only * * @return int|false count of bytes written or false on error + * + * @throws \RuntimeException if a current table definition cannot be read */ public function saveCurrentSchema() { @@ -110,19 +112,43 @@ public function saveCurrentSchema() /** * get the current definitions * + * Module tables that do not exist are left out. + * * @return array + * + * @throws \RuntimeException if a table definition cannot be read */ public function getCurrentSchema() { foreach ($this->moduleTables as $tableName) { - if (false === $this->tableHandler->useTable($tableName)) { - $this->tableHandler->addTable($tableName); - } + $this->loadCurrentTable($tableName); } return $this->tableHandler->dumpTables(); } + /** + * Load the current definition of a module table into the table handler + * + * @param string $tableName table to load + * + * @return bool true if the table exists, false if it does not + * + * @throws \RuntimeException if the table definition cannot be read + */ + protected function loadCurrentTable($tableName) + { + if ($this->tableHandler->useTable($tableName)) { + return true; + } + $error = $this->tableHandler->getLastError(); + if (!empty($error)) { + throw new \RuntimeException(sprintf('Cannot read table %s: %s', $tableName, $error)); + } + + return false; + } + /** * Return the target database condition * @@ -149,6 +175,8 @@ public function getTargetDefinitions() * @param bool $force true to force updates even if this is a 'GET' request * * @return bool true if no errors, false if errors encountered + * + * @throws \RuntimeException if a current table definition cannot be read */ public function synchronizeSchema($force = true) { @@ -161,13 +189,15 @@ public function synchronizeSchema($force = true) * Compare target and current schema, building work queue in $this->migrate to synchronized * * @return string[] array of DDL/SQL statements to transform current to target schema + * + * @throws \RuntimeException if a current table definition cannot be read */ public function getSynchronizeDDL() { $this->getTargetDefinitions(); $this->preSyncActions(); foreach ($this->moduleTables as $tableName) { - if ($this->tableHandler->useTable($tableName)) { + if ($this->loadCurrentTable($tableName)) { $this->synchronizeTable($tableName); } else { $this->addMissingTable($tableName); diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Tables.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Tables.php index 17d36b6e8..7b546085e 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Tables.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Database/Tables.php @@ -897,11 +897,13 @@ protected function fetch($result) /** * create default value clause for DDL * - * @param string|null $default the default value to be quoted + * @param string|null $default the default value to be quoted + * @param string|null $columnType COLUMN_TYPE of the column; when given, CURRENT_TIMESTAMP + * is only treated as a function for TIMESTAMP and DATETIME * * @return string the correctly quoted default value */ - protected function quoteDefaultClause($default) + protected function quoteDefaultClause($default, $columnType = null) { // . (($column['COLUMN_DEFAULT'] === null) ? '' : " DEFAULT '" . $column['COLUMN_DEFAULT'] . "' ") // no default specified @@ -912,8 +914,14 @@ protected function quoteDefaultClause($default) // functions should not be quoted // this section will need expanded when XOOPS minimum is no longer a mysql 5 version // Until mysql 8, only allowed function is CURRENT_TIMESTAMP - if ($default === 'CURRENT_TIMESTAMP') { - return ' DEFAULT CURRENT_TIMESTAMP '; + // MariaDB reports it as current_timestamp(), and either may carry a precision + // a string column can hold the literal text, so the column type decides + // Render the canonical CURRENT_TIMESTAMP form (empty parentheses dropped) so a + // Migrate comparison against a CURRENT_TIMESTAMP target does not churn on MariaDB + $temporal = $columnType === null || preg_match('/^(timestamp|datetime)\b/i', $columnType) === 1; + if ($temporal && preg_match('/^current_timestamp(?:\((\d*)\))?\z/i', $default, $matches)) { + $precision = isset($matches[1]) && $matches[1] !== '' ? '(' . $matches[1] . ')' : ''; + return ' DEFAULT CURRENT_TIMESTAMP' . $precision . ' '; } // surround default with quotes — escape embedded single quotes for valid DDL @@ -930,6 +938,9 @@ protected function quoteDefaultClause($default) */ protected function getTable($table) { + // errors describe this load only, so "missing" (true) has no error + $this->lastError = ''; + $this->lastErrNo = 0; $tableDef = array(); $sql = 'SELECT TABLE_NAME, ENGINE, CHARACTER_SET_NAME '; @@ -965,7 +976,10 @@ protected function getTable($table) while ($column = $this->fetch($result)) { $attributes = ' ' . $column['COLUMN_TYPE'] . ' ' . (($column['IS_NULLABLE'] === 'NO') ? ' NOT NULL ' : '') - . $this->quoteDefaultClause($column['COLUMN_DEFAULT']) + . $this->quoteDefaultClause( + $column['COLUMN_DEFAULT'], + is_string($column['COLUMN_TYPE']) ? $column['COLUMN_TYPE'] : null + ) //. $column['EXTRA']; . str_replace('DEFAULT_GENERATED ', '', $column['EXTRA']); @@ -995,9 +1009,20 @@ protected function getTable($table) $tableDef['keys'] = []; while ($key = $this->fetch($result)) { $currentKey = $key['INDEX_NAME']; - if (!is_string($currentKey) || !is_string($key['COLUMN_NAME'])) { + if (!is_string($currentKey)) { continue; } + // a functional key part (MySQL 8.0.13+) has no COLUMN_NAME; dropping it + // would misdescribe the index, so refuse the table instead + if (!is_string($key['COLUMN_NAME'])) { + $this->lastError = sprintf( + 'Index %s on table %s has a functional key part, which is not supported', + $currentKey, + $this->name($table) + ); + $this->lastErrNo = -1; + return false; + } if ($lastKey != $currentKey) { if (!empty($lastKey)) { diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/FilterInput.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/FilterInput.php index d96488554..2a1d6a659 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/FilterInput.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/FilterInput.php @@ -216,8 +216,8 @@ public static function clean($source, $type = 'string') */ public function cleanVar($source, $type = 'string') { - // Handle the type constraint - switch (strtoupper($type)) { + // Handle the type constraint (null type, as Request::getHeader() passes, means string) + switch (strtoupper((string) $type)) { case 'INT': case 'INTEGER': // Only use the first integer value diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/I18n/BlockPosition.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/I18n/BlockPosition.php index 05379ad66..cca837765 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/I18n/BlockPosition.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/I18n/BlockPosition.php @@ -28,15 +28,15 @@ final class BlockPosition { /** XOOPS convention: integer representing the left/start side */ - public const DEFAULT_LEFT = 1; + public const DEFAULT_LEFT = 0; /** XOOPS convention: integer representing the right/end side */ - public const DEFAULT_RIGHT = 0; + public const DEFAULT_RIGHT = 1; /** * Map a side integer to logical class based on convention. * - * @param int $side Physical side (convention: 1=left, 0=right in LTR context) + * @param int $side Physical side (XOOPS: 0=left, 1=right in LTR context) * @param int $leftValue What integer represents "left" in your CMS * @param int $rightValue What integer represents "right" in your CMS * diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Jwt/TokenReader.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Jwt/TokenReader.php index 8259a6e2c..339504529 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Jwt/TokenReader.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Jwt/TokenReader.php @@ -111,7 +111,9 @@ public static function fromHeader($key, $assertClaims = array(), $headerName = ' } $token = trim($parts[1]); } else { - $token = $header; + // custom headers may carry a bare token or "Bearer " + $parts = explode(' ', $header, 2); + $token = (count($parts) === 2 && strcasecmp($parts[0], 'Bearer') === 0) ? trim($parts[1]) : $header; } return static::fromString($key, $token, $assertClaims); } diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailException.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailException.php index 02bcb9cf3..fd37feb0d 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailException.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailException.php @@ -76,6 +76,18 @@ public static function prematurePipeClosure(): self return new self('sendmail closed the input pipe prematurely.'); } + /** + * Build an exception for a sendmail process that did not finish in time. + * + * @param int $seconds configured time limit + * + * @return self + */ + public static function timedOut(int $seconds): self + { + return new self('Sendmail did not finish within ' . $seconds . ' seconds.'); + } + /** * Build an exception for a non-zero sendmail exit code. * diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailRunner.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailRunner.php index ceedaabd9..456dae77c 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailRunner.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Mail/SendmailRunner.php @@ -26,6 +26,11 @@ * - Optional, validated envelope sender (-f) * - CRLF normalization (str_replace-based) * - Diagnostics: clipped stdout/stderr on failure; warns if stderr on success + * - Timeout: the sendmail process is stopped with SIGTERM, then SIGKILL. Only + * that process is signalled; proc_open() cannot start it in its own process + * group on POSIX, so children of a wrapper script are left to the wrapper. + * A timeout means "delivery outcome unknown", not "not delivered": callers + * must not resend automatically on SendmailException::timedOut(). * * Customize: * - Pass a custom $allowlist (array of absolute paths) in the constructor. @@ -58,13 +63,27 @@ final class SendmailRunner /** @var callable(string):bool */ private $isFile; + /** @var int wall-clock limit in seconds for one delivery, including process exit */ + private int $timeout; + + /** bytes of stdout/stderr kept for diagnostics; the rest is read and discarded */ + private const MAX_OUTPUT = 8192; + + /** seconds to wait for exit after SIGTERM before sending SIGKILL */ + private const TERMINATE_GRACE = 2; + public function __construct( ?array $allowlist = null, ?callable $isExecutable = null, ?callable $isLink = null, ?callable $isFile = null, - bool $allowSymlinks = true + bool $allowSymlinks = true, + int $timeout = 30 ) { + if ($timeout < 1) { + throw new \InvalidArgumentException('Sendmail timeout must be at least 1 second.'); + } + $this->timeout = $timeout; $this->allowlist = $allowlist ?? [ '/usr/sbin/sendmail', '/usr/lib/sendmail', @@ -154,7 +173,7 @@ public function validatePath(string $path): ?string * @return void * * @throws SendmailException on invalid path, process startup or pipe-open failures, - * write failures, premature pipe closure, or non-zero exit + * write failures, premature pipe closure, timeout, or non-zero exit */ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeFrom = null): void { @@ -163,8 +182,8 @@ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeF throw SendmailException::invalidPath(); } - // Normalize line endings to CRLF for RFC 5322 compliance (two-step, no double expansion). - $rfc822 = str_replace("\r\n", "\n", $rfc822); + // Normalize CRLF, lone CR and LF to CRLF for RFC 5322 compliance (two-step, no double expansion). + $rfc822 = str_replace(["\r\n", "\r"], "\n", $rfc822); $rfc822 = str_replace("\n", "\r\n", $rfc822); // Prefer the literal path if it resolves to the same canonical target; else use canonical. @@ -209,7 +228,8 @@ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeF $stdout = ''; $stderr = ''; - $code = null; + $exitCode = null; + $deadline = (int) hrtime(true) + $this->timeout * 1000000000; stream_set_blocking($stdin, false); stream_set_blocking($stdoutPipe, false); stream_set_blocking($stderrPipe, false); @@ -243,7 +263,12 @@ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeF break; } - $ready = @stream_select($read, $write, $except, 1); + $remaining = $deadline - (int) hrtime(true); + if ($remaining <= 0) { + throw SendmailException::timedOut($this->timeout); + } + $wait = min($remaining, 1000000000); + $ready = @stream_select($read, $write, $except, 0, intdiv($wait, 1000)); if ($ready === false) { throw SendmailException::failedToOpenPipes(); } @@ -277,14 +302,14 @@ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeF } foreach ($read as $stream) { - $chunk = stream_get_contents($stream); + $chunk = fread($stream, $chunkSize); if ($chunk === false || $chunk === '') { continue; } if ($stream === $stdoutPipe) { - $stdout .= $chunk; + $stdout .= substr($chunk, 0, self::MAX_OUTPUT - strlen($stdout)); } elseif ($stream === $stderrPipe) { - $stderr .= $chunk; + $stderr .= substr($chunk, 0, self::MAX_OUTPUT - strlen($stderr)); } } } @@ -292,6 +317,12 @@ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeF if ($stdinOpen && $off < $len) { throw SendmailException::prematurePipeClosure(); } + + // The pipes can close before the process exits. + $exitCode = $this->waitForExit($proc, $deadline); + if ($exitCode === null) { + throw SendmailException::timedOut($this->timeout); + } } finally { if (is_resource($stdin)) { fclose($stdin); @@ -302,10 +333,17 @@ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeF if (is_resource($stderrPipe)) { fclose($stderrPipe); } - // $proc is guaranteed a resource here (checked right after proc_open), - // so close it unconditionally; this also makes $code an int below. - $code = proc_close($proc); + // On an error path, give the process until the deadline to exit, then + // terminate it, so proc_close() cannot block the request indefinitely. + if ($exitCode === null && $this->waitForExit($proc, $deadline) === null) { + $this->terminate($proc); + } + proc_close($proc); } + // Use the code from proc_get_status(): proc_close() returns -1 on PHP < 8.3 + // once proc_get_status() has seen the exit. A null code threw timedOut() + // above, so the code is an int here. + $code = $exitCode; // Warn if stderr contains content despite success. if ($code === 0 && $stderr !== '') { @@ -327,6 +365,43 @@ public function deliver(string $sendmailPath, string $rfc822, ?string $envelopeF /* ====================== helpers ====================== */ + /** + * Poll until the process exits or the deadline passes. + * + * @param resource $proc process from proc_open() + * @param int $deadline hrtime() value in nanoseconds + * + * @return int|null exit code, or null if the process is still running (or stopped + * by a signal: it has not exited, and proc_close() would block on it) + */ + private function waitForExit($proc, int $deadline): ?int + { + while (true) { + $status = proc_get_status($proc); + if (!$status['running'] && !$status['stopped']) { + return $status['exitcode']; + } + if ((int) hrtime(true) >= $deadline) { + return null; + } + usleep(10000); + } + } + + /** + * Stop a process: SIGTERM, then SIGKILL if it is still running after the grace period. + * + * @param resource $proc process from proc_open() + */ + private function terminate($proc): void + { + proc_terminate($proc); + $grace = (int) hrtime(true) + self::TERMINATE_GRACE * 1000000000; + if ($this->waitForExit($proc, $grace) === null) { + proc_terminate($proc, 9); + } + } + /** * Filesystem checks for the target binary. * Uses $real (canonical target) when provided; otherwise uses $path. diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/ProxyCheck.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/ProxyCheck.php index 81b6a4bcf..40dbf8a13 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/ProxyCheck.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/ProxyCheck.php @@ -15,6 +15,12 @@ /** * ProxyCheck * + * Finds the client address in a proxy header. The header named in + * $xoopsConfig['proxy_env'] is only used when the direct peer (REMOTE_ADDR) + * is listed in $xoopsConfig['proxy_trusted']. Header entries are read from + * right to left, skipping trusted proxies; the first untrusted entry is the + * client. Any entry that is not an IP address stops the search. + * * @category Xmf\ProxyCheck * @package Xmf * @author Richard Griffith @@ -25,6 +31,8 @@ class ProxyCheck { public const PROXY_ENVIRONMENT_VARIABLE = 'proxy_env'; + public const TRUSTED_PROXIES_VARIABLE = 'proxy_trusted'; + public const FORWARDED = 'HTTP_FORWARDED'; /** @var string|false header name determines how to process */ @@ -33,6 +41,12 @@ class ProxyCheck /** @var string|false header data to process */ protected $proxyHeader = false; + /** @var string[] trusted proxy addresses and CIDR ranges */ + protected $trustedProxies = []; + + /** @var string|false address of the direct peer */ + protected $remoteAddr = false; + /** * ProxyCheck constructor. */ @@ -41,35 +55,50 @@ public function __construct() /* must declare expected proxy in $xoopsConfig['proxy_env'] */ $this->proxyHeaderName = $this->getProxyEnvConfig(); $this->proxyHeader = $this->getProxyHeader(); + $this->trustedProxies = $this->getTrustedProxiesConfig(); + $this->remoteAddr = (isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR'])) + ? $_SERVER['REMOTE_ADDR'] + : false; } /** * Get IP address from proxy header specified in $xoopsConfig['proxy_env'] * * Returns proxy revealed valid client address, or false if such address was - * not found. + * not found or the request did not come from a trusted proxy. * * @return string|false */ public function get() { - if (false === $this->proxyHeaderName || false === $this->proxyHeader) { + if (false === $this->proxyHeaderName || !is_string($this->proxyHeader)) { return false; } - $proxyVars = $this->splitOnComma($this->proxyHeader); - // only consider the first (left most) value - $header = reset($proxyVars); - $ip = false; - switch ($this->proxyHeaderName) { - case static::FORWARDED: - $ip = $this->getFor($header); - break; - default: - $ip = $this->getXForwardedFor($header); - break; + if (!is_string($this->remoteAddr) || !$this->isTrustedProxy($this->remoteAddr)) { + return false; } - return $ip; + if ($this->proxyHeaderName === static::FORWARDED) { + $candidates = $this->parseForwarded($this->proxyHeader); + } else { + $candidates = $this->splitOnComma($this->proxyHeader); + } + if (false === $candidates) { + return false; + } + + // walk from the proxy closest to us towards the client + for ($i = count($candidates) - 1; $i >= 0; $i--) { + $ip = $this->cleanNode($candidates[$i]); + if (false === $ip) { + return false; + } + if (!$this->isTrustedProxy($ip)) { + return $this->validateRoutableIP($ip); + } + } + + return false; } /** @@ -88,7 +117,7 @@ protected function splitOnComma($header) /** * get configured proxy environment variable * - * @return string|bool + * @return string|false */ protected function getProxyEnvConfig() { @@ -104,6 +133,36 @@ protected function getProxyEnvConfig() return trim($xoopsConfig[static::PROXY_ENVIRONMENT_VARIABLE]); } + /** + * get the trusted proxy list from $xoopsConfig['proxy_trusted'] + * + * Accepts an array or a comma separated string of addresses and CIDR ranges. + * + * @return string[] + */ + protected function getTrustedProxiesConfig() + { + global $xoopsConfig; + + if (!is_array($xoopsConfig) || !isset($xoopsConfig[self::TRUSTED_PROXIES_VARIABLE])) { + return []; + } + $list = $xoopsConfig[self::TRUSTED_PROXIES_VARIABLE]; + if (is_string($list)) { + $list = explode(',', $list); + } + if (!is_array($list)) { + return []; + } + $trusted = []; + foreach ($list as $entry) { + if (is_string($entry) && trim($entry) !== '') { + $trusted[] = trim($entry); + } + } + return $trusted; + } + /** * get the configured proxy header * @@ -120,26 +179,173 @@ protected function getProxyHeader() } /** - * Extract 'for' IP address in FORWARDED header as in RFC 7239 + * Get the 'for' node of each element of a FORWARDED header as in RFC 7239 + * + * Delimiters inside quoted strings are respected and parameter names are + * case-insensitive. * * @param string $header * - * @return string|false IP address, or false if invalid + * @return string[]|false one node per element, or false if the header is malformed + * or an element does not have exactly one 'for' parameter + */ + protected function parseForwarded($header) + { + $nodes = []; + $element = []; + $pair = ''; + $inQuotes = false; + $length = strlen($header); + for ($i = 0; $i <= $length; $i++) { + $char = ($i < $length) ? $header[$i] : ','; + if ($inQuotes) { + if ($i === $length) { + return false; + } + if ($char === '\\' && $i + 1 < $length) { + $pair .= $header[++$i]; + } elseif ($char === '"') { + $inQuotes = false; + } else { + $pair .= $char; + } + continue; + } + if ($char === '"') { + $inQuotes = true; + } elseif ($char === ';' || $char === ',') { + $element[] = $pair; + $pair = ''; + if ($char === ',') { + $node = $this->getForNode($element); + if (false === $node) { + return false; + } + $nodes[] = $node; + $element = []; + } + } else { + $pair .= $char; + } + } + + return $nodes; + } + + /** + * Find the single 'for' value among the name=value pairs of one element + * + * @param string[] $pairs + * + * @return string|false */ - protected function getFor($header) + protected function getForNode(array $pairs) { - $start = strpos($header, 'for='); - if ($start === false) { + $node = false; + foreach ($pairs as $pair) { + $parts = explode('=', $pair, 2); + if (count($parts) === 2 && strcasecmp(trim($parts[0]), 'for') === 0) { + if (false !== $node) { + return false; + } + $node = trim($parts[1]); + } + } + + return $node; + } + + /** + * Reduce a header node to a bare IP address, removing brackets and port + * + * @param string $node + * + * @return string|false IP address, or false if the node is not an IP address + */ + protected function cleanNode($node) + { + $node = trim($node); + // "[v6]", "[v6]:port" or "v4:port"; anything else after the address is rejected + if (preg_match('/^\[([^\]]*)\](?::\d+)?\z/', $node, $matches)) { + $node = $matches[1]; + } elseif (preg_match('/^([^:]*):\d+\z/', $node, $matches)) { + $node = $matches[1]; + } + + return (false === filter_var($node, FILTER_VALIDATE_IP)) ? false : $node; + } + + /** + * Check an address against the trusted proxy list + * + * @param string $ip + * + * @return bool + */ + protected function isTrustedProxy($ip) + { + $address = @inet_pton($ip); + if (false === $address) { return false; } - $ip = substr($header, $start + 4); - $end = strpos($ip, ';'); - if ($end !== false) { - $ip = substr($ip, 0, $end); + $address = $this->unmapIPv4($address); + foreach ($this->trustedProxies as $entry) { + $parts = explode('/', $entry, 2); + $packed = @inet_pton(trim($parts[0])); + if (false === $packed) { + continue; + } + $network = $this->unmapIPv4($packed); + if (strlen($network) !== strlen($address)) { + continue; + } + $bits = strlen($address) * 8; + if (isset($parts[1])) { + $prefix = trim($parts[1]); + if (!ctype_digit($prefix)) { + continue; + } + $prefix = (int) $prefix; + if (strlen($packed) !== strlen($network)) { + // a mapped entry's prefix counts the 96 bit ::ffff:0:0 part + $prefix -= 96; + } + if ($prefix < 0 || $prefix > $bits) { + continue; + } + $bits = $prefix; + } + $bytes = intdiv($bits, 8); + if (substr($address, 0, $bytes) !== substr($network, 0, $bytes)) { + continue; + } + $rest = $bits % 8; + if ($rest === 0) { + return true; + } + $mask = (0xff << (8 - $rest)) & 0xff; + if ((ord($address[$bytes]) & $mask) === (ord($network[$bytes]) & $mask)) { + return true; + } } - $ip = trim($ip, '"[] '); - return $this->validateRoutableIP($ip); + return false; + } + + /** + * Reduce an IPv4-mapped IPv6 address (::ffff:a.b.c.d, as a dual-stack listener + * reports an IPv4 peer) to its 4 byte IPv4 form so it matches IPv4 trust entries + * + * @param string $packed address from inet_pton() + * + * @return string + */ + protected function unmapIPv4($packed) + { + if (strlen($packed) === 16 && strncmp($packed, "\0\0\0\0\0\0\0\0\0\0\xff\xff", 12) === 0) { + return substr($packed, 12); + } + return $packed; } /** diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/SerializableTrait.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/SerializableTrait.php index 85781ae95..d25dbf2dd 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/SerializableTrait.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/SerializableTrait.php @@ -131,7 +131,13 @@ public function migrateSerializedData(string $property, string $oldData): bool if ($format === Format::PHP || $format === Format::LEGACY) { try { - $value = $this->unserializeProperty($oldData); + // a unique default tells a decoding failure apart from a stored null, + // so corrupt data is never overwritten + $failed = new \stdClass(); + $value = $this->unserializeProperty($oldData, $failed); + if ($value === $failed) { + throw new \UnexpectedValueException('stored data could not be decoded'); + } $newData = $this->serializeProperty($value, Format::JSON); $this->setVar($property, $newData); diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/Serializer.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/Serializer.php index 5ba1a26a6..9364881b4 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/Serializer.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Security/Serializer.php @@ -37,6 +37,9 @@ final class Serializer { private const MAX_SIZE = 5000000; // 5MB + + /** base64 length of a MAX_SIZE payload: 4 * ceil(MAX_SIZE / 3) */ + private const MAX_LEGACY_SIZE = 6666668; private const JSON_DEPTH = 512; private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; @@ -303,18 +306,19 @@ public static function toLegacy($data): string */ public static function fromLegacy(string $payload, array $allowedClasses = []) { - self::validateInput($payload); + // base64 input may be up to a third larger than the MAX_SIZE that toLegacy() allows + self::validateInput($payload, self::MAX_LEGACY_SIZE); self::validateSecurity($payload, empty($allowedClasses)); // Try plain PHP serialize first - $result = self::tryUnserialize($payload, $allowedClasses); - if ($result !== null) { + if (\strlen($payload) <= self::MAX_SIZE && self::tryUnserialize($payload, $allowedClasses, $result)) { self::logLegacy($payload); return $result; } // Check if it looks like base64 before attempting decode if (!self::isLikelyBase64($payload)) { + self::validateInput($payload); throw new RuntimeException('Invalid legacy format: not serialized or base64'); } @@ -394,10 +398,19 @@ public static function detect(string $payload): string return Format::AUTO; } + // nothing larger can be decoded; don't parse it here + if (\strlen($payload) > self::MAX_LEGACY_SIZE) { + return Format::AUTO; + } + $trimmed = ltrim($payload); // Check JSON (use trimmed for consistency with the leading-char check) - if (isset($trimmed[0]) && ($trimmed[0] === '{' || $trimmed[0] === '[')) { + if ( + isset($trimmed[0]) + && ($trimmed[0] === '{' || $trimmed[0] === '[') + && \strlen($payload) <= self::MAX_SIZE + ) { if (self::isValidJson($trimmed)) { return Format::JSON; } @@ -414,7 +427,7 @@ public static function detect(string $payload): string if ( $decoded !== false && \strlen($decoded) <= self::MAX_SIZE - && self::looksLikeSerialized($decoded) + && (self::looksLikeSerialized($decoded) || self::isGzip($decoded)) ) { return Format::LEGACY; } @@ -543,7 +556,7 @@ public static function tryFrom(string $payload, $default = null, string $format */ public static function jsonOnly(string $payload) { - if ($payload === '' || !isset(ltrim($payload)[0])) { + if ($payload === '' || !isset(ltrim($payload)[0]) || \strlen($payload) > self::MAX_SIZE) { return null; } @@ -614,21 +627,22 @@ public static function setLegacyLogger(?callable $logger): void /** * @param string $payload + * @param int $maxSize * * @return void * * @throws UnexpectedValueException * @throws RuntimeException */ - private static function validateInput(string $payload): void + private static function validateInput(string $payload, int $maxSize = self::MAX_SIZE): void { if ($payload === '') { throw new UnexpectedValueException('Cannot deserialize empty string'); } - if (\strlen($payload) > self::MAX_SIZE) { + if (\strlen($payload) > $maxSize) { throw new RuntimeException( - sprintf('Payload exceeds maximum size of %d bytes', self::MAX_SIZE) + sprintf('Payload exceeds maximum size of %d bytes', $maxSize) ); } } @@ -674,11 +688,14 @@ private static function safeGzipDecode(string $data): string $chunkSize = 8192; $maxSize = self::MAX_SIZE; - while ($offset < \strlen($data)) { + $length = \strlen($data); + while ($offset < $length) { $chunk = substr($data, $offset, $chunkSize); $offset += \strlen($chunk); - $inflated = inflate_add($context, $chunk); + // the last chunk finishes the stream; an extra empty ZLIB_FINISH after the + // end of the stream would leave a buffer error status instead of STREAM_END + $inflated = inflate_add($context, $chunk, $offset >= $length ? ZLIB_FINISH : ZLIB_SYNC_FLUSH); if ($inflated === false) { throw new DecompressionException('Gzip decompression failed'); } @@ -689,23 +706,17 @@ private static function safeGzipDecode(string $data): string sprintf('Decompressed payload exceeds %d bytes', $maxSize) ); } + if (inflate_get_status($context) === ZLIB_STREAM_END) { + break; + } } - // Flush remaining and verify stream completed - $inflated = inflate_add($context, '', ZLIB_FINISH); - if ($inflated === false) { - throw new DecompressionException('Gzip decompression failed during final flush'); - } if (inflate_get_status($context) !== ZLIB_STREAM_END) { throw new DecompressionException('Gzip stream is incomplete or truncated'); } - if ($inflated !== '') { - $output .= $inflated; - if (\strlen($output) > $maxSize) { - throw new DecompressionException( - sprintf('Decompressed payload exceeds %d bytes', $maxSize) - ); - } + // toLegacy() data is a single gzip member; reject trailing bytes or further members + if (inflate_get_read_len($context) !== $length) { + throw new DecompressionException('Unexpected data after the gzip stream'); } return $output; @@ -773,19 +784,25 @@ static function (int $severity, string $message): bool { } /** - * Attempt unserialization, returning null on failure instead of throwing. + * Attempt unserialization, reporting failure instead of throwing. * - * @param string $payload + * Success is returned separately so that a serialized null (N;) is not + * mistaken for a failure. + * + * @param string $payload * @param array $allowedClasses + * @param mixed $result set to the unserialized value on success * - * @return mixed|null + * @return bool true on success */ - private static function tryUnserialize(string $payload, array $allowedClasses) + private static function tryUnserialize(string $payload, array $allowedClasses, &$result): bool { try { - return self::unserialize($payload, $allowedClasses); + $result = self::unserialize($payload, $allowedClasses); + return true; } catch (\Throwable $e) { - return null; + $result = null; + return false; } } @@ -853,8 +870,8 @@ private static function isLikelyBase64(string $s): bool { $len = \strlen($s); - // Require reasonable minimum length and proper block alignment - if ($len < 16 || ($len % 4) !== 0) { + // Require proper block alignment; short values such as toLegacy(false) are valid + if ($len === 0 || ($len % 4) !== 0) { return false; } @@ -898,13 +915,12 @@ private static function logLegacy(string $payload): void } $preview = \substr($payload, 0, 50) . (\strlen($payload) > 50 ? '...' : ''); - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5); + // A frame's file/line is where its function was called, so the first + // frame outside this file is the call into the Serializer. + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10); $caller = ['file' => 'unknown', 'line' => 0]; foreach ($trace as $frame) { - if (!isset($frame['file'], $frame['line'])) { - continue; - } - if (isset($frame['class']) && $frame['class'] === self::class) { + if (!isset($frame['file'], $frame['line']) || $frame['file'] === __FILE__) { continue; } $caller = $frame; diff --git a/htdocs/xoops_lib/vendor/xoops/xmf/src/Ulid.php b/htdocs/xoops_lib/vendor/xoops/xmf/src/Ulid.php index d0f9e7c5c..9d4819e40 100644 --- a/htdocs/xoops_lib/vendor/xoops/xmf/src/Ulid.php +++ b/htdocs/xoops_lib/vendor/xoops/xmf/src/Ulid.php @@ -461,6 +461,7 @@ private static function toHex(string $ulid): string * * @return \DateTimeImmutable The timestamp as a DateTime object * @throws \InvalidArgumentException If the ULID is invalid + * @throws \RuntimeException If the timestamp cannot be converted */ public static function getDateTime(string $ulid): \DateTimeImmutable {