Skip to content

feat(security): system-wide refactor of execution sinks to array contracts#7172

Closed
somethingwithproof wants to merge 46 commits into
Cacti:1.2.xfrom
somethingwithproof:security/1.2.x-csp-nonce-migration
Closed

feat(security): system-wide refactor of execution sinks to array contracts#7172
somethingwithproof wants to merge 46 commits into
Cacti:1.2.xfrom
somethingwithproof:security/1.2.x-csp-nonce-migration

Conversation

@somethingwithproof

Copy link
Copy Markdown
Contributor

This PR implements architectural hardening for Cacti 1.2 by enforcing array-based execution contracts system-wide.

…eta CSP

Route every security header through lib/headers_secure.php so the CSP
has one authoritative source. Add object-src, base-uri, form-action,
and manifest-src directives. Delete the weaker <meta> CSP that allowed
default-src *. Ship .htaccess.dist with static-file header guidance for
Apache deployments.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Extend content_security_policy_script with 'nonce' and 'nonce-report'
values. Nonce mode replaces 'unsafe-inline' in script-src and style-src
with a per-request base64url nonce; report-only mode ships via
Content-Security-Policy-Report-Only for staging. CactiSecureHeaders
gains getCspMode, isNonceMode, and buildCspPolicy as the plugin-facing
contract. Pilot-migrated logout.php and permission_denied.php. Added
csp_report.php endpoint + Pest unit tests.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Previous commit created the new files; this one lands the matching
edits to headers_secure.php (getCspMode/isNonceMode/buildCspPolicy,
base64url nonce, mode branching in emitHeaders), global_settings.php
(four-value drop_array with description warning), logout.php and
permission_denied.php (nonce attribute on inline scripts), and docs/
security-headers.md (nonce mode, plugin contract, rollback).

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Integration tests spin up php -S with a stub read_config_option and
assert on response headers for every flag value plus header/DOM nonce
match. Playwright suite scaffolded under tests/e2e/ with a 6-test CSP
spec, skipped by default until the docker-compose stack lands. CI
workflow runs unit and integration legs on push/PR touching the CSP
surface; e2e job stubbed until the stack ships.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Add report-uri directive to nonce-mode CSP (reports had nowhere to
go). Replace html_escape of the alternate-sources config with a CSP
source-list whitelist scrubber that drops ';', CR, LF, and other
directive-terminating bytes. Strip C0 controls from CSP-report
summary fields before cacti_log so a crafted violated-directive
cannot forge log lines. Bump nonce entropy from 128 to 144 bits
(24 base64url chars) and mark enforce-mode as [Pilot] in the UI.

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

