Skip to content

Content Security Policy Hardening

Ed Mozley edited this page Jul 11, 2026 · 1 revision

Content Security Policy hardening β€” parked idea

Note

Status: PARKED / documented. This page records what it would take to make FreeITSM run under a strict Content Security Policy, why that's a large piece of work rather than a bug fix, and an honest, measured assessment of the actual risk. It also captures the CSP that works today, which resolves the practical need for almost everyone. Raised as GitHub issue #43 by a security-conscious self-hoster.

Background reading: Email Rendering & Images touches the same theme (untrusted HTML, isolation, defence in depth).


The problem, in plain English

A Content Security Policy (CSP) is a rule you give the browser that says, in effect, "only run scripts and load resources from places I explicitly trust." It's a seatbelt: if an attacker ever manages to sneak malicious code onto a page, a good CSP stops the browser from actually running it.

A self-hoster set a strict CSP on their web server and found parts of FreeITSM stopped working β€” first the Database Verify button on the setup page, then switching between modules. Their strict policy said "only run script files served from this site, and nothing written inline in the page." Loosening it (allowing inline script) fixed everything.

That's not a coincidence or a one-off bug. FreeITSM β€” like WordPress, phpMyAdmin, and the large majority of server-rendered PHP applications β€” was built with a lot of inline scripting: little <script> blocks written directly into pages, and onclick="…"-style handlers written directly onto buttons. A strict CSP forbids exactly that. So a strict policy doesn't break two features β€” it breaks the inline pattern the whole UI is built on, and those two were simply the first the reporter hit.

What we're actually dealing with (the scale)

A quick audit of the current codebase:

Thing a strict CSP forbids Count Notes
Inline event handlers in PHP (onclick= …) ~1,088 across 122 files The visible buttons/links
Inline handlers baked into JS-rendered HTML ~200 more The reading pane, settings modals etc. build HTML strings with onclick= in them β€” harder to find, only "fire" when that view renders
Files with inline <script> blocks 159 Page bootstrap, config, the t() i18n bridge, per-page glue
Inline style="…" attributes ~1,444 The style-src surface β€” see the note below
Uses of eval() / new Function() in app code 0 Good news β€” see below
CSP shipped by the app itself none The app is CSP-agnostic; the reporter added one at the Apache vhost level

So the inline script surface alone is roughly 1,300 handlers + 159 script blocks, and the inline style surface is larger still.

The risk, assessed honestly

It's worth being clear-eyed here, in both directions.

Why it genuinely matters. CSP is a real, respected layer of defence. Its value is defence in depth against cross-site scripting (XSS): if some input ever slips through un-escaped and injects a <script>, a strict CSP is the safety net that stops it executing. FreeITSM does handle untrusted input β€” inbound email HTML, requester-portal submissions, form fields, ticket content β€” so the class of threat CSP mitigates is not hypothetical for this kind of app.

Why it is not an emergency. A CSP is the second line of defence, not the first. The first line is correct output encoding, and FreeITSM already does that pervasively (htmlspecialchars throughout; inbound email is parsed and sanitised, then rendered in an isolated shadow root β€” see Email Rendering & Images). CSP would harden the app if an escaping gap existed; it is not itself the thing standing between you and compromise. FreeITSM is also self-hosted and internal β€” an IT team's own service desk behind their own auth β€” not a public, anonymous, multi-tenant SaaS where the blast radius of one XSS is enormous.

The measured conclusion. Strict-CSP support is a legitimate hardening goal with low-to-moderate real-world urgency for a typical deployment. That balance matters, because the "fix" is a very large, cross-cutting change β€” and a rushed refactor of ~1,300 interactive handlers is itself a reliable way to introduce bugs (and bugs in security-sensitive UI can be worse than the gap you set out to close). The responsible position is: take it seriously, give people a working policy now, and treat full strict-CSP as a deliberate, phased project to be done well or not at all β€” not as a reactive scramble.

What works today (the practical answer)

You do not need any of the below to run a sensible CSP right now. Because the app's own code contains no eval, the 'unsafe-eval' that the reporter carried over from phpMyAdmin's policy is almost certainly unnecessary. A good working policy is:

Content-Security-Policy:
  default-src 'self' data:;
  script-src  'self' 'unsafe-inline';
  style-src   'self' 'unsafe-inline';
  img-src     'self' data: https:;
  connect-src 'self';
  frame-src   'self' https://www.youtube.com;
  base-uri    'self';
  object-src  'none';
  • script-src 'self' 'unsafe-inline' β€” required by the inline pattern described above. Dropping 'unsafe-eval' is worth testing and will likely work everywhere except possibly a specific rich-media page (a bundled library such as the editor or a diagram/export tool might want it β€” verify per feature rather than granting it globally).
  • style-src 'unsafe-inline' β€” required by the ~1,444 inline styles (and by libraries that inject styles).
  • img-src … https: β€” inbound emails and knowledge articles can hot-link remote images.
  • frame-src … youtube.com β€” only if you embed the videos.

