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);