diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 7cb2f88..dcad03c 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -139,7 +139,8 @@ Document all changes in `CHANGELOG.md`; use descriptive commit messages referenc
## Internationalization (i18n)
- Translatable strings are managed with GNU gettext via `locales/build_gettext.sh`. `locales/po/cacti.pot` is the source template; Weblate owns syncing the per-language `.po`/`.mo` files from it.
-- When a pull request adds or changes a string wrapped in `__()`/`__n()`/`__esc()`/`__x()`/`__xn()`/`__gettext()`, run `locales/build_gettext.sh` before pushing and add the resulting change to `locales/po/cacti.pot` only. Do not commit the regenerated per-language `.po`/`.mo` files in the same PR — Weblate takes care of the rest.
+- **Never commit the per-language `.po` or compiled `.mo` files** (`locales/po/*.po`, `locales/LC_MESSAGES/*.mo`) in a plugin PR. Weblate is the sole owner of those catalogs, and regenerating them here produces spurious diffs and merge conflicts. `locales/po/cacti.pot` is the ONLY translation artifact a PR may add or modify.
+- When a pull request adds or changes a string wrapped in `__()`/`__n()`/`__esc()`/`__x()`/`__xn()`/`__gettext()`, run `locales/build_gettext.sh` before pushing and stage `locales/po/cacti.pot` only. `build_gettext.sh` also rewrites the `.po`/`.mo` files as a side effect; revert those before committing (`git checkout -- locales/po/*.po locales/LC_MESSAGES`), or run only the `xgettext` step that targets `cacti.pot`.
## References
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31adef1..9b326a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
--- develop ---
+* security: Add a version-safe CSP nonce (`plugin_quicktree_csp_nonce()`) to every inline `
+
tags, safely across
+ * Cacti versions. Newer Cacti releases enforce a Content-Security-Policy that
+ * requires a per-request nonce on parser-inserted scripts; older releases lack
+ * the CactiSecureHeaders class, so this returns an empty string there.
+ *
+ * @return string The nonce attribute when supported, otherwise empty string.
+ */
+function plugin_quicktree_csp_nonce(): string {
+ if (class_exists('CactiSecureHeaders')) {
+ return CactiSecureHeaders::getNonceAttribute();
+ }
+
+ return '';
+}
+
/**
* Installs the quicktree plugin: registers its Cacti hooks (top_header_tabs,
* top_graph_header_tabs, config_arrays, config_settings,
@@ -243,10 +259,10 @@ function quicktree_page_head(): void {
$page = get_current_page();
- print '';
+ print get_md5_include_js('plugins/quicktree/js/quicktree.js');
if (strstr($page, 'quicktree.php') !== false) {
- print '';
+ print get_md5_include_css('plugins/quicktree/css/quicktree.css');
}
}
diff --git a/tests/Unit/CspNonceTest.php b/tests/Unit/CspNonceTest.php
new file mode 100644
index 0000000..0ae7f36
--- /dev/null
+++ b/tests/Unit/CspNonceTest.php
@@ -0,0 +1,102 @@
+markTestSkipped('CactiSecureHeaders is present in this runtime; the delegation path is covered by the isolated-process test.');
+ }
+
+ expect(plugin_quicktree_csp_nonce())->toBe('');
+});
+
+test('csp nonce helper is declared to return a string', function () {
+ $ref = new ReflectionFunction('plugin_quicktree_csp_nonce');
+
+ expect((string) $ref->getReturnType())->toBe('string');
+});
+
+test('csp nonce delegates to CactiSecureHeaders when the class is available', function () {
+ $wrapper = realpath(__DIR__ . '/../../setup.php');
+ expect($wrapper)->not->toBeFalse();
+
+ // The wrapper's file may not be loadable in isolation (some plugins
+ // include translised arrays at file scope), so hand the child the unit
+ // bootstrap too; it defines the Cacti stubs the file needs at load time.
+ $bootstrap = realpath(__DIR__ . '/../bootstrap-unit.php');
+
+ // Run in a clean child process so the double never leaks into the rest
+ // of the suite and the delegation branch is genuinely invoked.
+ $script = <<<'CHILD'
+>>";
+CHILD;
+
+ $tmp = tempnam(sys_get_temp_dir(), 'csp_');
+ expect($tmp)->not->toBeFalse();
+
+ try {
+ file_put_contents($tmp, $script);
+
+ $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($tmp) . ' ' . escapeshellarg($wrapper) . ' ' . escapeshellarg($bootstrap === false ? '' : $bootstrap);
+ $output = shell_exec($command);
+
+ expect($output)->not->toBeNull();
+
+ preg_match('/<<<(.*)>>>/s', (string) $output, $matches);
+ expect($matches)->toHaveKey(1);
+ expect($matches[1])->toBe(' nonce="pest-controlled-double"');
+ } finally {
+ @unlink($tmp);
+ }
+});