This is the same shape of policy WordPress and phpMyAdmin ship, and it is a perfectly defensible posture. The honest documentation line is: "FreeITSM supports a CSP with 'unsafe-inline'; a strict policy that forbids inline script is a known limitation on the roadmap."

Technical detail β€” for devs who want to get stuck in

If someone wants to pursue real strict-CSP support, here's the shape of the terrain, including the parts that surprise people.

Nonces don't rescue you. The obvious move β€” a per-request nonce on each <script nonce="…"> and script-src 'self' 'nonce-…' β€” legitimises the 159 inline <script> blocks, but CSP cannot nonce an inline event-handler attribute. There is no onclick nonce. So a nonce strategy still requires removing every one of the ~1,300 onclick=-style handlers first. Nonces are necessary but nowhere near sufficient.

Hashes are impractical here. script-src 'sha256-…' can whitelist a static inline block by its hash, but the blocks are numerous, varied, and often template inline values (so their hash changes per render). Not a realistic path for 159 blocks.

The tractable migration is delegation, not 1,300 rewrites. Rather than converting each handler to its own addEventListener, the sane pattern is a data-attribute + delegated dispatcher: mark up <button data-action="openNoteModal" data-id="42"> and register one listener per view that reads data-action and calls the matching function. That collapses ~1,300 individual edits into a mechanical find-and-replace plus a small dispatch table β€” far lower risk and far easier to review. The JS-rendered handlers (the ~200 baked into template strings) get the same treatment at their source.

report-only is how you do this without breaking anything. Ship Content-Security-Policy-Report-Only with the strict policy and a report-to/report-uri endpoint. The app keeps working (report-only doesn't block), but every violation is reported, giving an empirical, exhaustive list of what still needs converting β€” including the JS-injected handlers that only fire when a particular view renders and which static grep can miss.

Style is the harder half, and probably not worth chasing. There are more inline style= attributes (~1,444) than inline scripts, and β€” exactly like event handlers β€” inline style attributes cannot be nonced. Removing them means moving every inline style into stylesheets or data-driven classes, an even bigger sweep with far less security payoff (CSS-only injection is a much weaker vector than script injection). The pragmatic, widely-accepted target is therefore strict script-src (nonce-based, no unsafe-inline) while keeping style-src 'unsafe-inline'. Chasing strict style-src is diminishing returns.

Realistic end state: script-src 'self' 'nonce-…' (no unsafe-inline, no unsafe-eval), style-src 'self' 'unsafe-inline', plus the resource directives above. That is a genuinely strong posture and an achievable one; fully strict everything is not a sensible goal for an app of this shape.

If it were ever built β€” a phased plan

  1. Baseline + measurement. Ship a sensible default CSP header from the app (the working policy above), documented. Simultaneously offer a report-only strict variant with a violation-collection endpoint, to build the real inventory.
  2. De-inline <script> blocks. Move page-bootstrap data (API_BASE, config, i18n payload) into data-* attributes or a single JSON island read by external JS; add a per-request nonce to whatever inline script genuinely must remain.
  3. De-inline handlers, module by module. Convert to the data-action + delegated-dispatcher pattern, one module at a time, while still in report-only so nothing breaks in production. Track progress per module (this is 21 modules of surface).
  4. Flip to enforcing, per module. Once a module reports clean under report-only, it can move under an enforcing strict script-src. Rollout is incremental, not big-bang.
  5. Leave style-src 'unsafe-inline'. Document it as a deliberate, pragmatic decision.

Pitfalls

  • The JS-injected handlers are the trap. De-inlining the PHP is the easy 80%; the ~200 handlers built inside JS template strings (reading pane, settings modals, pickers) are the hard 20% β€” they only violate when that view renders, so they slip past static analysis. report-only telemetry is the only reliable way to find them all.
  • Nonce plumbing touches everything. A per-request nonce must be generated once and threaded through every shared header/include, and interacts with any page/output caching. Get it wrong and either scripts break or the nonce becomes predictable (which defeats it).
  • Third-party libraries. The rich-text editor, charting, and diagram/export tooling may inject inline styles/scripts or want eval on specific pages. These need testing per feature; a global grant is the wrong answer.
  • Regression surface. ~1,300 interactive handlers is a lot of buttons; every conversion is a chance to silently break one. This work demands the project's verify-the-change discipline, ideally with click-through coverage per module.
  • Half-migrated confusion. An app where some modules are strict and some aren't needs clear tracking, or it becomes impossible to reason about what policy is safe to enforce.
  • Don't over-reach on style-src. It's tempting to "finish the job", but strict style is a much bigger sweep for a much smaller gain. Scope discipline matters.

The bar to ship

This is worth doing only as a deliberate, report-only-guarded, per-module project with real demand behind it β€” and the realistic target is strict script-src, not strict everything. Absent that, the correct state is parked, with the working 'unsafe-inline' policy clearly documented so security-conscious admins know exactly what is and isn't supported. Parking it indefinitely is an acceptable outcome; the documented policy meets the practical need for the overwhelming majority of deployments.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally