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); + } +});