Skip to content

CSP support #12084#12085

Draft
rymsha wants to merge 6 commits into
masterfrom
csp-support-12084
Draft

CSP support #12084#12085
rymsha wants to merge 6 commits into
masterfrom
csp-support-12084

Conversation

@rymsha
Copy link
Copy Markdown
Contributor

@rymsha rymsha commented May 28, 2026

Fixes #12084

Adds a first-class ContentSecurityPolicy model on PortalRequest, plus a lib-portal JavaScript surface (portal.csp()) so site / page controllers, parts, layouts, and widgets can contribute directives, SHA hashes, and a request nonce alongside Java contributors. The final Content-Security-Policy header is composed at portal response-flush time in BasePortalHandler, so late additions during rendering still land in the header.

JavaScript API (lib-portal)

const portal = require('/lib/xp/portal');
const csp = portal.csp(); // request-scoped; same instance for the whole request

// One-shot strict baselines -- start restrictive, then open up:
csp.strict();        // default-src 'none'; base-uri 'none'; frame-ancestors 'none'
csp.strictDynamic(); // script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; base-uri 'none'

// Typed source-list directives -- variadic CspSource and/or raw strings
csp.scriptSrc(portal.CspSource.SELF, 'https://cdn.example.com');
csp.styleSrc(portal.CspSource.SELF);
csp.imgSrc(portal.CspSource.SELF, portal.CspSource.DATA);
// also: defaultSrc, fontSrc, connectSrc, mediaSrc, objectSrc, frameSrc, workerSrc, manifestSrc, childSrc

// Restrictive directives
csp.frameAncestors(portal.CspSource.NONE);
csp.baseUri(portal.CspSource.SELF);
csp.formAction(portal.CspSource.SELF);

// Boolean directive + sandbox (unquoted tokens)
csp.upgradeInsecureRequests();
csp.sandbox(portal.SandboxFlag.ALLOW_SCRIPTS, portal.SandboxFlag.ALLOW_SAME_ORIGIN);

// Inline hashes (SHA of content, or precomputed digest)
csp.addScriptSrcSha('window.foo = 42;');
csp.addStyleSrcSha('body { color: red; }');

// Nonce -- wire into script-src, style-src, or both (the only directives a nonce is valid for)
const nonce = csp.nonceScriptSrc(); // -> script-src 'nonce-...'; returns the value
csp.nonceStyleSrc();                // -> style-src  'nonce-...' (same value)
// csp.nonce();                     // both at once

// Escape hatches for less-common / future directives
csp.add('require-trusted-types-for', ["'script'"]);
csp.set('script-src', [portal.CspSource.SELF]);

All directive methods are chainable. CspSource keyword constants are emitted single-quoted ('self', 'none', 'unsafe-inline', 'unsafe-eval', 'strict-dynamic', 'unsafe-hashes', 'wasm-unsafe-eval', 'report-sample'); scheme constants are emitted verbatim (data:, blob:). SandboxFlag constants are emitted unquoted (allow-scripts, allow-same-origin, ...).

Java API

PortalRequest.getContentSecurityPolicy() lazily creates a mutable, request-scoped ContentSecurityPolicy (same instance for the lifetime of the request; no separate Builder). It mirrors the JS surface:

ContentSecurityPolicy csp = req.getContentSecurityPolicy();

csp.strict();                 // or csp.strictDynamic();
csp.scriptSrc( CspSource.SELF, CspSource.STRICT_DYNAMIC );
csp.imgSrc( CspSource.SELF, CspSource.DATA );
csp.frameAncestors( CspSource.NONE );
csp.upgradeInsecureRequests();
csp.sandbox( SandboxFlag.ALLOW_SCRIPTS );
csp.addScriptSrcSha( inlineScriptBytes );

String nonce = csp.nonceScriptSrc(); // nonceStyleSrc() / nonce() for the others

csp.add( "require-trusted-types-for", "'script'" );
csp.set( "script-src", "'self'" );

All directive methods return ContentSecurityPolicy (chainable); build() renders the header value.

Presets

  • strict() -- deny-all baseline: base-uri 'none'; default-src 'none'; frame-ancestors 'none'. Call it first, then open up only what you need.
  • strictDynamic() -- the web.dev nonce-based "strict CSP": base-uri 'none'; object-src 'none'; script-src 'nonce-...' 'strict-dynamic'. The nonce is generated eagerly on script-src; retrieve it with nonceScriptSrc().

