← 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. 웹 콘텐츠에서 도달 가능한 실제 CSP bypass입니다. 다만 inert document를 거쳐 markup을 준비하는 HTML injection sink가 이미 존재해야 합니다. 영향의 상한도 CSS 수준의 시각적 제어에 머무르며, script 실행이나 cross-origin 접근으로는 이어지지 않습니다.

Content Security Policy는 markup이 어떤 경로로 페이지 DOM에 들어왔는지와 관계없이 적용되어야 합니다. DOMParser의 결과물, createHTMLDocument(), <template> contents처럼 browsing context가 없는 document는 markup을 parse하지만 렌더링하지는 않습니다. 한편 WebCore의 StyledElement는 document의 ContentSecurityPolicy를 먼저 확인한 뒤에야 style attribute를 inline style로 parse합니다. 즉 style-src-attr 'none'을 선언한 페이지라면, element가 어떤 경로로 들어왔든 작성자가 제공한 inline style은 렌더링되지 않아야 합니다.

관전 포인트: client-side sanitizer나 template pipeline을 통해 HTML을 주입할 수 있는 attacker라면, policy가 금지하는 페이지에서도 inline CSS를 적용할 수 있습니다. 이를 통해 overlay, 요소 숨기기, UI spoofing이 가능해집니다.

CSP style-src-attr가 browsing context 없는 document에서 parse된 style attribute를 차단하지 못하는 문제입니다. StyledElement::styleAttributeChanged()는 attribute가 설정되는 시점에 element 자신이 속한 document의 policy로 검사를 수행했습니다. 그런데 DOMParser, createHTMLDocument, template으로 생성된 document는 빈 policy로 시작합니다. 그래서 attribute가 허용되었고, parse된 style이 element에 그대로 남았습니다. 이후 element가 style-src-attr을 none으로 설정한 페이지로 이동해도 다시 검사하는 단계는 없었습니다. 이 검사는 CSP Level 3 4.2.3에 정의되어 있으며, HTML 3.2.6.5는 차단된 style rule이 적용되지 않을 것을 요구합니다. Fix에서는 contextDocument()가 반환하는 document의 policy로 검사하도록 변경되었습니다. 이 document는 DOMParser 결과물, createHTMLDocument document, template contents의 경우 기록된 creator이며, 그 외에는 element 자신의 document입니다. DOMParser, createHTMLDocument, template 세 경로는 각각 web-platform test로 검증됩니다.

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);

StyledElement.cpp에서 styleAttributeChanged() 안의 allowInlineStyle 호출이 새로운 private helper인 isStyleAttributeAllowedByContentSecurityPolicy(const AtomString&, OrdinalNumber)로 옮겨졌습니다. 이 helper는 StyledElement.h에 선언되었습니다. Helper는 먼저 Ref policyDocument = document().contextDocument()로 기준 document를 구한 뒤, 해당 document의 contentSecurityPolicy()를 조회합니다. 이때 reporting URL로는 policyDocument->url()이 전달됩니다. allowInlineStyle의 나머지 인자인 CheckUnsafeHashes::Yes, element, nonce(), isInUserAgentShadowTree()는 그대로 유지되었습니다. AttributeModificationReason::ByCloning short-circuit과, element 자신의 document에서 parser line number를 조회하는 부분도 변경되지 않았습니다.

새로 추가된 테스트 세 개는 모두 같은 흐름을 따릅니다. 먼저 페이지 policy를 style-src-attr 'none'으로 두고 inert document에서 <div style="background-color: green">을 parse합니다. 이어 해당 element를 live document로 옮긴 뒤, probe.style.length == 0인지 확인합니다. 아울러 violatedDirective == 'style-src-attr'인 securitypolicyviolation event가 발생하는지도 검증합니다.

Security policy가 ingestion 시점에 inert staging container의 빈 policy를 기준으로 한 번만 평가되고, content가 렌더링 context로 이동할 때 재검사가 누락된 패턴.

Content Security Policy. CSP는 페이지가 HTTP header나 <meta http-equiv>로 선언하는 policy로, 페이지가 사용할 수 있는 리소스와 inline content를 제한합니다. style-src-attr은 inline style="..." attribute를 다루는 CSP Level 3 directive입니다. 값이 'none'이면 모든 inline style attribute가 금지됩니다. 차단된 attribute는 blockedURI == 'inline'인 securitypolicyviolation event로 보고됩니다.

Inert document. Browsing context가 없는 document는 어떤 window에도 연결되지 않습니다. DOMParser.parseFromString의 결과물, document.implementation.createHTMLDocument(), 그리고 <template>의 content DocumentFragment가 대표적입니다. 마지막 경우의 fragment는 별도의 template-contents document가 소유합니다. 이런 document는 markup을 parse하지만, script를 실행하지 않고 아무것도 렌더링하지 않습니다. 한편 adoptNode와 일반적인 삽입(append)은 node를 이미 parse된 내부 상태와 함께 다른 document로 옮깁니다.

WebCore의 style attribute 처리. style attribute 값이 바뀔 때마다 StyledElement::attributeChanged()가 styleAttributeChanged()를 호출합니다. 이 함수는 setInlineStyleFromString()을 통해 문자열을 element의 m_inlineStyle로 parse합니다. m_inlineStyle은 ElementData에 들어 있는 StyleProperties입니다. 다만 이 parse는 ContentSecurityPolicy::allowInlineStyle을 통과해야만 수행됩니다.

