Content-Security-Policy — No Inline Script

The portal's script-src is moving from 'self' 'unsafe-inline' 'unsafe-eval' blob: to 'self' 'nonce-<per response>' blob:. 'unsafe-inline' is what let injected markup run at all: an inline <script> or an on*= handler smuggled into rendered content executed with the viewer's session. Fixing each injection point (at render time and at write time) removes that injection; dropping 'unsafe-inline' removes that class of execution.

What the strict policy stops, precisely: an inline <script> (or inline import map) that does not carry this response's nonce, every inline event handler (on*=), every javascript: URL, and — with 'unsafe-eval' gone — eval, new Function and string timers.

What it does NOT stop: a <script src> pointing at a file the policy allows WITHOUT a nonce — any same-origin URL ('self') and any blob: URL. So an injection that can make the portal SERVE attacker-controlled JavaScript from its own origin (an uploaded file returned as script, a content path that echoes input as JavaScript), or that already runs script able to mint a blob:, still executes. Same-origin hosting of user content as executable script is therefore a separate defect class the policy does not cover — X-Content-Type-Options: nosniff and the content-type a file is served with are what stand between an uploaded file and a script tag. Nor does it stop markup injection that runs no script (an injected form, link or style).

The policy is set in MeshWeaver.Plugins, src/Memex.Portal.Gui/Security/ContentSecurityPolicy.cs, and composed by MemexPortalComposition. No other host in either repository sets one: the print and slide export documents carry their own default-src 'none' meta policy (see Pixel-Faithful Export), which is unrelated.

The two policies

Every directive except script-src is the same in both, and unchanged from the policy enforced since MeshWeaver.Plugins#1988: default-src 'self', object-src 'none', base-uri 'self', frame-ancestors 'self', form-action 'self' https:, and the img/media/font/connect/frame/worker sources.

script-src reports
Compatibility (enforced since #1988) 'self' 'unsafe-inline' 'unsafe-eval' blob: none
Strict 'self' 'nonce-<n>' blob: report-uri /api/csp-report

style-src keeps 'unsafe-inline' in both, deliberately. Fluent UI and Blazor write inline styles everywhere, and an injected style cannot execute script. Tightening it is a separate pass and out of scope here.

The nonce

One per request: 128 random bits, base64, minted by the CSP middleware before the response renders and kept on HttpContext.Items (HttpContext.CspNonce()). App.razor stamps it on every inline <script> it emits and on Blazor's <ImportMap> (an inline import map is subject to script-src too). The header names the same value. It is new on every response, so it cannot be guessed or replayed from an earlier page. Browsers hide the nonce ATTRIBUTE (getAttribute and CSS attribute selectors read an empty value), which keeps it from markup-only exfiltration — but the nonce PROPERTY (script.nonce) still returns it to any JavaScript already running in the page. The nonce therefore stops injected markup from starting script; it is no barrier once script is running, which is what the rest of this policy exists to prevent.

Pages rendered outside the Blazor shell carry no nonce and need none: the step-up pages (memex/Memex.Portal.Shared/Authentication/StepUpPages.cs, core) put their per-page values in a JSON data block (<script type="application/json">, which the browser never executes) and their behaviour in a same-origin file, /auth/step-up/page.js. The first-run setup page has no script.

The switch — declared, default ReportOnly

Security:ContentSecurityPolicy:Mode (environment: Security__ContentSecurityPolicy__Mode), declared in both portal hosts' appsettings.json and overridable per deployment record:

Mode Content-Security-Policy Content-Security-Policy-Report-Only
ReportOnly (default) compatibility strict
Enforce strict —

In ReportOnly nothing a browser blocks changes; every page the strict policy would break is reported. Any other value refuses to start, naming the value — an unknown mode never falls back to one.

Reports

POST /api/csp-report — anonymous (a browser sends a report on its own), excluded from the guest virtual-user flow (VirtualUserMiddleware.ExcludedExactPaths, core), bounded at 16 KB (413 otherwise, unread), answered 204. Each violation is ONE warning line on the Memex.Portal.Gui.Security.ContentSecurityPolicy category:

CSP violation (report): script-src-elem blocked inline on https://<host>/<path> at https://<host>/<file>:<line> sample=<first chars>

Every URL is cut at its query and fragment (a page URL can carry a token), every field at 256 characters with control characters dropped. Both report bodies are read: the report-uri shape (application/csp-report) and the Reporting API's (application/reports+json). The lines ship to Loki like any other warning, so a Logs instance action over that category is the read.

Inventory — what emitted inline script, and what became of it

Site Was Now
App.razor (Plugins): theme seed, Monaco loader, instance title, prerender hook, reconnect reload, Blazor.start 6 inline <script> each carries the nonce
App.razor: <ImportMap /> inline import map carries the nonce
Step-up pages (core) one inline <script> per page data block + /auth/step-up/page.js
PortalLayoutBase.razor.cs (Plugins) eval("window.dispatchEvent(...)") module export dispatchResize
SimpleDropdown.razor (Plugins) three eval calls collocated module SimpleDropdown.razor.js
DevLoginConfirm.razor (Plugins) eval("…submit()") collocated module DevLoginConfirm.razor.js
NotificationCenterPanel.razor (Plugins) anchor on javascript:void(0) FluentButton
FileBrowser.razor (Plugins) anchor on javascript:void(0) href="#" + @onclick:preventDefault
Content pages under /api/content (Google and AppleMusic authorize.html, two Essentials design previews) one inline <script> each sibling .js file
React client clients/portal/index.html module script with src unchanged — no inline script
Blazor framework, Fluent UI, BlazorMonaco interop, Monaco bundle external files unchanged — 'self'

Pinned by tests: MeshWeaver.Plugins ContentSecurityPolicyTest (headers per mode, a new nonce per request, the nonce in the header is the one the render read, unknown mode refused, report logged with the query cut, oversized report refused) and StaticPublicPageRenderingTest (the real shell, static and interactive, signed in and anonymous: every inline script carries the response's nonce, no on*= and no javascript: — read against a denominator of inline scripts, with a foreign-nonce negative control); core StepUpPagesCarryNoInlineScriptTest.

What remains before Enforce

These are expected to show up as reports in ReportOnly, and each must be resolved — or consciously accepted in this list — before a deployment declares Enforce:

Rolling out

  1. Ship with ReportOnly everywhere (this change).
  2. Read the report lines per instance for at least one full working week (Logs instance action on the category above). Each distinct (directive, blocked, source) is either fixed at its source or added to the list above with its reason.
  3. Declare Enforce on the control instance's own record first, then the public instance, then the fleet — one record change each, reversible by the same change.
  4. Once every record enforces, the compatibility policy and the mode are retired.