Semantics

  • Merge: add is union (deduped) for every directive class (source-list, boolean, restrictive); set resets a directive's source list -- there is no freeze, so add after set still extends.
  • Nonce: lazily generated, >= 128 bits, cached for the request. A nonce- source is valid only for script-src and style-src; nonceScriptSrc(), nonceStyleSrc() and nonce() (both) all return the same value and wire their target directive. If no nonce* method is called, no nonce- entry is emitted.
  • Output: directives render in alphabetical order (deterministic); sources within a directive keep insertion order. 'unsafe-inline' and 'nonce-...' may coexist -- browser precedence rules apply.

Test plan

  • ./gradlew :portal:portal-api:test :lib:lib-portal:test -- green.
  • ContentSecurityPolicyTest (Java model) + CspHandlerScriptTest (script bridge) cover: typed/raw directives, restrictive directives, boolean, sandbox, hashes, the strict()/strictDynamic() presets, the nonceScriptSrc/nonceStyleSrc/nonce trio, and the CspSource keyword + scheme tokens.

Generated with Claude Code

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 28, 2026

Codacy's Analysis Summary

0 new issue (≤ 0 issue)
0 new security issue
182 complexity
More details

AI Reviewer: run a review on demand. To trigger the first review automatically, go to your organization or repository integration settings. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes. Give us feedback

@codecov
Copy link
Copy Markdown

codecov Bot commented May 28, 2026

Codecov Report

❌ Patch coverage is 78.72340% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.02%. Comparing base (660fb27) to head (2f4e8ad).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
.../java/com/enonic/xp/lib/portal/csp/CspHandler.java 60.00% 28 Missing and 4 partials ⚠️
...om/enonic/xp/portal/csp/ContentSecurityPolicy.java 82.69% 18 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #12085      +/-   ##
============================================
- Coverage     86.04%   86.02%   -0.03%     
- Complexity    19377    19466      +89     
============================================
  Files          2544     2549       +5     
  Lines         65928    66162     +234     
  Branches       5289     5300      +11     
============================================
+ Hits          56728    56916     +188     
- Misses         6636     6678      +42     
- Partials       2564     2568       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rymsha rymsha force-pushed the csp-support-12084 branch from a947560 to 6633f41 Compare May 28, 2026 20:14
Introduce a first-class ContentSecurityPolicy model on PortalRequest, plus
a lib-portal JavaScript API (portal.getCsp()) exposing the same surface to
script code. Multiple contributors during request rendering (platform,
site app, custom apps, widgets, page controllers, parts, layouts) can
extend the same instance through add / set / addSha / nonce /
applyNonceTo. The final Content-Security-Policy header value is composed
at portal response-flush time by BasePortalHandler, so late additions
during rendering still land in the header.

Java surface (portal-api):
- com.enonic.xp.portal.csp.ContentSecurityPolicy — mutable, request-scoped;
  methods return this for chaining; no separate Builder.
- com.enonic.xp.portal.csp.HashAlgo — SHA256 / SHA384 / SHA512.
- PortalRequest.getContentSecurityPolicy() — lazy field, same instance per
  request.
- BasePortalHandler.doHandle — emits Content-Security-Policy from
  policy.build() on both happy-path and exception-rendered paths,
  preserves any header the response already carries, and keeps the
  PortalResponse type via PortalResponse.create(...).

JavaScript surface (lib-portal):
- portal.getCsp() — returns a Csp object backed by the request-scoped
  policy, so Java and JS contributors compose on a single instance.
- Csp.add / set / addSha / getNonce / applyNonceTo.
- The 2-arg addSha hashes UTF-8 bytes of the content; the 3-arg form
  accepts a precomputed digest + algo string ('sha256' | 'sha384' |
  'sha512'); unsupported algo throws.

Tests:
- ContentSecurityPolicyTest covers add/set/dedupe, addSha bytes vs
  precomputed, nonce lazy + cached, applyNonceTo before / after nonce(),
  alphabetical directive order, unsafe-inline + nonce coexistence, late
  mutation lands in subsequent build(), null arguments throw.
- PortalRequestTest covers same-instance-per-request and
  distinct-instances-across-requests.
- BasePortalHandlerTest covers header emission, no-emit when policy was
  never accessed or stayed empty, preserves existing CSP header,
  preserves PortalResponse type, exception-path emission.
- CspHandlerScriptTest covers the JS surface end-to-end via Nashorn.

Closes #12084

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rymsha rymsha force-pushed the csp-support-12084 branch from a935f09 to 366f1ad Compare May 28, 2026 20:33
rymsha and others added 2 commits May 28, 2026 20:44
The only realistic use case for applyNonceTo was inline
<style nonce="...">, which is rare and one line via the existing
add method. Drop the method to keep the CSP API minimal: the nonce
is added to script-src on first nonce() call; callers wanting the
nonce in other directives use add directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the stringly-typed CSP surface with typed per-directive
methods plus constants for the special source/sandbox keywords,
keeping add / set / addSha as escape hatches.