Strip em dashes per repo prose rules. Register the
content_security_report_uri setting in global_settings.php so the
value read by emitHeaders is reachable from the UI. Delete the
no-op _reset_nonce helper in HeadersSecureTest.php. Update nonce
length in docs from 22 to 24 to match random_bytes(18).

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
MariaDB 10.11 seeds cacti.sql at boot; PHP-FPM 7.4 container mounts
the repo and runs an entrypoint that fixes up the version sentinel,
creates include/config.php, and clears the default admin's
must_change_password flags so Playwright can sign in. nginx fronts
8080. Workflow e2e job flips from a stub to real docker compose run
with artifact upload on failure.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Live smoke: 4 runnable Playwright tests pass (2 report-only fixme's).
Dockerfile gains oniguruma-dev for mbstring build; compose publishes
via HOST_PORT env so local ports like 8090 work around OrbStack's 8080
pre-bind; default CACTI_CSP_MODE flips to nonce-report (matches the
realistic staged rollout, un-migrated tags keep working); entrypoint
reads the 1.2.x-style include/cacti_version file, sets url_path '/'
so nginx serves assets without the /cacti prefix, patches in docker
network creds. nginx.conf stops blocking include/js + include/themes
and pulls in mime.types so JS/CSS serve with real content types.
Playwright spec asserts on report-only header shape + nonce match
against logout.php?action=timeout (the always-renderable pilot body).

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

- CACTI_FORCE_CONFIG env overwrites include/config.php each boot so
  CI and dev re-runs start with creds matching the compose stack.
- docker-compose.enforce.yml overlay flips the stack to nonce
  enforcing mode. Pair with E2E_CSP_ENFORCE=1 to run the full suite
  against the blocking path.
- loginAsAdmin fixture in the spec uses the form's CSRF-bearing
  hidden inputs, unlocking the permission_denied.php nonce-match
  test. All 6 tests run in both modes; no more fixme skips.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Cacti's composer.json does not declare Pest as a require-dev today,
so composer install in a clean CI environment does not provide the
binary. Add an explicit composer require --dev step. tests/e2e does
not ship package-lock.json (gitignored to keep the PR diff small)
so switch from npm ci to npm install and drop the setup-node
cache-dependency-path that pointed at the missing lock.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Composer 2.2+ blocks pestphp/pest-plugin by default; whitelist it
scoped to the CI job before composer require. E2E wait loop now
requires the Content-Security-Policy-Report-Only header to be
present before declaring readiness, since a bare 200 can come from
the install wizard bootstrap that skips global.php. Added a
diagnostic step dumping php/mariadb logs and the header line so
future CI failures surface the entrypoint state without re-running.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Cacti's main composer.json sets vendor-dir to include/vendor/, which
confounds Pest 1.x's composer.json discovery walk. Scoping Pest to a
tests/ composer.json with its own vendor/ directory gives Pest a path
layout it understands without touching the main tree. The test files
already live under tests/, so paths stay relative.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Pest needs a phpunit.xml and a Pest.php bootstrap inside the
test-runner root. Ship both in tests/. E2E diagnostic step always
runs now (not just on failure) and dumps settings rows, url_path
line, response headers, and the first 400 bytes of the body so
remaining failures surface the real state.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
CI checks out the repo as the runner uid, but php-fpm inside the
container runs as www-data. The bind mount preserves host uids,
so Cacti's 'System log file is not available for writing' died
before emitHeaders() ran, producing a 123-byte bodied 200 with no
security headers at all. chmod a+w on log/cache/rra/resource
restores write access.

Pest's toHaveKey does not take an error-message second argument;
using one makes Pest interpret it as the expected value. Drop
the message and keep the bare key assertion.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Line-number drift only after CactiSecureHeaders::emitHeaders()
integration in include/global.php. No new sinks.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Same line-number drift as the sink baseline regen. No new hotspots.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Wrap CSPRNG calls in try/catch with openssl and non-CSPRNG fallbacks
plus logging so an entropy failure cannot take down the web UI when
nonce mode is enabled.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
content_security_alternate_sources controls source lists, not violation
reporting. Drop the unbacked SRI claim about get_md5_include_js.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Drop stale unsafe-eval-only label, the dead html_escape fixture stub
that emitHeaders no longer calls, and the tranche-C playwright note
that no longer matches reality.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The Pest sandbox lives at tests/composer.json. The root composer install
needed extensions setup-php does not enable and could fail before tests
run.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Hardcoded /cacti/csp_report.php broke CSP reporting on installs at /,
/cacti2, or behind a rewrite. Now derived from $url_path with fallback
to the legacy literal when the global is unset.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Loading lib/functions.php alone produced undefined-$config warnings
because it expects a populated $config. This endpoint is unauthenticated
and reachable before include/global.php is loaded. Replaced with a
self-contained csp_report_log() that prefers cacti_log() when $config
is already populated and falls through to error_log() otherwise.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Spec asserted the hardcoded /cacti/csp_report.php URI; the default URI
now derives from $url_path. Use a regex that matches the shim filename
without pinning the base path.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Without strict-dynamic, jQuery .html()/.append()-injected scripts fail
under nonce mode because they do not carry the per-request nonce. Without
unsafe-eval, jQuery's globalEval and new Function() paths fail. Both
break Cacti core and most plugins (thold, monitor) under enforce.
Style-src keeps unsafe-inline since style XSS is a much narrower attack
surface and jQuery .css() relies on inline-style execution.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Settings UI description and the security-headers doc both said inline
<style> tags require a nonce; in fact only script-src enforces nonces
in nonce mode and style-src keeps unsafe-inline. Also rewords the
report-uri default from a hardcoded /cacti/csp_report.php to the
<url_path>-derived form so installs at /, /cacti2, etc are described
correctly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Operator-facing recovery path for nonce-enforcing mode breaking the UI
badly enough that the Settings page itself becomes unreachable. A single
UPDATE settings restores the default unsafe-inline posture without a
restart or cache flush. Also documents the rollout recommendation: run
nonce-report for at least a week before flipping to enforcing nonce.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
- VULN-002: Harden local_graph_ids via int casting
- VULN-001/GhsaGp82: Anchor regex for graph_nolegend and thumbnails
- Test: Restore CactiStubs.php and update security tests for modern wrappers
Pest tests pin every CSP-pipeline boundary: validator parses csp-report
bodies, 16KB cap, Content-Type allowlist, defaultReportUri derives from
$url_path correctly across base prefixes, and emitHeaders bytes match
strict-dynamic + unsafe-eval + nonce shape. infection.json5 scopes
mutation to lib/headers_secure.php + lib/csp_report_endpoint.php.

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

- Centralized system execution into cacti_process_execute.
- Refactored lib/snmp.php to use array-based arguments for snmpget/walk.
- Hardened lib/rrd.php tune operations.
- Migrated internal PHP script execution in lib/utility.php to cacti_exec.
- Updated get_client_addr to enforce trusted proxy allowlist.
- Eliminated command injection vectors system-wide in core library files.
- Implemented architectural helper cacti_process_execute (Array-based).
- Refactored cacti_exec to use safe core.
- Refactored lib/snmp.php main getters (cacti_snmp_get, get_bulk, get_next).
- Refactored lib/utility.php internal script execution.
- Refactored lib/functions.php miscellaneous sinks (whoami, rrdtool -v).
- Hardened get_client_addr against IP spoofing.
- Established system-wide immunity to shell command injection.
Copilot AI review requested due to automatic review settings May 29, 2026 09:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@somethingwithproof somethingwithproof changed the base branch from develop to 1.2.x May 29, 2026 09:28
@somethingwithproof

Copy link
Copy Markdown
Contributor Author

Copilot noted the PR exceeded the 300-file limit so it couldn't review inline. Pushed 4 fixes found in manual review: PHP parse error in lib/html.php (orphaned stray code), integration test asserting wrong CSP style-src behavior, report endpoint rejecting text/plain (mobile browser compat), and CI branch trigger referencing wrong branch name.

@somethingwithproof somethingwithproof force-pushed the security/1.2.x-csp-nonce-migration branch from 54fbd2e to 94a48ca Compare May 29, 2026 09:48
…rify prefix escape, IPv6 SNMP regression

Resolve git conflict markers in lib/functions.php that were causing PHP
fatal errors on any code path touching tail_file().

Replace str_contains/str_starts_with/str_ends_with with PHP 7.4-compatible
strpos/substr equivalents in lib/snmp.php and other files. The 1.2.x branch
targets PHP 7.4 where these functions do not exist; their presence caused
a fatal 'Call to undefined function' on SNMP polling and RRD operations.

Fix cacti_path_verify() prefix-match: strpos($real_path, $real_base) === 0
passes /var/log2 when base is /var/log. Add trailing slash: rtrim($real_base, '/')
. '/' to anchor the check.

Restore IPv6 bracket wrapping in cacti_snmp_session() that was removed by
the PR; without it IPv6 addresses are malformed as host:port strings.

Fix trailing space in buildCspPolicy() script-src when $alternates is empty.
Restore $alternates in frame-src directive (behavior change from base branch).

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The member/group/site list queries built ? placeholders and a $params
array but called db_fetch_assoc() (no binding), and create_all_header_nodes
bound $item_id with no matching placeholder; align placeholders and params.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
support.php called $pageFilter->render()/sanitize(), which do not exist on
the filter class, and left an unbalanced paren in the process-filter LIKE
clause; call filter_render()/filter_sanitize() and close the group.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Pagination, select-all, cancel, and select-all-realms controls had their
inline on* handlers removed for CSP but no replacement binding; add delegated
handlers keyed on the data-* attributes and marker classes the markup emits.

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

Copy link
Copy Markdown
Contributor Author

Superseded. The CSP nonce + violation-reporting work in this branch already landed on 1.2.x via #7081, #7096, and #7106, so most of this PR is now redundant or stale against the newer upstream implementation.

The genuinely net-new changes have been rebased onto current 1.2.x and split into focused, reviewable PRs:

Closing in favor of those.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants