From c8afca6b27244c803b82606d9fd460ebf5ba2ed9 Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Fri, 23 Oct 2026 15:40:10 -0300 Subject: [PATCH] fix: bound WXIS execution and harden PHP-FPM --- docker-compose.yml | 2 + docker/rocky9/Dockerfile | 4 + docker/rocky9/entrypoint.sh | 10 +++ docker/rocky9/httpd-scielo.conf | 5 ++ docker/rocky9/php-fpm-scielo.conf | 12 +++ htdocs/health.php | 22 ++++++ htdocs/scielo.php | 58 ++++++++++++-- htdocs/wxis_runner.php | 126 ++++++++++++++++++++++++++++++ tests/security_regression.php | 27 +++++++ 9 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 docker/rocky9/php-fpm-scielo.conf create mode 100644 htdocs/health.php create mode 100644 htdocs/wxis_runner.php diff --git a/docker-compose.yml b/docker-compose.yml index 0f1d61db3..f7674c475 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,8 @@ services: STANDARD_LANG: ${STANDARD_LANG:-} ACTIVATE_GOOGLE: ${ACTIVATE_GOOGLE:-} GOOGLE_CODE: ${GOOGLE_CODE:-} + SCIELO_WXIS_TIMEOUT_SECONDS: ${SCIELO_WXIS_TIMEOUT_SECONDS:-20} + SCIELO_FPM_MAX_CHILDREN: ${SCIELO_FPM_MAX_CHILDREN:-8} volumes: - /Users/rondinelisaad/Downloads/repo-scielo/comciencia_scielo_br_new:/var/www/html restart: unless-stopped diff --git a/docker/rocky9/Dockerfile b/docker/rocky9/Dockerfile index 461e17c3e..15e04b2d7 100644 --- a/docker/rocky9/Dockerfile +++ b/docker/rocky9/Dockerfile @@ -115,6 +115,7 @@ RUN dnf -y --refresh upgrade \ COPY docker/rocky9/httpd-scielo.conf /etc/httpd/conf.d/scielo.conf COPY docker/rocky9/php-scielo.ini /etc/php.d/99-scielo.ini +COPY docker/rocky9/php-fpm-scielo.conf /etc/php-fpm.d/zz-scielo.conf COPY docker/rocky9/entrypoint.sh /usr/local/bin/entrypoint-scielo.sh COPY docker/rocky9/scielo-instance-check.sh /usr/local/bin/scielo-instance-check.sh @@ -130,4 +131,7 @@ LABEL org.opencontainers.image.version="${PHP_VERSION}" \ EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl --fail --silent --show-error --max-time 8 http://127.0.0.1/health.php || exit 1 + CMD ["/usr/local/bin/entrypoint-scielo.sh"] diff --git a/docker/rocky9/entrypoint.sh b/docker/rocky9/entrypoint.sh index 003dc7701..601141447 100644 --- a/docker/rocky9/entrypoint.sh +++ b/docker/rocky9/entrypoint.sh @@ -11,6 +11,16 @@ fi mkdir -p /run/php-fpm chown apache:apache /run/php-fpm +SCIELO_FPM_MAX_CHILDREN_VALUE="${SCIELO_FPM_MAX_CHILDREN:-8}" +if [[ ! "$SCIELO_FPM_MAX_CHILDREN_VALUE" =~ ^[0-9]+$ ]] \ + || (( SCIELO_FPM_MAX_CHILDREN_VALUE < 2 || SCIELO_FPM_MAX_CHILDREN_VALUE > 64 )); then + echo "Invalid SCIELO_FPM_MAX_CHILDREN: use an integer between 2 and 64" >&2 + exit 1 +fi +sed -ri \ + "s#^pm.max_children = .*$#pm.max_children = ${SCIELO_FPM_MAX_CHILDREN_VALUE}#" \ + /etc/php-fpm.d/zz-scielo.conf + # Ensure required def files exist. if [[ ! -f /var/www/html/htdocs/scielo.def.php && -f /var/www/html/htdocs/scielo.def.php.template ]]; then cp /var/www/html/htdocs/scielo.def.php.template /var/www/html/htdocs/scielo.def.php diff --git a/docker/rocky9/httpd-scielo.conf b/docker/rocky9/httpd-scielo.conf index 3b74f0b5c..a4569f619 100644 --- a/docker/rocky9/httpd-scielo.conf +++ b/docker/rocky9/httpd-scielo.conf @@ -2,6 +2,7 @@ ServerName localhost ServerTokens Prod ServerSignature Off TraceEnable Off +ProxyTimeout 35 DocumentRoot "/var/www/html/htdocs" DirectoryIndex scielo.php index.php index.html @@ -21,6 +22,10 @@ Alias /pdf/ "/var/www/html/bases/pdf/" Require all denied + + Require local + + Require all denied diff --git a/docker/rocky9/php-fpm-scielo.conf b/docker/rocky9/php-fpm-scielo.conf new file mode 100644 index 000000000..8dddd44e7 --- /dev/null +++ b/docker/rocky9/php-fpm-scielo.conf @@ -0,0 +1,12 @@ +[www] +pm = dynamic +pm.max_children = 8 +pm.start_servers = 2 +pm.min_spare_servers = 1 +pm.max_spare_servers = 3 +pm.max_requests = 250 + +request_slowlog_timeout = 5s +slowlog = /proc/self/fd/2 +request_terminate_timeout = 30s +catch_workers_output = yes diff --git a/htdocs/health.php b/htdocs/health.php new file mode 100644 index 000000000..b0d05e3fe --- /dev/null +++ b/htdocs/health.php @@ -0,0 +1,22 @@ + 'runtime_dependency_unavailable') + ); +} + +echo json_encode(array('status' => $healthy ? 'ok' : 'unavailable')); diff --git a/htdocs/scielo.php b/htdocs/scielo.php index fcb1c93cd..0c31265d1 100644 --- a/htdocs/scielo.php +++ b/htdocs/scielo.php @@ -2,6 +2,7 @@ require_once("classScielo.php"); require_once('applications/scielo-org/sso/header.php'); + require_once(__DIR__ . '/wxis_runner.php'); // Create new Scielo object $host = $_SERVER['HTTP_HOST']; @@ -22,7 +23,6 @@ $filenamePage = $scielo->GetPageFile(); } - $filenamePage = ""; $pageContent = ""; $GRAVA = false; @@ -335,7 +335,7 @@ function wxis_exe ($url){ if (isset($_COOKIE["userID"]) && $_COOKIE["userID"] != "-2"){ if ($_REQUEST["script"] == 'sci_arttext' or $_REQUEST["script"] == 'sci_abstract' or $_REQUEST["script"] == 'sci_home' or $_REQUEST["script"] == ''){ $restrito = true; - $useCache == '0'; + $useCache = '0'; } } @@ -404,14 +404,58 @@ function wxis_exe_ ($url){ $args = array_filter(explode("&", $query), "strlen"); } - $request = escapeshellarg($wxisBinary); + $command = array($wxisBinary); foreach ($args as $arg) { - $request .= " ".escapeshellarg($arg); + $command[] = $arg; + } + $command[] = "PATH_TRANSLATED=".$PATH_HTDOCS; + + $scriptName = scielo_wxis_script_name($args); + try { + $execution = scielo_run_process_with_timeout( + $command, + scielo_wxis_timeout_seconds() + ); + } catch (Throwable $error) { + scielo_audit_event( + 'system.wxis_execution', + 'failure', + 'wxis_script', + $scriptName, + array('reason' => 'process_start_failed') + ); + if (!headers_sent()) { + http_response_code(503); + header('Retry-After: 5'); + } + return ''; + } + + $result = $execution['timed_out'] || $execution['exit_code'] !== 0 + ? 'failure' + : 'success'; + scielo_audit_event( + 'system.wxis_execution', + $result, + 'wxis_script', + $scriptName, + array( + 'duration_ms' => $execution['duration_ms'], + 'exit_code' => $execution['exit_code'], + 'timed_out' => $execution['timed_out'], + ) + ); + + if ($execution['timed_out']) { + if (!headers_sent()) { + http_response_code(503); + header('Retry-After: 5'); + } + return ''; } - $request .= " ".escapeshellarg("PATH_TRANSLATED=".$PATH_HTDOCS); - $output = shell_exec($request); - if ($output === null || $output === false) { + $output = $execution['stdout']; + if ($output === '') { return ''; } diff --git a/htdocs/wxis_runner.php b/htdocs/wxis_runner.php new file mode 100644 index 000000000..e4058b00e --- /dev/null +++ b/htdocs/wxis_runner.php @@ -0,0 +1,126 @@ + array('pipe', 'w'), + 2 => array('pipe', 'w'), + ); + $pipes = array(); + $process = proc_open( + array_values($command), + $descriptors, + $pipes, + null, + null, + array('bypass_shell' => true) + ); + + if (!is_resource($process)) { + throw new RuntimeException('Unable to start WXIS'); + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $startedAt = microtime(true); + $deadline = $startedAt + $timeoutSeconds; + $stdout = ''; + $stderr = ''; + $timedOut = false; + $lastStatus = null; + + while (true) { + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + $lastStatus = proc_get_status($process); + + if (!$lastStatus['running']) { + break; + } + + if (microtime(true) >= $deadline) { + $timedOut = true; + proc_terminate($process, 15); + + $graceDeadline = microtime(true) + 0.25; + do { + usleep(10000); + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + $lastStatus = proc_get_status($process); + } while ($lastStatus['running'] && microtime(true) < $graceDeadline); + + if ($lastStatus['running']) { + proc_terminate($process, 9); + } + break; + } + + usleep(10000); + } + + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $closeExitCode = proc_close($process); + $exitCode = is_array($lastStatus) && isset($lastStatus['exitcode']) + && $lastStatus['exitcode'] >= 0 + ? $lastStatus['exitcode'] + : $closeExitCode; + + return array( + 'stdout' => $stdout, + 'stderr' => substr($stderr, 0, 1000), + 'exit_code' => $exitCode, + 'timed_out' => $timedOut, + 'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000), + ); +} + +function scielo_wxis_timeout_seconds() +{ + $configured = getenv('SCIELO_WXIS_TIMEOUT_SECONDS'); + if ($configured !== false && preg_match('/^[0-9]+$/D', $configured)) { + $seconds = (int) $configured; + if ($seconds >= 1 && $seconds <= 120) { + return $seconds; + } + } + + return 20; +} + +function scielo_wxis_script_name($arguments) +{ + foreach ($arguments as $argument) { + if (strpos($argument, 'IsisScript=') !== 0) { + continue; + } + + $script = rawurldecode(substr($argument, strlen('IsisScript='))); + if (preg_match('#^[A-Za-z0-9_./-]{1,160}$#D', $script)) { + return $script; + } + } + + return 'unknown'; +} diff --git a/tests/security_regression.php b/tests/security_regression.php index aef14d2dc..f4df57649 100644 --- a/tests/security_regression.php +++ b/tests/security_regression.php @@ -9,6 +9,7 @@ function assert_true($condition, $message) } require_once(__DIR__ . '/../htdocs/security.php'); +require_once(__DIR__ . '/../htdocs/wxis_runner.php'); require_once(__DIR__ . '/../htdocs/applications/scielo-org/classes/domit-1/xml_domit_lite_include.inc.php'); $domitDocument = new DOMIT_Lite_Document(); @@ -100,6 +101,26 @@ function assert_true($condition, $message) $config = array('SCIELO' => array('SERVER_SCIELO' => '127.0.0.1:8080')); assert_true(scielo_internal_host_from_config($config) === '127.0.0.1:8080', 'configured internal host should be returned'); assert_true(scielo_audit_sanitize('secret-value', 'password') === '***', 'sensitive audit fields must be redacted'); +assert_true(scielo_wxis_timeout_seconds() === 20, 'WXIS timeout must default to 20 seconds'); +assert_true( + scielo_wxis_script_name(array('IsisScript=ScieloXML/sci_references.xis', 'pid=private')) + === 'ScieloXML/sci_references.xis', + 'WXIS audit metadata must retain only the script name' +); + +$quickProcess = scielo_run_process_with_timeout( + array(PHP_BINARY, '-r', 'fwrite(STDOUT, "ready");'), + 2 +); +assert_true($quickProcess['timed_out'] === false, 'quick subprocess must complete before its deadline'); +assert_true($quickProcess['stdout'] === 'ready', 'subprocess stdout must be captured'); + +$slowProcess = scielo_run_process_with_timeout( + array(PHP_BINARY, '-r', 'usleep(1500000);'), + 0.1 +); +assert_true($slowProcess['timed_out'] === true, 'slow subprocess must be terminated at its deadline'); +assert_true($slowProcess['duration_ms'] < 1000, 'timed out subprocess must be reaped promptly'); $csrfToken = scielo_csrf_token(); assert_true(strlen($csrfToken) === 64, 'CSRF token should contain 256 bits encoded as hexadecimal'); @@ -127,6 +148,8 @@ function assert_true($condition, $message) $accessServiceSource = file_get_contents(__DIR__ . '/../htdocs/applications/scielo-org/classes/services/AccessServiceBar.php'); $graphSource = file_get_contents(__DIR__ . '/../htdocs/applications/scielo-org/classes/Open_Flash_Chart/ofc-library/Graph.php'); $apacheSource = file_get_contents(__DIR__ . '/../docker/rocky9/httpd-scielo.conf'); +$scieloSource = file_get_contents(__DIR__ . '/../htdocs/scielo.php'); +$fpmSource = file_get_contents(__DIR__ . '/../docker/rocky9/php-fpm-scielo.conf'); $domitUtilitiesSource = file_get_contents(__DIR__ . '/../htdocs/applications/scielo-org/classes/domit-1/xml_domit_utilities.php'); assert_true(strpos($old2newSource, '$GLOBALS[$key]') === false, 'old2new must not export arbitrary request keys'); @@ -147,6 +170,10 @@ function assert_true($condition, $message) assert_true(strpos($graphSource, 'function __construct()') !== false, 'legacy graph class must initialize on PHP 8.5'); assert_true(strpos($apacheSource, '.*\\.(?:inc|def|template)') !== false, 'Apache must block auxiliary file extensions'); assert_true(substr_count($apacheSource, 'Require all denied') >= 3, 'Apache must deny unsafe files and CGI by default'); +assert_true(strpos($apacheSource, 'ProxyTimeout 35') !== false, 'Apache must bound FastCGI request time'); +assert_true(strpos($scieloSource, 'shell_exec(') === false, 'WXIS must not run through an unbounded shell'); +assert_true(strpos($scieloSource, "\$useCache = '0';") !== false, 'authenticated requests must bypass shared cache'); +assert_true(strpos($fpmSource, 'request_terminate_timeout = 30s') !== false, 'PHP-FPM must terminate stuck requests'); assert_true(strpos($domitUtilitiesSource, 'public static function validateXML') !== false, 'DOMIT utility methods must support static calls on PHP 8.5'); fwrite(STDOUT, "Security regression tests passed" . PHP_EOL);