Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions docker/rocky9/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"]
10 changes: 10 additions & 0 deletions docker/rocky9/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docker/rocky9/httpd-scielo.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,10 @@ Alias /pdf/ "/var/www/html/bases/pdf/"
Require all denied
</FilesMatch>

<Files "health.php">
Require local
</Files>

<FilesMatch "^(?i:(readme(?:\..*)?|version(?:overview)?(?:\..*)?|.*\.(?:inc|def|template)(?:\.php)?))$">
Require all denied
</FilesMatch>
Expand Down
12 changes: 12 additions & 0 deletions docker/rocky9/php-fpm-scielo.conf
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions htdocs/health.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

require_once(__DIR__ . '/security.php');

header('Content-Type: application/json; charset=UTF-8');
header('Cache-Control: no-store');

$healthy = is_executable(__DIR__ . '/../cgi-bin/wxis.exe')
&& is_readable(__DIR__ . '/../bases');

if (!$healthy) {
http_response_code(503);
scielo_audit_event(
'system.healthcheck',
'failure',
'service.scielo_web',
null,
array('reason' => 'runtime_dependency_unavailable')
);
}

echo json_encode(array('status' => $healthy ? 'ok' : 'unavailable'));
58 changes: 51 additions & 7 deletions htdocs/scielo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -22,7 +23,6 @@
$filenamePage = $scielo->GetPageFile();
}


$filenamePage = "";
$pageContent = "";
$GRAVA = false;
Expand Down Expand Up @@ -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';
}
}

Expand Down Expand Up @@ -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 '';
}

Expand Down
126 changes: 126 additions & 0 deletions htdocs/wxis_runner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

/**
* Runs WXIS without invoking a shell and enforces a hard execution deadline.
*
* Keeping the command as an argument array avoids shell interpretation. The
* caller receives stdout even when WXIS exits unsuccessfully so the legacy
* error handling can continue to inspect its response.
*/
function scielo_run_process_with_timeout($command, $timeoutSeconds)
{
if (!is_array($command) || count($command) === 0) {
throw new InvalidArgumentException('A non-empty command array is required');
}

$timeoutSeconds = (float) $timeoutSeconds;
if ($timeoutSeconds <= 0) {
throw new InvalidArgumentException('The process timeout must be positive');
}

$descriptors = array(
1 => 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';
}
27 changes: 27 additions & 0 deletions tests/security_regression.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Loading