mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-24 03:57:32 +09:00
* feat(rendering): add view renderer abstraction Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * isolate renderer traversal test file * Standardize renderer headers and endpoint guard * style(renderer): use standard unit-test header in CactiRendererTest Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(renderer): reject null-byte paths before realpath Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * ci: refresh sink and hotspot baselines for line shifts Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(renderer): guard root base path and template-dir redirect Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(renderer): canonicalize both paths in index.php direct-access guard Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * hardening: normalize renderer base path to one separator Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(renderer): reject filesystem root as template path A /-rooted renderer collapsed the containment prefix to /, which str_starts_with() matches for every absolute path, so any file was includable. Fail closed by refusing a root template path. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * hardening: reject windows drive-root template path Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * refactor: adopt PHP 8.4 idioms in view renderer Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: cover renderer abstraction to full coverage Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * hardening(renderer): reject UNC template names and fix sink baselines Refuse a leading-backslash (UNC) template name the same way absolute POSIX paths are refused, skip the direct-request redirect when SCRIPT_FILENAME carries a null byte, and correct the renderer include and views redirect line numbers in the security baselines. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * style: apply php-cs-fixer Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(renderer): use PHP 8.1-compatible readonly property in CactiRenderer Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * hardening(rendering): drop template context array before include Unset the internal context array after extract so an included template only sees the extracted variables, not the full context. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * ci: refresh security baselines for renderer line shift Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(renderer): simplify containment check and drain nested output buffers The redundant real_path !== template_path comparison in validateTemplateFile() can never be false once is_file()/is_dir() have run, so drop it and keep the str_starts_with() prefix check as the actual containment guard. renderFile() also only unwound a single output buffer, so a template that opens (and leaves) its own buffer could leak an ob level or drop its content; unwind every buffer opened during rendering and reassemble them in the order they were written. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * ci: refresh security baselines for renderer buffer/containment fix The dynamic_include line for lib/renderer.php's include shifted again after adding drainOutputBuffers(). Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * feat(htmx): vendor htmx 2.0.6 and add opt-in filter loader Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: cover htmx filter loader Add missing-vendored-file case for htmx_script_tag and mark the pre-bootstrap read_config_option guard as uncoverable. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: harden htmx loader harness and tighten enable check Skip anything but an exact 'on' for htmx_enabled, reject 4xx/5xx fixture responses, fail fast when the fixture base path is unresolved, pin setup-php to a commit SHA, and reindent the e2e specs to tabs. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * style: apply php-cs-fixer to htmx test files Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test(htmx): drop duplicate script-tag src assertion The subdir-install test repeated the preceding assertion; CACTI_PATH_URL is fixed at bootstrap so it could not exercise a distinct scenario. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(e2e): drop sqlite3 from docker-php-ext-install php:8.2-apache already ships sqlite3 built in, and its ext/sqlite3 source only carries config0.m4 (not config.m4), so building it standalone always fails with "Cannot find config.m4", breaking the e2e image build before Playwright ever runs. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test(htmx): clear stat cache around version-file rename Earlier tests already stat the version file via file_exists()/ file_get_contents(), so PHP's stat cache can report it present right after these tests rename it away, making the assertions flaky. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(htmx): recompute script-tag cache-buster per call The md5 cache-buster was cached in a static, so FPM/script_server processes kept serving a stale ?v=... hash after an htmx.min.js upgrade. Recompute it per call, matching get_md5_include_js() and get_theme_paths() in lib/functions.php, and drop the 'v=' parameter name to match their path + '?' + hash shape. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test(e2e): drop obsolete MariaDB options * ci(htmx): run the htmx integration test by path instead of a testsuite filter Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * build: vendor htmx unminified Debian rejects minified sources. The file is the htmx 2.0.6 dist source; its minified sibling hashes identically to what was committed, and the pinned SRI is recomputed over the unminified file. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix: address htmx review feedback * test(e2e): resolve Cacti version through PHP * test(htmx): harden portable server cleanup * ci(actions): update checkout action pins --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: TheWitness <thewitness@cacti.net>
259 lines
7.3 KiB
PHP
259 lines
7.3 KiB
PHP
<?php
|
|
/*
|
|
+-------------------------------------------------------------------------+
|
|
| Copyright (C) 2004-2026 The Cacti Group |
|
|
| |
|
|
| This program is free software; you can redistribute it and/or |
|
|
| modify it under the terms of the GNU General Public License |
|
|
| as published by the Free Software Foundation; either version 2 |
|
|
| of the License, or (at your option) any later version. |
|
|
| |
|
|
| This program is distributed in the hope that it will be useful, |
|
|
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
|
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
|
| GNU General Public License for more details. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
| This code is designed, written, and maintained by the Cacti Group. See |
|
|
| about.php and/or the AUTHORS file for specific developer information. |
|
|
+-------------------------------------------------------------------------+
|
|
| http://www.cacti.net/ |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/*
|
|
* Integration tests for the htmx loader. Each test spins up a `php -S`
|
|
* instance that serves tests/integration/fixtures/htmx_fixture.php with the
|
|
* htmx_enabled toggle driven via environment variable, then asserts on the
|
|
* actual HTTP response. The server is torn down in finally{} so a failing
|
|
* assertion never leaks a bound port.
|
|
*/
|
|
|
|
/**
|
|
* Starts a PHP built-in server on a free port and returns [proc, port].
|
|
*
|
|
* @param array<string,string> $env
|
|
* @return array{0: resource, 1: int}
|
|
*/
|
|
function htmx_loader_start_server(array $env = []): array {
|
|
$port = htmx_loader_pick_port();
|
|
$fixture = dirname(__DIR__) . '/integration/fixtures/htmx_fixture.php';
|
|
|
|
// stdout/stderr go to the platform null sink: the server logs every request,
|
|
// and an unread pipe buffer would eventually fill and block it mid-test.
|
|
$null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null';
|
|
$descriptors = [
|
|
0 => ['file', $null, 'r'],
|
|
1 => ['file', $null, 'w'],
|
|
2 => ['file', $null, 'w'],
|
|
];
|
|
|
|
$env = array_merge($_ENV ?: [], $env);
|
|
|
|
$cmd = sprintf(
|
|
'exec %s -S 127.0.0.1:%d %s',
|
|
escapeshellarg(PHP_BINARY),
|
|
$port,
|
|
escapeshellarg($fixture)
|
|
);
|
|
|
|
$proc = proc_open($cmd, $descriptors, $pipes, dirname(__DIR__, 2), $env);
|
|
|
|
if (!is_resource($proc)) {
|
|
throw new RuntimeException('failed to start php -S');
|
|
}
|
|
|
|
// wait for the listener to bind before issuing the first curl
|
|
set_error_handler(static function (): bool { return true; });
|
|
|
|
try {
|
|
for ($i = 0; $i < 50; $i++) {
|
|
$probe = fsockopen('127.0.0.1', $port, $errno, $errstr, 0.2);
|
|
|
|
if ($probe) {
|
|
fclose($probe);
|
|
|
|
return [$proc, $port];
|
|
}
|
|
|
|
usleep(50_000);
|
|
}
|
|
} finally {
|
|
restore_error_handler();
|
|
}
|
|
|
|
htmx_loader_stop_server($proc);
|
|
|
|
throw new RuntimeException('php -S never accepted connections on port ' . $port);
|
|
}
|
|
|
|
function htmx_loader_pick_port(): int {
|
|
$sock = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
|
|
|
if ($sock === false) {
|
|
throw new RuntimeException('cannot allocate a local port: ' . $errstr);
|
|
}
|
|
|
|
$name = stream_socket_get_name($sock, false);
|
|
|
|
fclose($sock);
|
|
|
|
return (int) substr($name, strrpos($name, ':') + 1);
|
|
}
|
|
|
|
function htmx_loader_stop_server($proc): void {
|
|
if (is_resource($proc)) {
|
|
proc_terminate($proc);
|
|
|
|
$deadline = microtime(true) + 1.0;
|
|
|
|
do {
|
|
$status = proc_get_status($proc);
|
|
|
|
if (!$status['running']) {
|
|
proc_close($proc);
|
|
|
|
return;
|
|
}
|
|
|
|
usleep(20_000);
|
|
} while (microtime(true) < $deadline);
|
|
|
|
proc_terminate($proc, defined('SIGKILL') ? SIGKILL : 9);
|
|
|
|
$deadline = microtime(true) + 1.0;
|
|
do {
|
|
$status = proc_get_status($proc);
|
|
if (!$status['running']) {
|
|
proc_close($proc);
|
|
|
|
return;
|
|
}
|
|
usleep(20_000);
|
|
} while (microtime(true) < $deadline);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,string> $headers
|
|
*/
|
|
function htmx_loader_http_get(int $port, array $headers = []): string {
|
|
$ch = curl_init('http://127.0.0.1:' . $port . '/');
|
|
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 5,
|
|
CURLOPT_HTTPHEADER => array_map(
|
|
static fn ($k, $v) => "$k: $v",
|
|
array_keys($headers),
|
|
array_values($headers)
|
|
),
|
|
]);
|
|
|
|
$body = curl_exec($ch);
|
|
$err = curl_error($ch);
|
|
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
|
|
curl_close($ch);
|
|
|
|
if ($body === false) {
|
|
throw new RuntimeException('curl failed: ' . $err);
|
|
}
|
|
|
|
// A 4xx/5xx error page can still contain the substrings the tests look for,
|
|
// so reject it here rather than assert against an error body.
|
|
if ($status >= 400) {
|
|
throw new RuntimeException('fixture returned HTTP ' . $status);
|
|
}
|
|
|
|
return (string) $body;
|
|
}
|
|
|
|
// The HTTP probe relies on ext-curl; without it curl_init() is a fatal, not a
|
|
// catchable error, so skip the suite rather than let it crash on lean runners.
|
|
beforeEach(function () {
|
|
if (!function_exists('curl_init')) {
|
|
test()->markTestSkipped('ext-curl not available');
|
|
}
|
|
});
|
|
|
|
test('GET / emits an htmx script tag when enabled', function () {
|
|
[$proc, $port] = htmx_loader_start_server(['FIXTURE_HTMX_ENABLED' => 'on']);
|
|
|
|
try {
|
|
$body = htmx_loader_http_get($port);
|
|
|
|
expect($body)->toContain('<script')
|
|
->and($body)->toMatch('/src=[\'"][^\'"]*htmx\.js/');
|
|
} finally {
|
|
htmx_loader_stop_server($proc);
|
|
}
|
|
});
|
|
|
|
test('script tag carries a sha384 integrity attribute', function () {
|
|
[$proc, $port] = htmx_loader_start_server(['FIXTURE_HTMX_ENABLED' => 'on']);
|
|
|
|
try {
|
|
$body = htmx_loader_http_get($port);
|
|
|
|
expect($body)->toMatch('/integrity=[\'"]sha384-[A-Za-z0-9+\/=]+/');
|
|
} finally {
|
|
htmx_loader_stop_server($proc);
|
|
}
|
|
});
|
|
|
|
test('script tag declares crossorigin="anonymous"', function () {
|
|
[$proc, $port] = htmx_loader_start_server(['FIXTURE_HTMX_ENABLED' => 'on']);
|
|
|
|
try {
|
|
$body = htmx_loader_http_get($port);
|
|
|
|
expect($body)->toMatch('/crossorigin=[\'"]anonymous/');
|
|
} finally {
|
|
htmx_loader_stop_server($proc);
|
|
}
|
|
});
|
|
|
|
test('no htmx reference in body when the setting is off', function () {
|
|
[$proc, $port] = htmx_loader_start_server(['FIXTURE_HTMX_ENABLED' => 'off']);
|
|
|
|
try {
|
|
$body = htmx_loader_http_get($port);
|
|
|
|
expect($body)->not->toContain('htmx.js');
|
|
} finally {
|
|
htmx_loader_stop_server($proc);
|
|
}
|
|
});
|
|
|
|
test('htmx_is_fragment_request returns true when HX-Request header is sent', function () {
|
|
[$proc, $port] = htmx_loader_start_server([
|
|
'FIXTURE_HTMX_ENABLED' => 'on',
|
|
'FIXTURE_EMIT_FRAGMENT_FLAG' => '1',
|
|
]);
|
|
|
|
try {
|
|
$body = htmx_loader_http_get($port, ['HX-Request' => 'true']);
|
|
|
|
expect($body)->toBe('FRAGMENT=true');
|
|
} finally {
|
|
htmx_loader_stop_server($proc);
|
|
}
|
|
});
|
|
|
|
test('htmx_is_fragment_request returns false without the header', function () {
|
|
[$proc, $port] = htmx_loader_start_server([
|
|
'FIXTURE_HTMX_ENABLED' => 'on',
|
|
'FIXTURE_EMIT_FRAGMENT_FLAG' => '1',
|
|
]);
|
|
|
|
try {
|
|
$body = htmx_loader_http_get($port);
|
|
|
|
expect($body)->toBe('FRAGMENT=false');
|
|
} finally {
|
|
htmx_loader_stop_server($proc);
|
|
}
|
|
});
|