New methods on ContentSecurityPolicy / Csp (chainable):

- defaultSrc / scriptSrc / styleSrc / imgSrc / fontSrc / connectSrc /
  mediaSrc / objectSrc / frameSrc / workerSrc / manifestSrc / childSrc
- frameAncestors / baseUri / formAction
- upgradeInsecureRequests
- sandbox(SandboxFlag... flags)
- addScriptSrcSha / addStyleSrcSha (bytes + precomputed-digest overloads)

New constants:

- CspSource: SELF / NONE / UNSAFE_INLINE / UNSAFE_EVAL / STRICT_DYNAMIC /
  UNSAFE_HASHES / WASM_UNSAFE_EVAL / REPORT_SAMPLE -- emitted single-quoted
- SandboxFlag: ALLOW_SCRIPTS / ALLOW_SAME_ORIGIN / ALLOW_FORMS / ALLOW_POPUPS /
  ALLOW_MODALS / ALLOW_TOP_NAVIGATION / ALLOW_DOWNLOADS / ALLOW_POINTER_LOCK /
  ALLOW_PRESENTATION / ALLOW_ORIENTATION_LOCK -- emitted unquoted

Java directive methods come in CspSource... / String... overload pairs so
host/scheme strings and typed keywords are both supported without losing
type safety. TS surface uses (CspSource | string)[] variadics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rymsha
Copy link
Copy Markdown
Contributor Author

rymsha commented May 29, 2026

Pushed bcc73e9406 — typed CSP API on top of the existing branch (no force-push, no rebase).

What changed

  • New CspSource / SandboxFlag enums in portal-api (single-quoted vs. unquoted tokens, respectively).
  • ContentSecurityPolicy grew typed per-directive methods (defaultSrc / scriptSrc / styleSrc / imgSrc / fontSrc / connectSrc / mediaSrc / objectSrc / frameSrc / workerSrc / manifestSrc / childSrc / frameAncestors / baseUri / formAction / upgradeInsecureRequests / sandbox) in CspSource... and String... overload pairs, plus addScriptSrcSha / addStyleSrcSha. Generic add / set / addSha remain as escape hatches.
  • CspHandler forwarders + lib/xp/portal.ts extensions, plus exported CspSource / SandboxFlag as const constants (importable as both value and type).
  • examples/portal/getCsp.js rewritten against the typed API.
  • Tests: ContentSecurityPolicyTest 22 → 43; CspHandlerScriptTest 9 → 20.

Local tests

  • ./gradlew :portal:portal-api:test :portal:portal-impl:test :lib:lib-portal:test — green
  • ./gradlew :portal:portal-api:check :lib:lib-portal:check — green
  • ./gradlew :lib:lint — green

CI on bcc73e9406
All required checks pass: build (11m24s), Analyze (java-kotlin / javascript-typescript / actions), CodeQL, Codacy. See https://github.com/enonic/xp/pull/12085/checks.

@rymsha rymsha marked this pull request as draft May 29, 2026 07:36
Follow-ups on the CSP API:

- lib-portal: portal.getCsp() -> portal.csp() (drop the get- prefix);
  example + script test renamed to csp.js / csp-test.js accordingly.
- ContentSecurityPolicy: add two one-shot strict baselines --
  strict() (deny-all: default-src/base-uri/frame-ancestors 'none') and
  strictDynamic() (web.dev nonce + 'strict-dynamic' script policy).
- Nonce: replace the script-only getNonce() with nonceScriptSrc(),
  nonceStyleSrc() and nonce() (both) -- the only two directives a
  nonce- source is valid for. One cached per-request value; each call
  wires its directive. strictDynamic() uses nonceScriptSrc().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

// Request-scoped nonce (lazy; same value on subsequent calls). Wire it into script-src,
// style-src, or both -- the only directives a nonce is valid for.
var nonce = csp.nonceScriptSrc(); // -> script-src 'nonce-...'
rymsha and others added 2 commits May 29, 2026 23:23
Schemes are emitted verbatim (data:, blob:) vs single-quoted keywords.
HTTPS intentionally omitted (no clear use-case); strictDynamic keeps its
raw https: source. Updates the example and adds token + typed-scheme tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The web.dev legacy fallbacks only apply on browsers without strict-dynamic
/ nonce support, where they weaken the policy (any https script allowed).
strictDynamic() now seeds the pure modern core:
  script-src 'nonce-<r>' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

CSP support

1 participant