mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +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>
174 lines
6.7 KiB
PHP
174 lines
6.7 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/ |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
final class CactiRenderer {
|
|
private readonly string $template_path;
|
|
|
|
public function __construct(string $template_path) {
|
|
// realpath() raises a ValueError on a null byte, so reject it up front
|
|
// and fail through the same "does not exist" path.
|
|
if (str_contains($template_path, "\0")) {
|
|
throw new InvalidArgumentException('Renderer template path does not exist');
|
|
}
|
|
|
|
$real_path = realpath($template_path);
|
|
|
|
if ($real_path === false || !is_dir($real_path)) {
|
|
throw new InvalidArgumentException('Renderer template path does not exist');
|
|
}
|
|
|
|
// A renderer rooted at a filesystem root makes the containment prefix
|
|
// collapse to that root, which str_starts_with() then matches for every
|
|
// absolute path. Trim both separators so the test holds regardless of the
|
|
// OS running it, and refuse a POSIX root ("/" -> "") or a Windows drive
|
|
// root ("C:\" -> "C:") so the check stays fail-closed on either platform.
|
|
$trimmed = rtrim($real_path, '/\\');
|
|
|
|
if ($trimmed === '' || preg_match('/^[A-Za-z]:$/', $trimmed) === 1) {
|
|
throw new InvalidArgumentException('Renderer template path does not exist');
|
|
}
|
|
|
|
$this->template_path = $trimmed;
|
|
}
|
|
|
|
public function render(string $template, array $context = []) : string {
|
|
return $this->renderFile($this->resolveTemplate($template), $context);
|
|
}
|
|
|
|
public function renderFile(string $template_file, array $context = []) : string {
|
|
$template_file = $this->validateTemplateFile($template_file);
|
|
|
|
$start_level = ob_get_level();
|
|
|
|
ob_start();
|
|
|
|
try {
|
|
$this->includeTemplate($template_file, $context);
|
|
|
|
return $this->drainOutputBuffers($start_level);
|
|
} catch (Throwable $e) {
|
|
$this->drainOutputBuffers($start_level);
|
|
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
// A template should close any buffer it opens, but a stray ob_start() must
|
|
// not leak a level or scramble output order. Pop innermost-first (the only
|
|
// order ob_get_clean() allows), then replay outermost-first so the result
|
|
// matches the order the template actually wrote it in.
|
|
private function drainOutputBuffers(int $start_level) : string {
|
|
$chunks = [];
|
|
|
|
while (ob_get_level() > $start_level) {
|
|
$chunks[] = (string) ob_get_clean();
|
|
}
|
|
|
|
return implode('', array_reverse($chunks));
|
|
}
|
|
|
|
private function resolveTemplate(string $template) : string {
|
|
if ($template === '' || str_contains($template, "\0")) {
|
|
throw new InvalidArgumentException('Renderer template name is invalid');
|
|
}
|
|
|
|
if (str_starts_with($template, '/') || str_starts_with($template, '\\') || preg_match('/^[A-Za-z]:[\/\\\\]/', $template) === 1) {
|
|
throw new InvalidArgumentException('Renderer template name must be relative');
|
|
}
|
|
|
|
return $this->validateTemplateFile($this->template_path . DIRECTORY_SEPARATOR . $template);
|
|
}
|
|
|
|
private function validateTemplateFile(string $template_file) : string {
|
|
// renderFile() reaches here with a caller-supplied path that never passed
|
|
// through resolveTemplate()'s null-byte check, so guard realpath() here too.
|
|
if (str_contains($template_file, "\0")) {
|
|
throw new InvalidArgumentException('Renderer template file does not exist');
|
|
}
|
|
|
|
$real_path = realpath($template_file);
|
|
|
|
if ($real_path === false || !is_file($real_path)) {
|
|
throw new InvalidArgumentException('Renderer template file does not exist');
|
|
}
|
|
|
|
// Anchor containment with a trailing separator so a sibling directory
|
|
// sharing the prefix (".../tmpl-evil" against ".../tmpl") cannot match.
|
|
// str_starts_with() is case-sensitive, which on a case-insensitive
|
|
// Windows volume only ever over-rejects (fail-closed), so leave it.
|
|
//
|
|
// (No separate $real_path !== $this->template_path check: the is_file()
|
|
// check above and the is_dir() check in the constructor already make
|
|
// that comparison unreachable, since the same real path can't be both.)
|
|
$base_path = rtrim($this->template_path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
|
|
|
if (!str_starts_with($real_path, $base_path)) {
|
|
throw new InvalidArgumentException('Renderer template file is outside the template path');
|
|
}
|
|
|
|
return $real_path;
|
|
}
|
|
|
|
private function includeTemplate(string $template_file, array $context) : void {
|
|
(static function (string $__cacti_template_file, array $__cacti_template_context) : void {
|
|
extract($__cacti_template_context, EXTR_SKIP);
|
|
unset($__cacti_template_context);
|
|
|
|
include $__cacti_template_file;
|
|
})($template_file, $context);
|
|
}
|
|
}
|
|
|
|
function cacti_renderer(?string $template_path = null) : CactiRenderer {
|
|
static $renderers = [];
|
|
|
|
if ($template_path === null) {
|
|
$template_path = CACTI_PATH_INCLUDE . '/views';
|
|
}
|
|
|
|
if (str_contains($template_path, "\0")) {
|
|
throw new InvalidArgumentException('Renderer template path does not exist');
|
|
}
|
|
|
|
$key = realpath($template_path);
|
|
|
|
if ($key === false) {
|
|
throw new InvalidArgumentException('Renderer template path does not exist');
|
|
}
|
|
|
|
if (!isset($renderers[$key])) {
|
|
$renderers[$key] = new CactiRenderer($key);
|
|
}
|
|
|
|
return $renderers[$key];
|
|
}
|
|
|
|
function cacti_render(string $template, array $context = [], ?string $template_path = null) : string {
|
|
return cacti_renderer($template_path)->render($template, $context);
|
|
}
|
|
|
|
function cacti_render_file(string $template_file, array $context = [], ?string $template_path = null) : string {
|
|
return cacti_renderer($template_path)->renderFile($template_file, $context);
|
|
}
|