완료됨
50 코인 CSP - Strict Dynamic 신뢰전파 범위 질문
  <script nonce={{ nonce }}>
    window.addEventListener("load", function() {
      var name_elem = document.getElementById("name");
      name_elem.innerHTML = `${location.hash.slice(1)} is my name !`;
    });
 </script>
 <script id="name"></script>

위 코드는 질문하기 쉽게 문제와 유사하게 만든 코드입니다.

위 코드에서 nonce를 가진 script(CSP에 의해 신뢰된 스크립트)를 A라 두고
id=name 인 script(CSP에 의해 신뢰되지 않은 스크립트)를 B라 지칭하겠습니다.

A 스크립트는 nonce를 가지고 있어 CSP에 의해 허용된 스크립트입니다. 하지만 B 스크립트는 CSP의 화이트리스트를 통과하지 못해 차단 되어야 합니다.

A 스크립트가 B 스크립트의 내용을 InnerHTML을 통해 수정하였을 때 B스크립트에 내용을 넣어줍니다. HTML Standard 명세에 따라 내용이 비어있어서 실행되지 않았던 script는 요소가 변형되면 실행된다는 것은 알 수 있었습니다. 하지만 수정된 B 스크립트의 코드가 실행된다 하여도 CSP를 통과하는 것은 다른 문제입니다.

관련 명세나 문서를 아무리 찾아봐도 Strict Dynamic은 신뢰된 스크립트가 동적으로 생성하거나 로드한 script만 허용한다는 말 밖에 없습니다.

위 경우는 동적으로 생성한 것도 아니고, 로드한 것도 아닌, 기존의 B스크립트의 내용만을 생성했을 뿐이며, B스크립트를 따로 호출하지도 않습니다. 그렇다면 A가 삽입한 코드이지만 B스크립트 내에서 실행 될텐데 신뢰 전파의 영향을 받는 정확한 이유가 궁급합니다.

#csp #script #dom
작성자 정보
더 깊이 있는 답변이 필요할 때
드림핵 팀과 멘토에게 직접 문의해 보세요!
답변 1
질문자가 채택한 답변입니다. 좋은 지식을 공유해줘서 고마워요!
avatar
qpal
대표 업적 없음
avatar
qpal
대표 업적 없음

질문을 보고 저도 궁금해져서 찾아봤습니다.
우선 스펙에서 스크립트 준비에 관한 prepare-the-script-element 섹션은 chromium의 blink에서 script_loader.ccPrepareScript 함수에 구현되어 있습니다. 스크립트 태그에 src도 없고 비어있는 경우는 6번 스텝에 의해 종료되어 parser_inserted_ 멤버 변수가 false인 상태로 있게 됩니다.

  // step 3
  parser_inserted_ = false;
  ...
  // step 6
  if (!element_->HasSourceAttribute() && source_text.empty()) {
    return nullptr;
  }

이후에 innerHTML이 할당되면 이 함수가 다시 실행되고 18번 스텝에서 스크립트 요소 클래스의 AllowInlineScriptForCSP 함수를 호출하여 CSP를 확인합니다.

  // step 18
  if (!element_->HasSourceAttribute() &&
      (!element_->AllowInlineScriptForCSP(element_->GetNonceForElement(),
                                          position.line_, source_text) ||
       !SubresourceIntegrity::VerifyInlineIntegrity(
           element_->IntegrityAttributeValue(),
           element_->SignatureAttributeValue(), source_text,
           element_->GetExecutionContext()))) {
    return nullptr;
  }

다음 순서대로 함수가 실행되고 AllowInlineScriptForCSP -> AllowInline ->
CSPDirectiveListAllowInline 최종적으로 CSPDirectiveListAllowInline에서 다음과 같은 코드가 있습니다. 이전에 parser_inserted_가 false였기 때문에 !html_script_element->Loader()->IsParserInserted() 조건은 통과합니다.

  if (html_script_element &&
      (inline_type == ContentSecurityPolicy::InlineType::kScript ||
       inline_type ==
           ContentSecurityPolicy::InlineType::kScriptSpeculationRules) &&
      !html_script_element->Loader()->IsParserInserted() &&
      CSPDirectiveListAllowDynamic(csp, type)) {
    return true;
  }

CSPDirectiveListAllowDynamic 함수는 내부적으로 CheckDynamic 함수를 호출하고 이 함수는 allow_dynamic 멤버 변수를 사용합니다.

bool CheckDynamic(const network::mojom::blink::CSPSourceList* directive,
                  CSPDirectiveName effective_type) {
  // 'strict-dynamic' only applies to scripts
  if (effective_type != CSPDirectiveName::ScriptSrc &&
      effective_type != CSPDirectiveName::ScriptSrcV2 &&
      effective_type != CSPDirectiveName::ScriptSrcAttr &&
      effective_type != CSPDirectiveName::ScriptSrcElem &&
      effective_type != CSPDirectiveName::WorkerSrc) {
    return false;
  }
  return !directive || directive->allow_dynamic;
}

content_security_policy.cc에서 strict-dynamic 지시문이 있을 때 이 멤버 변수가 true가 되기 때문에 결국 스크립트가 실행됩니다.

    if (base::EqualsCaseInsensitiveASCII(expression, "'strict-dynamic'")) {
      directive->allow_dynamic = true;
      continue;
    }

만약 nonce 없이 처음부터 인라인 스크립트를 실행하는 경우 script_loader.cc에서 parser_inserted_ 멤버 변수가 true가 되고 2번 스텝, 12번 스텝에 의해 parser_inserted_가 true인 상태로 CSPDirectiveListAllowInline 함수를 호출하여 실행이 실패합니다.

  if (flags.IsCreatedByParser()) {
    ...
    parser_inserted_ = true;
    ...
  }

결론적으로 질문하신 상황에서 스크립트가 실행되는 이유는 이것이 스펙을 따르는 의도한 동작이며 크로미움에서 이를 그대로 구현했기 때문입니다.

2025.10.21. 02:57