Skip to content

Fix CSP violation: use DOM style API instead of setAttribute("style")#288

Open
eagle-head wants to merge 1 commit intopatrick-steele-idem:masterfrom
eagle-head:fix/csp-safe-style-sync
Open

Fix CSP violation: use DOM style API instead of setAttribute("style")#288
eagle-head wants to merge 1 commit intopatrick-steele-idem:masterfrom
eagle-head:fix/csp-safe-style-sync

Conversation

@eagle-head
Copy link
Copy Markdown

Fixes #287

Problem

morphAttrs uses setAttribute("style", ...) to sync inline styles during DOM patching. This is blocked by Content Security Policy style-src directives that do not include 'unsafe-inline', producing violations on every morph that touches a style attribute.

Solution

Add a syncStyle() function to morphAttrs.js that uses the DOM style API instead of setAttribute:

  • Remove old properties via style.removeProperty() (iterating backwards since CSSStyleDeclaration is a live list)
  • Copy target properties via style.setProperty(name, value, priority), only when value or priority differs
  • Fast-path: compare fromNode.style.cssText with toNode.style.cssText first — if they match, skip the sync entirely. This comparison uses browser-normalized strings, though shorthand expansion differences between attached and detached nodes may cause false positives (harmless, since syncStyle guards each property individually)

The DOM style API (style.setProperty, style.removeProperty) is explicitly exempt from CSP restrictions per the W3C CSP3 spec and MDN documentation.

What changed

  • src/morphAttrs.jssyncStyle() function + style branch in the attribute loop
  • test/browser/test.js — 8 new tests in describe('CSP-safe style handling'), including a spy verifying setAttribute("style") is never called
  • test/fixtures/autotest/style-{change,add,remove,css-variable}/ — 4 autotest fixtures

What did NOT change

  • Non-style attributes still use setAttribute — no behavioral change
  • Style attribute removal still uses removeAttribute("style") — this is not restricted by CSP
  • No changes to morphdom.js, specialElHandlers.js, or any other file
  • dist/ not included — left for the maintainer to rebuild

Handles

  • Standard CSS properties (color, margin, padding, display, etc.)
  • Shorthand properties (margin, padding, etc.)
  • CSS custom properties / variables (--value, --size, etc.)
  • !important priority (preserved and cleared correctly)
  • Mixed changes (style + other attributes in the same morph)
  • Idempotent — second morph with identical styles is a no-op

Context

This issue was identified while implementing CSP Level 3 support for Phoenix LiveView. Phoenix LiveView uses morphdom as its DOM patching engine, and setAttribute("style") is the only CSP-violating call in the morph pipeline. This fix unblocks strict CSP adoption for all morphdom consumers.

Discussion: Elixir Forum — CSP Level 3 support for Phoenix

@SteffenDE (who contributed recent morphdom PRs #279, #277, #269, #268) confirmed interest in this approach on the Elixir Forum.

@SteffenDE
Copy link
Copy Markdown
Contributor

Looks good to me. It would be interesting to know if there's a meaningful difference in morph time on a page that uses a lot of style attributes. If yes, it may be best to add a flag to enable/disable this.

setAttribute("style", ...) is blocked by Content Security Policy (CSP)
style-src without 'unsafe-inline'. Replace it with style.cssText assignment,
which uses the CSSOM API and is never blocked by CSP.

The fix compares via getAttribute("style") (cheap string read) and writes
via style.cssText (CSP-safe CSSOM setter). This keeps the fast-path for
unchanged styles identical to the original code.

Includes a Docker-based benchmark suite (bench/) for reproducible
performance comparison. See bench/RESULTS.md for full results.
@eagle-head eagle-head force-pushed the fix/csp-safe-style-sync branch from 0038617 to 5ff0e67 Compare March 24, 2026 12:47
@eagle-head
Copy link
Copy Markdown
Author

Thanks for the feedback @SteffenDE! Great call on the benchmark — it revealed an important issue with the initial approach.

What changed

The initial implementation (property-by-property setProperty/removeProperty loop) turned out to be 4–11x slower than setAttribute in stress tests. I've reworked the approach to use style.cssText assignment instead:

// Before (master):
fromNode.setAttribute(attrName, attrValue);

// After (this PR):
fromNode.style.cssText = attrValue;

style.cssText is a CSSOM setter — it's never blocked by CSP style-src, unlike setAttribute("style", ...) which goes through the HTML attribute mutation path that CSP checks.

The comparison still uses getAttribute("style") (cheap string read, same as master does for all other attributes), so the fast-path for unchanged styles is identical.

Benchmark results

Ran via Docker (Chromium headless, 30 iterations, trimmed mean). Full results in bench/RESULTS.md.

Scenario master this PR Factor
1000 els, 20 props, 0% changed 1.61ms 1.57ms 0.97x (identical)
1000 els, 5 props, 10% changed 1.79ms 1.90ms 1.06x (neutral)
1000 els, 20 props, 10% changed 2.09ms 2.92ms 1.40x
1000 els, 5 props, 100% changed 3.77ms 5.51ms 1.46x
1000 els, 20 props, 100% changed 5.46ms 13.73ms 2.51x
1000 els, no style (baseline) 1.20ms 1.26ms 1.05x (neutral)

The common case in frameworks like LiveView (incremental re-renders, 0–10% of elements change style) shows negligible overhead. The 100% changed worst case (all 1000 elements change all 20 style properties simultaneously) is 1.5–2.5x — this is the intrinsic cost of the CSSOM cssText setter vs setAttribute, and represents the minimum overhead of being CSP-safe.

Based on these numbers, I don't think a flag is necessary — but happy to add one if you feel differently.

How to reproduce

npm run build
docker build -t morphdom-bench ./bench
docker run --rm \
  -v "$(pwd)/dist:/morphdom/dist:ro" \
  -v "$(pwd)/bench/style-benchmark.html:/bench/style-benchmark.html:ro" \
  morphdom-bench

See bench/README.md for details on comparing branches.

Let me know what you think — open to any suggestions on the approach.

@SteffenDE
Copy link
Copy Markdown
Contributor

That sure is an interesting CSP workaround. And yeah, I agree that there's no need to make it configurable. See also w3c/webappsec-csp#774.

@eagle-head
Copy link
Copy Markdown
Author

Thanks @SteffenDE! Great find on the W3C issue — it's indeed the core of why this works.

As discussed there, style.cssText should be blocked per spec, but no browser enforces it — and this has been the case for years. So our fix relies on consistent browser behavior rather than spec guarantees. If browsers ever align with the spec on this, we'd need to revisit.

The ideal long-term solution would be pure CSS — using mechanisms like attr() with CSS custom properties to drive style values declaratively, removing the need for any JS-based style mutation. However, CSS attr() with type coercion is currently only available in Chrome/Edge. Once it lands in Firefox and Safari, frameworks could move toward data-* attributes + CSS attr() and avoid the CSP style-src question entirely.

Until then, style.cssText seems like the most practical path — it's CSP-safe in every browser today and the performance overhead is acceptable for real-world use cases.

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.

setAttribute("style") violates Content Security Policy (CSP) style-src

2 participants