← All reports

[5] CSP style-src-attr skipped for style attributes parsed in inert documents

LowWebCore DOM / Content Security PolicyCrossOrigin

CSP blocked the style attribute, unless it was parsed somewhere with no policy.

62614d8

Low. This is a real CSP bypass reachable from web content, but it needs an existing HTML-injection sink that stages markup through an inert document, and the ceiling is CSS-level visual control: no script execution and no cross-origin access.

Content Security Policy is meant to hold regardless of the route markup took to reach a page's DOM. Documents with no browsing context, such as the results of DOMParser, createHTMLDocument() and <template> contents, parse markup without rendering it, and WebCore's StyledElement parses a style attribute into inline style only after asking the document's ContentSecurityPolicy. The expectation is that a page declaring style-src-attr 'none' never renders an author-supplied inline style, however the element got there.

The angle: an attacker who can inject HTML through a client-side sanitizer or template pipeline can apply inline CSS in a page whose policy forbids it, enabling overlays, hiding and UI spoofing.

CSP style-src-attr does not block a style attribute parsed in a document with no browsing context. StyledElement::styleAttributeChanged() checked the attribute against the policy of the element's own document when set. A document created by DOMParser, createHTMLDocument or a template starts with an empty policy, so the attribute was allowed and the parsed style stayed on the element. When the element moved into a page whose policy sets style-src-attr to none, nothing checked it again. CSP Level 3 4.2.3 defines this check, and HTML 3.2.6.5 requires that blocked style rules are not applied. The fix checks against the policy of the document returned by contextDocument(): the recorded creator for a DOMParser result, a createHTMLDocument document or template contents, and otherwise the element's own document. Three web-platform tests cover the DOMParser, createHTMLDocument and template paths.

Source/WebCore/dom/StyledElement.cpp

+bool StyledElement::isStyleAttributeAllowedByContentSecurityPolicy(const AtomString& styleString, OrdinalNumber startLineNumber)
+{
+ Ref policyDocument = document().contextDocument();
+ return protect(policyDocument->contentSecurityPolicy())->allowInlineStyle(policyDocument->url().string(), startLineNumber, styleString.string(), CheckUnsafeHashes::Yes, *this, nonce(), isInUserAgentShadowTree());
+}
...
if (newStyleString.isNull())
ensureMutableInlineStyle()->clear();
- else if (reason == AttributeModificationReason::ByCloning || protect(document->contentSecurityPolicy())->allowInlineStyle(document->url().string(), startLineNumber, newStyleString.string(), CheckUnsafeHashes::Yes, *this, nonce(), isInUserAgentShadowTree()))
+ else if (reason == AttributeModificationReason::ByCloning || isStyleAttributeAllowedByContentSecurityPolicy(newStyleString, startLineNumber))
setInlineStyleFromString(newStyleString);
...

LayoutTests/imported/w3c/web-platform-tests/content-security-policy/style-src/style-src-attr-blocked-domparser-adopt.html

+ <meta http-equiv="Content-Security-Policy" content="style-src-attr 'none'">
...
+ var parsed = new DOMParser().parseFromString('<div id="probe" style="background-color: green"></div>', 'text/html');
+ var probe = document.adoptNode(parsed.querySelector('#probe'));
+ document.body.append(probe);
+ ...
+ assert_equals(probe.style.length, 0);

In StyledElement.cpp, the allowInlineStyle call in styleAttributeChanged() moves into a new private helper, isStyleAttributeAllowedByContentSecurityPolicy(const AtomString&, OrdinalNumber), declared in StyledElement.h. The helper resolves Ref policyDocument = document().contextDocument() and queries that document's contentSecurityPolicy(), passing policyDocument->url() as the reporting URL. The other allowInlineStyle arguments are unchanged: CheckUnsafeHashes::Yes, the element, nonce() and isInUserAgentShadowTree(). The AttributeModificationReason::ByCloning short-circuit and the parser line-number lookup on the element's own document are also unchanged. Each of the three new tests parses <div style="background-color: green"> in an inert document under a page policy of style-src-attr 'none', moves the element into the live document, and asserts probe.style.length == 0 plus a securitypolicyviolation event with violatedDirective == 'style-src-attr'.

Security policy evaluated once at ingestion against an inert staging container's empty policy, with no re-check when content migrates into the rendering context.

