Files
Thomas VincentandTheWitness 97e67c6777 security: harden browser rendering and review CodeQL boundaries (#8298)
* security: harden browser history and dependency option merging

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs: record CodeQL triage decisions and follow-up scope

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: wait for history reload before inspecting browser storage

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* security: isolate HTMX history from the legacy navigation handler

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: build CodeQL assets through controlled dependency installs

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* security: render form validation messages as plain text

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: verify drag rows and color input security boundaries

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: verify tree navigation rename and datetime input boundaries

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* security: harden menu rendering and complete CodeQL caller review

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* security: centralize menu rendering and stabilize certificate CI

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: align certificate expectation with parallel web PR

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-10-11 21:26:38 -04:00

213 lines
9.9 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/ |
+-------------------------------------------------------------------------+
*/
require_once __DIR__ . '/headers_secure.php';
/**
* Returns true when htmx support is enabled for this install. Reads the
* htmx_enabled setting row. Default behavior is enabled on develop so the
* pilot surfaces; flip the setting to 'off' to disable site-wide.
*
* Guards on function_exists('read_config_option') so callers during early
* CLI bootstrap (before the settings table is reachable) get false rather
* than a fatal call.
*
* @return bool
*/
function cacti_htmx_enabled(): bool {
if (!function_exists('read_config_option')) {
// @codeCoverageIgnoreStart
// Reached only before include/global.php declares read_config_option
// (early CLI bootstrap). Every test loads global.php, which declares it
// process-wide, so this guard cannot be exercised under the test suite.
return false;
// @codeCoverageIgnoreEnd
}
$value = read_config_option('htmx_enabled');
// When the settings row is absent read_config_option() falls back to the
// default from include/global_settings.php ('on'), so '' only reaches us
// when the cached value is explicitly empty (or no default is registered,
// as in stripped-down test bootstraps). Treat that as disabled so
// half-initialised installs are safe.
// The setting is a drop_array with explicit 'on'/'off' values, so treat only
// an exact 'on' as enabled. Anything else (an empty cached value, a stray
// '0'/'1', or an explicit 'off') leaves htmx disabled.
return $value === 'on';
}
/**
* Returns true when the current request was issued by htmx. The HX-Request
* header signals that htmx initiated the fetch, so the page can return a
* fragment instead of a full HTML document.
*
* PHP normalises header names to HTTP_ + uppercase + underscores, so
* HX-Request arrives as HTTP_HX_REQUEST.
*
* @return bool
*/
function htmx_is_fragment_request(): bool {
return ($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true';
}
/**
* Returns the vendored htmx version string (reads include/js/htmx.js.version).
* Empty string when the version file is missing.
*
* @return string
*/
function htmx_version(): string {
$path = CACTI_PATH_BASE . '/include/js/htmx.js.version';
if (!file_exists($path)) {
return '';
}
return trim((string) file_get_contents($path));
}
/**
* Subresource Integrity hash pinned to the exact vendored htmx 2.0.11 build
* (include/js/htmx.js). This is a constant, not a value recomputed from the
* served file at runtime: a self-referential hash gives no supply-chain
* protection, since swapping htmx.js would simply re-hash to match it. With
* a fixed constant the browser rejects any vendored file that no longer matches.
*
* On an htmx upgrade, replace the vendored file and recompute this value:
* php -r 'echo "sha384-".base64_encode(hash_file("sha384","include/js/htmx.js",true));'
*/
const HTMX_2_0_11_SRI = 'sha384-gmJEF2eAKY4e+FDN+qtKIivWyb6ANwDB7JUdUybKgQspPKyEX/pIRZG/0uaRoW2C';
/**
* Returns a <script> tag that loads the vendored htmx.js with an SRI
* integrity attribute and crossorigin="anonymous". Returns empty string when
* htmx is disabled or the vendored file is absent.
*
* hx-boost is intentionally left off the global document. Boost intercepts
* every anchor and form, which conflicts with Cacti's existing jQuery-driven
* navigation and AJAX submission model. Pages that want htmx behaviour opt in
* per-element with explicit hx-* attributes.
*
* The integrity attribute uses the HTMX_2_0_11_SRI constant pinned to the
* vendored build (see that constant for why the hash is not recomputed here).
*
* The src is root-absolute (prefixed with CACTI_PATH_URL) so the asset
* resolves the same way on plugin pages served from subdirectories, matching
* get_md5_include_js() in lib/functions.php. A document-relative src 404s
* whenever the current page lives below the Cacti root. The cache-buster
* query string matches that helper's shape too (path + '?' + hash, no
* parameter name), and is recomputed on every call rather than cached in a
* static: get_md5_include_js()/get_theme_paths() do the same, since a static
* cache would leave long-lived PHP processes (FPM, script_server) serving a
* stale hash after include/js/htmx.js changes.
*
* Two wiring pieces precede the script element:
* - an htmx-config meta that disables allowEval/allowScriptTags. htmx 2.0.11
* defaults both to true, which Cacti's CSP (no unsafe-eval) forbids. The
* meta is read by htmx at load, so it must appear before the script.
* HTML history snapshots are disabled, and history misses reload from
* Cacti so authenticated content is not retained in browser storage.
* - history cache cleanup and a cache-hit guard prevent stale or externally
* reenabled snapshots from bypassing that policy. The guard cancels the
* HTML swap and reloads the current URL through the server.
* - an htmx:configRequest listener that adds the csrf-magic token to
* body-based (POST/PUT/PATCH) htmx requests. Cacti validates the
* __csrf_magic field on POSTs; layout.js injects it into $.post payloads,
* but htmx has no such hook, so the first hx-post would otherwise fail
* CSRF validation. GET and DELETE are excluded because htmx 2.0.11 encodes
* their parameters into the URL (methodsThatUseUrlParams), which would
* leak the token into query strings and server logs.
*
* @return string
*/
function htmx_script_tag(): string {
if (!cacti_htmx_enabled()) {
return '';
}
$js_path = CACTI_PATH_BASE . '/include/js/htmx.js';
if (!file_exists($js_path)) {
return '';
}
// md5 is a cache-buster for the asset URL only, recomputed on every call
// (not cached in a static) so an htmx.js upgrade is visible without a
// process restart. Integrity is the pinned constant, never derived from
// the served file (see HTMX_2_0_11_SRI).
$md5 = md5_file($js_path);
// CACTI_PATH_URL is defined by include/global_path.php during a normal
// bootstrap. Fall back to '/' when the constant is absent (lightweight
// fixtures that load lib/htmx.php without the full path bootstrap).
$base = defined('CACTI_PATH_URL') ? CACTI_PATH_URL : '/';
$url = $base . 'include/js/htmx.js?' . $md5;
$nonce_attr = CactiSecureHeaders::getNonceAttribute();
$config_meta = "<meta name='htmx-config' content='"
. htmlspecialchars('{"allowEval":false,"allowScriptTags":false,"selfRequestsOnly":true,"historyCacheSize":0,"refreshOnHistoryMiss":true}', ENT_QUOTES, 'UTF-8')
. "'>\n";
// htmx:configRequest fires before each request and bubbles to document;
// listen on document so this works from the <head> where document.body is
// still null. Mirror layout.js by adding csrfMagicToken under the
// __csrf_magic field that csrf-magic validates. Only body-based verbs get
// the token: htmx puts GET and DELETE parameters into the URL, which would
// leak it (see the function doc).
$csrf_wiring = "<script type='text/javascript' $nonce_attr>\n"
// Authenticated DOM snapshots must not persist across logout or be
// restored from browser storage. Back navigation reloads from Cacti.
. "try { window.sessionStorage.removeItem('htmx-history-cache'); } catch (error) {}\n"
// Cacti's legacy navigator assigns window.onpopstate after HTMX loads.
// Capture only HTMX-owned entries before either navigator can restore
// cached HTML; ordinary Cacti history still reaches its own handler.
. "window.addEventListener('popstate', function(evt) {\n"
. "\tif (evt.state && evt.state.htmx === true) {\n"
. "\t\tevt.stopImmediatePropagation();\n"
. "\t\twindow.location.reload();\n"
. "\t}\n"
. "}, true);\n"
. "document.addEventListener('htmx:historyCacheHit', function(evt) {\n"
. "\tevt.preventDefault();\n"
. "\twindow.location.reload();\n"
. "});\n"
. "document.addEventListener('htmx:configRequest', function(evt) {\n"
. "\tvar verb = String(evt.detail.verb).toLowerCase();\n"
. "\tif (typeof csrfMagicToken !== 'undefined' && (verb === 'post' || verb === 'put' || verb === 'patch')) {\n"
. "\t\tevt.detail.parameters['__csrf_magic'] = csrfMagicToken;\n"
. "\t}\n"
. "});\n"
. "</script>\n";
$tag = "<script type='text/javascript' $nonce_attr src='" . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . "'"
. " integrity='" . HTMX_2_0_11_SRI . "'"
. " crossorigin='anonymous'></script>\n";
// Both wiring pieces precede the script element (see the function doc):
// the config meta must exist before htmx reads it at load, and registering
// the listener first means no configRequest can ever fire without it.
return $config_meta . $csrf_wiring . $tag;
}