Document::contextDocument(). Policy 관점에서 논리적인 소유자로 취급되는 document를 반환합니다. 자체 browsing context가 없는 document라면 creator document를, 그렇지 않으면 document 자신을 반환합니다.

Fix 이전에는 style attribute에 대한 CSP 검사가 attribute 설정 시점에 styleAttributeChanged() 안에서 단 한 번 수행되었습니다. 이때 기준이 된 것은 this->document()였습니다. 그런데 inert document는 자체 policy를 받지 않습니다. Response header가 없고, <meta> policy를 선언한 페이지도 아니기 때문입니다. 결과적으로 allowInlineStyle이 통과되었고, setInlineStyleFromString()이 선언을 element의 inline style로 parse했습니다. 문제는 이후 이 값을 다시 평가하는 단계가 없다는 점입니다. adoptNode, append, 또는 template.content에서 element를 꺼내는 경로 모두 parse된 style을 그대로 가져가며, 그 과정에서 CSP 검사는 수행되지 않습니다. 즉 policy 판단이 잘못된 document에 묶여 있었던 셈이고, policy bypass로 이어지는 logic error에 해당합니다.

  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

누락되었던 invariant는 다음과 같습니다. Inline style 선언은 최종적으로 이를 렌더링할 context의 policy를 통과했을 때만 허용되어야 합니다. Inert document는 creator document로 향하는 content를 잠시 담아 두는 staging 영역입니다. 따라서 inert document 자신의 policy는 판단 기준이 될 수 없습니다.

테스트가 violation event를 window에서 기대하는 이유도 여기에 있습니다. 이제 검사가 creator document의 ContentSecurityPolicy 객체를 기준으로 수행되므로, report도 해당 live document를 통해 전달됩니다. policyDocument->url() 인자는 report에 기록되는 document URL만 지정할 뿐, event가 dispatch되는 위치를 결정하지는 않습니다. 기존 코드에서는 inert document의 policy가 violation을 전혀 생성하지 않았습니다. Memory corruption은 발생하지 않으며, 결과는 순수하게 policy 적용 실패에 그칩니다.

이 bypass는 웹 콘텐츠에서 도달 가능하지만, 단독으로는 성립하지 않습니다. DOMParser, createHTMLDocument, <template>을 거쳐 markup을 준비하는 HTML injection sink가 이미 존재해야 합니다. 이런 staging 패턴은 client-side sanitizer와 templating 코드에서 흔히 사용됩니다. 확보되는 primitive는 CSP가 style attribute를 금지한 페이지에서, attacker가 주입한 element에 임의의 inline CSS를 적용하는 능력입니다. 영향의 상한은 해당 element에 대한 CSS 수준의 시각적 제어이며, layout, 위치, 표시 여부, overlay 등이 여기에 포함됩니다. 한편 background-image URL처럼 CSS가 유발하는 fetch는 여전히 img-src 같은 directive의 적용을 받습니다. Process 경계를 넘는 영향은 없습니다.

남은 질문들은 주변 코드에 있습니다. WebCore에서 parse 시점에 policy를 판단하면서 contextDocument() 대신 this->document()를 참조하는 코드가 있다면, 같은 형태의 문제를 갖습니다. 또한 creator를 기록하지 않는 inert document를 거치는 staging 경로가 존재할 수 있습니다. 이 경우 contextDocument()가 inert document 자신의 policy로 fallback하게 되고, bypass가 다시 나타날 수 있습니다. 유지된 AttributeModificationReason::ByCloning short-circuit도 따져볼 필요가 있습니다. 이 경로는 복제 원본이 모두 올바른 policy로 검사되었을 때만 안전합니다. 따라서 creator가 기록되지 않은 inert document에서 parse된 원본을 페이지로 clone하는 경우는 별도 테스트로 확인해볼 만합니다.

이번 문제는 HTML injection을 억제하기 위한 defense-in-depth 계층, 즉 CSP의 inline style 제한을 약화합니다. CSP의 policy 모델은 style-src-attr 'none'을 선언한 페이지가 markup의 유입 경로와 관계없이 작성자가 제공한 style attribute를 렌더링하지 않는다고 가정합니다. 이론적으로 얻을 수 있는 이득은 시각적인 범위에 머뭅니다. UI redressing, content spoofing, 페이지 요소를 덮거나 숨기는 방식이 이에 해당합니다.

Insight: client-side sanitizer와 templating library는 신뢰할 수 없는 markup을 inert document에서 parse한 뒤 node를 live page로 옮기는 경우가 많습니다. 이번 버그가 다루는 세 경로가 정확히 이 흐름입니다. 이런 sanitizer가 style attribute를 통과시켰을 때 마지막 방어선 역할을 하는 것이 CSP입니다. 그런데 fix 이전에는 이 경로들에서 해당 방어선이 아무런 신호 없이 적용되지 않았습니다. Parse 시점이나 attribute 설정 시점에 미리 수행되는 검사는 판단 결과를 내부 상태(m_inlineStyle)에 고정합니다. 그래서 그 순간 어떤 document를 참조하는지가 보안상 핵심이 됩니다. Inert document에 어떤 document의 policy를 적용할지에 대해 WebKit이 정립해 둔 답이 contextDocument()입니다.