Content Security Policy. CSP is a page-declared policy, sent as an HTTP header or <meta http-equiv>, restricting which resources and inline content the page may use. style-src-attr is the CSP Level 3 directive governing inline style="..." attributes, 'none' forbids all of them, and a blocked attribute is reported via a securitypolicyviolation event with blockedURI == 'inline'.

Inert documents. A document without a browsing context is attached to no window. Examples are the result of DOMParser.parseFromString, document.implementation.createHTMLDocument(), and the content DocumentFragment of a <template> (owned by a separate template-contents document). They parse markup but run no scripts and render nothing. adoptNode and ordinary insertion (append) move a node, together with its already-parsed internal state, from one document into another.

Style attribute handling in WebCore. StyledElement::attributeChanged() dispatches to styleAttributeChanged() whenever the style attribute value changes. That function parses the string into the element's m_inlineStyle (a StyleProperties held in ElementData) through setInlineStyleFromString(), gated on ContentSecurityPolicy::allowInlineStyle.

Document::contextDocument(). It returns the document treated as the logical owner for policy purposes: the creator document for documents lacking their own browsing context, or the document itself otherwise.

This is a logic error that amounts to a policy bypass: the policy decision is bound to the wrong document. Before the fix, the CSP check for a style attribute ran once, inside styleAttributeChanged(), when the attribute was set, and it consulted this->document(). Inert documents receive no policy of their own (no response headers, and they are not the page that declared the <meta> policy), so allowInlineStyle passed and setInlineStyleFromString() parsed the declaration into the element's inline style. Nothing re-evaluates it later: adoptNode, append, or moving an element out of template.content carries the parsed style along with no CSP check on that path.

  Page CSP: style-src-attr 'none'

  Pre-fix:   DOMParser doc ──► style attr set ──► check vs OWN doc policy ──► allowed
                 m_inlineStyle parsed ──► adoptNode/append into page ──► no re-check ──► style applied

  Post-fix:  DOMParser doc ──► style attr set ──► check vs contextDocument() (creator page)
                 ──► blocked, violation reported, m_inlineStyle stays empty

The invariant that was missing: an inline style declaration is admitted only if it passes the policy of the context that will eventually render it. Inert documents are staging areas for content headed to their creator document, so their own policy is the wrong authority. The tests expect the violation event on window because the check now runs against the creator document's ContentSecurityPolicy object, so the report goes through that live document. The policyDocument->url() argument sets only the document URL recorded in the report; it does not choose where the event is dispatched. Under the old code the inert document's policy produced no violation at all. No memory is corrupted; the consequence is purely a policy-enforcement failure.

The bypass is reachable from web content, but only as a companion to an existing HTML-injection sink that stages markup through DOMParser, createHTMLDocument or <template>, a staging pattern common in client-side sanitizer and templating code. The primitive is arbitrary inline CSS on attacker-injected elements in a page whose CSP forbids style attributes. The ceiling is CSS-level visual control over those elements (layout, positioning, visibility, overlays). Fetches triggered by the CSS, such as background-image URLs, remain subject to directives like img-src. No process boundary is crossed.

The open questions are adjacent. Any other parse-time policy decision in WebCore that consults this->document() instead of contextDocument() has the same shape. If a staging path exists whose inert document records no creator, contextDocument() would fall back to the inert document's own policy and reintroduce the bypass. The retained AttributeModificationReason::ByCloning short-circuit is safe only if every cloned source was itself checked against the right policy, so a source parsed in an unrecorded-creator inert document and then cloned into the page deserves a test.

This weakens CSP's inline-style restriction, a defense-in-depth layer meant to contain HTML injection. The policy model assumes a page declaring style-src-attr 'none' never renders an author-supplied style attribute, whatever path the markup took. The plausible gain is visual: UI redressing, content spoofing, or overlaying and hiding page elements.

Insight: client-side sanitizers and templating libraries often parse untrusted markup in an inert document and then move the nodes into the live page, exactly the three paths this bug covers. CSP is the backstop for when such a sanitizer lets a style attribute through, and before the fix the backstop silently did not apply on those paths. Checks done eagerly at parse or attribute-set time bake the decision into internal state (m_inlineStyle), so the document consulted at that moment is security-critical. contextDocument() is WebKit's established answer to which document's policy governs an inert document.