HTTP/Guides/Content Security Policy (CSP)

김동현·2026년 3월 22일

안녕하세요! 오늘 살펴볼 주제는 프론트엔드 보안의 핵심이자 방패 역할을 하는 '콘텐츠 보안 정책(Content Security Policy, CSP)'입니다.

악성 스크립트가 내 웹사이트에서 실행되는 것을 막고, 외부 자원을 안전하게 관리하려면 이 CSP 설정이 필수적이죠. 나중에 실무에서 웹사이트의 보안 취약점을 점검하거나 최적화할 때 CSP를 얼마나 잘 이해하고 있느냐가 프론트엔드 개발자의 역량을 보여주는 중요한 지표가 될 수 있습니다. 원본 문서를 바탕으로 쉽게 풀어 설명해 드릴게요!


콘텐츠 보안 정책 (Content Security Policy, CSP)

콘텐츠 보안 정책(CSP)은 특정 유형의 보안 위협을 예방하거나 그 위험을 최소화하는 데 도움을 주는 기능입니다. CSP는 웹사이트가 브라우저에게 내리는 일련의 지시어(instructions)들로 구성되며, 이 지시어들은 브라우저에게 "우리 사이트를 구성하는 코드가 할 수 있는 일들에 제한을 두어라"라고 명령하는 역할을 합니다.

CSP의 주된 사용 목적은 문서가 로드하도록 허용되는 리소스(resource), 그중에서도 특히 JavaScript 리소스를 제어하는 것입니다. 이는 주로 크로스 사이트 스크립팅(Cross-site scripting, XSS) 공격에 대한 방어책으로 사용되는데, XSS 공격이란 해커가 피해자의 사이트에 악성 코드를 주입하는 것을 말합니다.

CSP는 이외에도 여러 다른 목적을 가질 수 있습니다. 클릭재킹(clickjacking) 방어나 사이트의 모든 페이지가 안전한 HTTPS를 통해 로드되도록 보장하는 것 등이 포함됩니다.

이 가이드에서는 먼저 CSP가 브라우저에 어떻게 전달되는지, 그리고 그 전체적인 구조가 어떻게 생겼는지부터 설명하겠습니다.

그다음, CSP가 다음의 목적들을 위해 어떻게 사용될 수 있는지 설명하겠습니다:
1. 리소스 로드 제어하기 (Control which resources are loaded) - XSS 공격 방어
2. 임베딩 제한하기 (Restrict embedding) - 클릭재킹 방어
3. 안전하지 않은 요청 업그레이드하기 (Upgrade insecure requests) - 모든 리소스가 HTTPS를 통해 제공되도록 보장
4. 신뢰할 수 있는 타입의 사용 강제하기 (Require the use of trusted types) - 클라이언트 사이드 XSS 방어

참고로 이 각각의 사용 사례들은 서로 독립적입니다. 만약 클릭재킹 방어 기능만 추가하고 XSS 완화 기능은 원하지 않는다면, 딱 그 목적에 맞는 지시어만 추가하면 됩니다.

마지막으로, CSP를 배포하기 위한 전략과 이 과정을 더 쉽게 만들어 줄 수 있는 도구들에 대해 설명하겠습니다.


이 문서의 내용 (In this article)


CSP 개요 (CSP overview)

CSP는 브라우저에게 Content-Security-Policy라는 HTTP 응답 헤더를 통해 전달되어야 합니다. 메인 HTML 문서뿐만 아니라 모든 요청에 대한 모든 응답에 이 헤더가 설정되어 있어야 합니다.

HTML 문서의 <meta> 요소에 http-equiv 속성을 사용하여 CSP를 지정할 수도 있습니다. 이는 정적 리소스만 존재하는 클라이언트 사이드 렌더링(CSR) 기반의 싱글 페이지 앱(SPA) 같은 특정 상황에서 매우 유용한 옵션입니다. 서버 인프라에 의존하지 않고도 설정이 가능하기 때문이죠. 하지만 이 방법은 CSP의 모든 기능을 지원하지는 않습니다.

💡 강사의 팁: Next.js로 개발하실 때 next.config.js에서 headers 설정을 통해 CSP를 직접 지정해 줄 수 있습니다. 실무에서는 보통 이렇게 서버(또는 프레임워크) 단에서 HTTP 헤더로 내려주는 방식을 훨씬 많이 씁니다!

보안 정책은 세미콜론(;)으로 구분된 일련의 지시어(directives)들로 지정됩니다. 각각의 지시어는 보안 정책의 서로 다른 측면을 제어합니다. 각 지시어는 이름(name)을 가지고 있으며, 그 뒤에 공백(space) 하나가 오고, 그 뒤에 값(value)이 따라옵니다. 지시어의 종류에 따라 문법이 다를 수 있습니다.

예를 들어, 다음과 같은 CSP를 살펴봅시다:

Content-Security-Policy: default-src 'self'; img-src 'self' example.com

이 CSP는 두 개의 지시어를 설정하고 있습니다:

  • default-src 지시어는 'self'로 설정되었습니다.
  • img-src 지시어는 'self' example.com으로 설정되었습니다.

A CSP broken into its directives.

첫 번째 지시어인 default-src는, 다른 특정 리소스 타입들에 대해 더 구체적인 정책이 설정되어 있지 않은 한, 해당 문서와 동일한 출처(same-origin, 'self')를 가진 리소스만 로드하라고 브라우저에 지시합니다. 두 번째 지시어인 img-src는 이미지만큼은 동일 출처('self')이거나 example.com에서 제공되는 것만 로드하도록 지시합니다.

다음 섹션에서는 CSP의 주된 기능인 리소스 로드 제어를 위해 사용할 수 있는 도구들을 살펴보겠습니다.


리소스 로딩 제어하기 (Controlling resource loading)

CSP는 문서가 어떤 리소스를 로드할 수 있는지 제어하는 데 사용될 수 있습니다. 이 기능은 주로 크로스 사이트 스크립팅(XSS) 공격을 막기 위해 사용됩니다.

이 섹션에서는 먼저 리소스 로드를 제어하는 것이 어떻게 XSS 공격을 막는 데 도움이 되는지 살펴보고, 그다음 CSP가 제공하는 제어 도구들을 알아보겠습니다. 마지막으로 "엄격한 CSP(Strict CSP)"라고 불리는 특정 권장 전략을 소개하겠습니다.

XSS와 리소스 로딩 (XSS and resource loading)

크로스 사이트 스크립팅(XSS) 공격이란 공격자가 타깃 웹사이트의 문맥 안에서 자신의 코드를 실행할 수 있게 되는 해킹 방식입니다. 이 악성 코드는 웹사이트의 원래 코드가 할 수 있는 모든 일을 똑같이 할 수 있게 되는데, 예를 들면 다음과 같은 일들입니다:

  • 사이트에 로드된 페이지의 내용(content)에 접근하거나 수정
  • 로컬 스토리지(local storage)의 내용에 접근하거나 수정
  • 사용자의 권한(credentials)을 가지고 HTTP 요청을 보내, 사용자 본인인 것처럼 행세하거나 민감한 데이터에 접근

XSS 공격은 웹사이트가 공격자가 정교하게 조작한 입력값(예: URL 파라미터나 블로그 게시물의 댓글 등)을 받아들이고, 이를 검증(sanitizing)하지 않은 채 페이지에 그대로 포함시킬 때 발생할 수 있습니다. 즉, 그 입력값이 JavaScript로 실행될 수 없도록 확실히 막는 처리를 하지 않은 것이죠.

웹사이트는 입력값을 페이지에 포함시키기 전에 반드시 이를 검증(sanitize)하여 XSS로부터 자신을 보호해야 합니다.

참고 (Note):
CSP는 사실 두 가지 다른 방식으로 XSS를 방어하는 데 도움을 줍니다:

  • 클라이언트에서 입력값이 사용되기 전에 검증되도록 보장하는 데 도움을 줍니다: 이에 대해서는 나중에 신뢰할 수 있는 타입 강제 (Requiring trusted types) 섹션에서 다룹니다.
  • 리소스 로드를 제어함으로써 CSP는 XSS에 대한 심층적인 방어(defense in depth)를 제공하여, 설령 검증(sanitization)이 실패하더라도 웹사이트를 보호합니다. 이 섹션에서 다룰 XSS 방어법이 바로 이것입니다.

검증이 실패할 경우, 문서에 주입된 악성 코드는 다음과 같은 여러 가지 형태를 띨 수 있습니다:

  • 악의적인 소스를 링크하는 <script> 태그:
    <script src="[https://evil.example.com/hacker.js](https://evil.example.com/hacker.js)"></script>
  • 인라인(inline) JavaScript를 포함하는 <script> 태그:
    <script>
      console.log("해킹당했습니다!");
    </script>
  • 인라인 이벤트 핸들러:
    <img
      onmouseover="console.log(`해킹당했습니다!`)"
      src="thumbnail.jpg"
      alt="" />
  • javascript: URL:
    <iframe src="javascript:console.log(`해킹당했습니다!`)"></iframe>
  • eval()과 같이 안전하지 않은 API에 문자열 인자로 전달:
    eval("console.log(`해킹당했습니다!`)");

리소스 로딩을 제어함으로써 CSP는 이 모든 위협들로부터 사이트를 방어할 수 있습니다. CSP를 사용하면 다음을 할 수 있습니다:

  • JavaScript 파일과 기타 리소스들이 허용되는 출처를 정의하여, https://evil.example.com과 같은 출처로부터 로드되는 것을 효과적으로 차단합니다.
  • 인라인 스크립트 태그를 비활성화합니다.
  • 올바른 논스(nonce)나 해시(hash)가 설정된 스크립트 태그만 허용합니다.
  • 인라인 이벤트 핸들러를 비활성화합니다.
  • javascript: URL을 비활성화합니다.
  • eval()과 같은 위험한 API를 비활성화합니다.

다음 섹션에서는 이러한 작업들을 수행하기 위해 CSP가 제공하는 도구들을 살펴보겠습니다.

참고 (Note):
CSP를 설정하는 것이 입력값 검증(sanitizing input)을 대체할 수는 없습니다. 웹사이트는 입력값을 검증하는 것 더하기 CSP를 설정하는 것을 함께 적용하여 XSS에 대해 다중 방어막(defense in depth)을 구축해야 합니다.

Fetch 지시어 (Fetch directives)

Fetch 지시어는 문서가 로드하도록 허용된 특정 카테고리의 리소스(예: JavaScript, CSS 스타일시트, 이미지, 폰트 등)를 지정하는 데 사용됩니다.

리소스 타입에 따라 서로 다른 Fetch 지시어들이 있습니다. 예를 들어:

  • script-src는 JavaScript가 허용되는 출처를 설정합니다.
  • style-src는 CSS 스타일시트가 허용되는 출처를 설정합니다.
  • img-src는 이미지가 허용되는 출처를 설정합니다.

특별한 Fetch 지시어인 default-src는, 명시적으로 나열되지 않은 나머지 모든 리소스들에 대한 대체 정책(fallback policy)을 설정합니다.

Fetch 지시어들의 전체 목록은 참조 문서에서 확인할 수 있습니다.

각각의 Fetch 지시어는 단일 키워드인 'none'으로 지정되거나, 공백으로 구분된 하나 이상의 출처 표현식(source expressions)으로 지정됩니다. 여러 개의 출처 표현식이 나열된 경우: 나열된 방법들 중 단 하나라도 해당 리소스를 허용한다면, 그 리소스는 허용됩니다.

예를 들어, 아래의 CSP는 두 개의 Fetch 지시어를 설정합니다:

  • default-src에는 단일 출처 표현식인 'self'가 주어집니다.
  • img-src에는 두 개의 출처 표현식인 'self'example.com이 주어집니다.

CSP diagram showing source expressions

이 설정의 효과는 다음과 같습니다:

  • 이미지는 문서와 동일한 출처('self')이거나 example.com에서 로드된 것이어야 합니다.
  • 나머지 다른 모든 리소스들은 문서와 반드시 동일한 출처('self')여야 합니다.

다음 몇 섹션에서는 리소스 로드를 제어하기 위해 출처 표현식을 사용하는 여러 가지 방법들을 설명하겠습니다. 비록 여기서는 각각 따로 설명하지만, 이 표현식들은 일반적으로 함께 조합될 수 있습니다: 예를 들어, 하나의 Fetch 지시어에 논스(nonces)와 호스트 이름(hostnames)이 모두 포함될 수 있습니다.

리소스 차단하기 (Blocking resources)

어떤 리소스 타입을 완전히 차단하려면 'none' 키워드를 사용하세요. 예를 들어, 다음 지시어는 모든 <object><embed> 리소스를 차단합니다:

Content-Security-Policy: object-src 'none'

특정 지시어 안에서 'none'은 다른 어떤 방법과도 조합될 수 없습니다. 실제로는 'none' 옆에 다른 출처 표현식이 함께 주어지면, 그것들은 무시됩니다.

논스 (Nonces)

nonce<script><style> 리소스의 로드를 제한하기 위해 가장 권장되는 접근 방식입니다.

논스를 사용할 때, 서버는 모든 HTTP 응답마다 매번 새롭게 임의의 값(random value)을 생성하고, 이를 script-srcstyle-src 지시어에 포함시킵니다:

Content-Security-Policy:
  script-src 'nonce-416d1177-4d12-4e3b-b7c9-f6c409789fb8'

그런 다음 서버는 이 값을 문서에 포함시키려는 모든 <script><style> 태그의 nonce 속성 값으로 삽입합니다.

브라우저는 이 두 값을 비교하여 일치할 때만 리소스를 로드합니다. 그 아이디어는 해커가 페이지에 JavaScript를 삽입할 수는 있어도, 서버가 이번 요청에 어떤 논스를 사용할지 미리 알 수는 없기 때문에 브라우저가 해당 악성 스크립트의 실행을 거부하게 만든다는 것입니다.

이 방식이 제대로 동작하려면, 공격자가 논스 값을 추측할 수 없어야 합니다.

실제로는 이는 논스가 모든 HTTP 응답마다 다르게 매번 생성되어야 하며, 예측할 수 없는 값이어야 한다는 뜻입니다.

이는 곧 서버가 단순히 정적인 HTML을 그대로 제공할 수 없음을 의미합니다. 매번 새로운 논스를 끼워 넣어야 하기 때문이죠. 일반적으로 서버는 템플릿 엔진을 사용하여 논스를 삽입하게 됩니다.

다음은 이를 시연하는 Express 코드 조각입니다:

function content(nonce) {
  return `
    <script nonce="${nonce}" src="/main.js"></script>
    <script nonce="${nonce}">console.log("hello!");</script>
    <h1>Hello world</h1> 
    `;
}

app.get("/", (req, res) => {
  const nonce = crypto.randomUUID();
  res.setHeader("Content-Security-Policy", `script-src 'nonce-${nonce}'`);
  res.send(content(nonce));
});

매 요청마다 서버는 새로운 논스를 생성하고, 이를 CSP와 반환할 문서의 <script> 태그에 각각 삽입합니다. 서버의 동작을 주목해 보세요:

  • 매 요청마다 새로운 논스를 생성합니다.
  • 외부 스크립트 파일(src)과 인라인 스크립트 모두에 논스를 사용할 수 있습니다.
  • 문서 내의 모든 <script> 태그에 동일한 논스를 사용합니다.

서버가 모든 <script> 태그에 그냥 무작정 논스를 끼워 넣지 말고, 템플릿 등을 사용하여 '자신이 의도한 태그에만' 논스를 삽입하는 것이 매우 중요합니다. 그렇지 않으면 서버가 공격자에 의해 주입된 악성 스크립트 태그에까지 실수로 논스를 달아줄 수도 있기 때문입니다.

참고로 논스는 nonce 속성을 가질 수 있는 요소, 즉 <script><style> 요소에만 사용할 수 있습니다.

해시 (Hashes)

Fetch 지시어는 무결성을 보장하기 위해 스크립트 내용 자체의 해시(hash) 값을 사용할 수도 있습니다. 이 방법에서 서버는 다음과 같이 동작합니다:

  1. 해시 함수 (hash function) (SHA-256, SHA-384, SHA-512 중 하나)를 사용하여 스크립트 내용의 해시를 계산합니다.
  2. 결과값을 Base64 인코딩으로 변환합니다.
  3. 사용된 해시 알고리즘을 식별하는 접두사(sha256-, sha384-, sha512- 중 하나)를 붙입니다.

그리고 그 결과를 지시어에 추가합니다:

Content-Security-Policy: script-src 'sha256-cd9827ad...'

브라우저가 문서를 받으면, 스크립트의 내용을 해싱해 보고, 그 결과를 헤더에 있는 값과 비교하여 일치할 때만 스크립트를 로드합니다.

외부 스크립트 파일의 경우, 이 방법이 작동하려면 <script> 태그에 반드시 integrity 속성도 포함되어야 합니다.

다음을 시연하는 Express 코드 조각을 살펴보세요:

const hash1 = "sha256-ex2O7MWOzfczthhKm6azheryNVoERSFrPrdvxRtP8DI=";
const hash2 = "sha256-H/eahVJiG1zBXPQyXX0V6oaxkfiBdmanvfG9eZWSuEc=";

const csp = `script-src '${hash1}' '${hash2}'`;
const content = `
  <script src="./main.js" integrity="${hash2}"></script>
  <script>console.log("hello!");</script>
    <h1>Hello world</h1> 
    `;

app.get("/", (req, res) => {
  res.setHeader("Content-Security-Policy", csp);
  res.send(content);
});

💡 강사의 팁: 해시 방식은 매번 랜덤한 값을 생성해야 하는 nonce 방식과 달리, 파일의 내용이 변하지 않으면 해시값도 똑같습니다! 그래서 Next.js의 SSG(Static Site Generation)나 React의 정적 배포처럼 서버에서 동적으로 HTML을 변경해 줄 수 없을 때 이 '해시' 방식이 정말 요긴하게 쓰인답니다.

참고 사항:

  • 문서 안의 각각의 스크립트마다 별도의 해시 값을 가지고 있습니다.
  • 외부 스크립트인 "main.js"의 경우, integrity 속성을 포함하고 동일한 해시값을 부여했습니다.
  • 논스를 사용하는 예제와 달리, 해시값은 변하지 않기 때문에 CSP와 콘텐츠(HTML) 모두 완전히 정적인(static) 상태로 제공될 수 있습니다. 이 점 때문에 해시 기반 정책은 정적 페이지나 클라이언트 사이드 렌더링(CSR)에 의존하는 웹사이트에 훨씬 적합합니다.

스킴 기반 정책 (Scheme-based policies)

Fetch 지시어는 https:와 같은 스킴(scheme)을 나열하여 해당 스킴으로 제공되는 리소스만 허용할 수 있습니다. 예를 들어, 이를 통해 모든 리소스 로드에 HTTPS를 강제하는 정책을 만들 수 있습니다:

Content-Security-Policy: default-src https:

위치 기반 정책 (Location-based policies)

Fetch 지시어는 리소스가 위치한 장소(도메인 등)를 기반으로 리소스 로드를 제어할 수 있습니다.

'self' 키워드는 문서 자체와 동일한 출처(same-origin)에 있는 리소스들을 허용합니다:

Content-Security-Policy: img-src 'self'

하나 이상의 호스트 이름(필요시 와일드카드 * 포함)을 지정할 수도 있으며, 오직 그 호스트들에서 제공되는 리소스만 허용됩니다. 예를 들어, 신뢰할 수 있는 CDN에서 콘텐츠를 제공할 수 있도록 허용할 때 이 방법을 쓸 수 있습니다.

Content-Security-Policy: img-src *.example.org

여러 개의 위치를 지정할 수도 있습니다. 다음 지시어는 현재 문서와 동일한 출처이거나, "example.org"의 하위 도메인이거나, "example.com"에서 제공되는 이미지만 허용합니다:

Content-Security-Policy: img-src 'self' *.example.org  example.com

인라인 자바스크립트 (Inline JavaScript)

만약 CSP에 default-srcscript-src 지시어가 포함되어 있다면, 이를 활성화하기 위한 특별한 조치를 취하지 않는 한 인라인 JavaScript의 실행은 허용되지 않습니다. 이 제한은 다음과 같은 경우를 포함합니다:

  • 페이지 안의 <script> 요소 내부에 포함된 JavaScript:
    <script>
      console.log("인라인 스크립트에서 인사드립니다.");
    </script>
  • 인라인 이벤트 핸들러 속성 내의 JavaScript:
    <img src="x" onerror="console.log('인라인 이벤트 핸들러에서 인사드립니다.')" />
  • javascript: URL 안의 JavaScript:
    <a href="javascript:console.log('javascript: URL에서 인사드립니다.')">Click me</a>

이러한 제한을 무시(override)하고 허용하려면 'unsafe-inline' 키워드를 사용할 수 있습니다. 예를 들어, 다음 지시어는 모든 리소스가 동일 출처여야 함을 요구하지만, 인라인 JavaScript만큼은 허용합니다:

Content-Security-Policy: default-src 'self' 'unsafe-inline'

경고 (Warning):
개발자는 'unsafe-inline'의 사용을 피해야 합니다. 왜냐하면 이는 CSP를 도입하는 근본적인 목적 자체를 무색하게 만들기 때문입니다. 인라인 JavaScript는 가장 흔한 XSS 공격 경로 중 하나이며, CSP의 가장 기본적인 목표 중 하나가 바로 이 인라인 스크립트가 통제 없이 무분별하게 사용되는 것을 막는 것입니다.

인라인 <script> 요소는 앞서 설명한 대로 논스나 해시로 보호될 경우에만 허용됩니다.
만약 지시어에 논스나 해시 표현식이 포함되어 있다면, 브라우저는 'unsafe-inline' 키워드를 발견하더라도 이를 완전히 무시해 버립니다.

eval()과 유사한 API들

인라인 JavaScript와 마찬가지로, CSP에 default-srcscript-src 지시어가 포함되어 있다면 eval()과 그 비슷한 API들의 실행도 기본적으로 허용되지 않습니다. 여기에는 다른 API들과 더불어 다음 사항들이 포함됩니다:

  • eval() 그 자체:
    eval('console.log("hello from eval()")');
  • Function() 생성자:
    const sum = new Function("a", "b", "return a + b");
  • setTimeout()setInterval()에 문자열(string)을 인자로 전달하는 경우:
    setTimeout("console.log('hello from setTimeout')", 1);

이러한 동작을 무시하고 허용하려면 'unsafe-eval' 키워드를 사용할 수 있습니다. 하지만 'unsafe-inline'과 마찬가지의 이유로, 개발자는 'unsafe-eval'의 사용을 피해야 합니다.

때로는 코드에서 eval()이나 다른 메서드의 사용을 제거(리팩토링)하는 것이 매우 어려울 수 있습니다. 이런 상황에서는 Trusted Types API를 도입하여, 입력값이 사전에 정의된 정책을 통과하도록 보장함으로써 이를 더 안전하게 만들 수 있습니다.
이 경우 브라우저의 기본 차단 동작을 무시하기 위해 'trusted-types-eval' 키워드를 사용해야 합니다.
unsafe-inline과 달리, 이는 브라우저가 Trusted Types 기능을 지원하고 활성화된 경우에만 브라우저의 기본 동작을 덮어씁니다. 따라서 Trusted Types를 지원하지 않는 브라우저에서는 여전히 저 위험한 메서드들이 안전하게 차단된 상태로 남게 됩니다.

unsafe-inline과 또 다르게, 'unsafe-eval' 키워드는 지시어 안에 논스나 해시 표현식이 같이 포함되어 있더라도 무시되지 않고 여전히 동작합니다.

엄격한 CSP (Strict CSP)

XSS에 대한 방어책으로서 스크립트 로드를 제어할 때 가장 권장되는 방식은 논스해시 기반의 Fetch 지시어를 사용하는 것입니다. 이를 엄격한 CSP(Strict CSP)라고 부릅니다. 이러한 방식의 CSP는 위치 기반 CSP(일반적으로 허용 목록(allowlist) CSP라 불림)보다 크게 두 가지 측면에서 이점이 있습니다:

논스(nonce)를 기반으로 한 엄격한 CSP는 다음과 같이 생겼습니다:

Content-Security-Policy:
  script-src 'nonce-{RANDOM}';
  object-src 'none';
  base-uri 'none';

이 CSP에서 우리는:

  • 어떤 JavaScript 리소스가 로드되도록 허용할지 제어하기 위해 논스를 사용합니다.
  • 모든 객체(object) 임베딩을 차단합니다.
  • 베이스 URI(기본 주소)를 설정하기 위한 <base> 요소의 모든 사용을 차단합니다.

해시 기반의 엄격한 CSP도 이와 똑같지만, 논스 대신 해시를 사용한다는 점만 다릅니다:

Content-Security-Policy:
  script-src 'sha256-{HASHED_SCRIPT}';
  object-src 'none';
  base-uri 'none';

콘텐츠 자체를 포함하여 응답(HTML 등)을 동적으로 생성할 수 있는 환경이라면 논스 기반의 지시어가 유지보수하기 더 편합니다. 동적 생성이 불가능하다면 해시 기반 지시어를 사용해야 하죠. 해시 기반 지시어의 단점은, 스크립트의 내용이 아주 조금이라도 수정되면 무조건 해시값을 다시 계산해서 새롭게 정책에 적용해 주어야 한다는 점입니다.

'strict-dynamic' 키워드

위에서 설명한 엄격한 CSP는, 여러분이 직접 통제할 수 없는 '외부 스크립트'를 사용할 때 적용하기가 무척 어렵습니다. 만약 여러분이 삽입한 외부 서드파티 스크립트가 내부적으로 또 다른 스크립트들을 로드하거나 인라인 스크립트를 사용하려 한다면, 서드파티 스크립트가 그들에게 논스나 해시를 넘겨주지 않을 것이기 때문에 이 과정은 에러와 함께 실패하게 됩니다.

'strict-dynamic' 키워드는 바로 이 문제를 해결하기 위해 제공됩니다. 이 키워드는 Fetch 지시어에 포함시킬 수 있으며, 이를 적용하면 어떤 스크립트가 논스나 해시를 부여받아 신뢰를 얻었다면, 그 스크립트가 로드하는 또 다른 스크립트들은 굳이 논스나 해시가 없어도 로드되도록 허용해 주는 효과를 가집니다. 즉, 논스나 해시에 의해 처음 부여된 신뢰(trust)가 원래 스크립트가 로드하는 다른 스크립트들로(그리고 그 스크립트들이 로드하는 또 다른 스크립트들로, 계속해서) 전파된다는 뜻입니다.

예를 들어, 다음과 같은 문서를 생각해 봅시다:

<html lang="en-US">
  <head>
    <script
      src="./main.js"
      integrity="sha256-gEh1+8U9S1vkEuQSmmUMTZjyNSu5tIoECP4UXIEjMTk="></script>
  </head>
  <body>
    <h1>Example page!</h1>
  </body>
</html>

이 문서는 "main.js"라는 스크립트를 포함하고 있는데, "main.js"는 내부적으로 "main2.js"라는 또 다른 스크립트를 동적으로 생성해서 추가하고 있습니다:

console.log("hello");

const scriptElement = document.createElement("script");
scriptElement.src = `main2.js`;

document.head.appendChild(scriptElement);

이 상태에서 다음과 같은 CSP를 적용해서 문서를 서비스해 보겠습니다:

Content-Security-Policy:
  script-src 'sha256-gEh1+8U9S1vkEuQSmmUMTZjyNSu5tIoECP4UXIEjMTk='

"main.js" 스크립트는 자체 해시값이 CSP의 값과 일치하므로 로드되도록 허용됩니다. 하지만 "main.js"가 시도하는 "main2.js" 로드는 CSP 위반으로 실패하고 맙니다.

만약 CSP에 'strict-dynamic'을 추가해주면, "main.js"는 "main2.js"를 무사히 로드할 수 있게 됩니다:

Content-Security-Policy:
  script-src 'sha256-gEh1+8U9S1vkEuQSmmUMTZjyNSu5tIoECP4UXIEjMTk='
  'strict-dynamic'

'strict-dynamic' 키워드는 논스나 해시 기반의 CSP를 작성하고 유지보수하는 것을 훨씬 쉽게 만들어 주며, 특히 서드파티 스크립트(구글 애널리틱스, 페이스북 픽셀 등)를 사용할 때 그 진가를 발휘합니다. 하지만 이 키워드를 쓰면 CSP의 보안성이 다소 약해지는 것은 사실입니다. 왜냐하면 여러분이 포함시킨 그 서드파티 스크립트가 만약 잠재적인 XSS 취약점을 통해 <script> 요소를 생성한다면, 그 악성 스크립트까지 덩달아 신뢰를 얻게 되어 CSP가 그것들을 막아주지 못하기 때문입니다.

인라인 자바스크립트 및 eval() 리팩토링하기

위에서 보았듯이 CSP에서는 인라인 JavaScript가 기본적으로 차단됩니다. 개발자는 논스나 해시를 사용하여 인라인 <script> 태그를 사용할 수는 있지만, 그 외에 허용되지 않는 패턴들(인라인 이벤트 핸들러, javascript: URL, eval()의 사용 등)은 반드시 코드를 고쳐서(리팩토링) 제거해야만 합니다. 예를 들어, 인라인 이벤트 핸들러는 대개 addEventListener()를 호출하는 방식으로 교체해야 합니다:

<p onclick="console.log('인라인 이벤트 핸들러에서 인사드립니다')">click me</p>
<p id="hello">click me</p>
<script>
  const hello = document.querySelector("#hello");
  hello.addEventListener("click", () => {
    console.log("인라인 스크립트에서 인사드립니다");
  });
</script>

클릭재킹 방어 (Clickjacking protection)

frame-ancestors 지시어는 <iframe>과 같은 중첩된 브라우징 컨텍스트(nested browsing context) 내에서 현재 문서를 포함(embed)할 수 있는 부모(조상) 문서가 과연 누구인지를 제어하는 데 사용됩니다. 이는 클릭재킹(clickjacking) 공격에 대한 매우 효과적인 방어책입니다. 클릭재킹 공격은 사용자를 속이기 위해 공격자가 통제하는 사이트 안에 몰래 타깃 사이트를 투명하게 임베딩하는 방식에 의존하기 때문입니다.

frame-ancestors의 문법은 Fetch 지시어의 문법 구조와 비슷합니다: 단일 키워드 값인 'none'을 제공하거나 하나 이상의 출처 표현식을 나열할 수 있습니다. 단, 여기서 사용할 수 있는 출처 표현식은 오직 스킴(schemes), 호스트 이름(hostnames), 그리고 'self' 키워드 값뿐입니다.

여러분의 사이트가 남의 사이트에 임베드될 필요가 전혀 없다면, frame-ancestors를 무조건 'none'으로 설정해야 합니다:

Content-Security-Policy: frame-ancestors 'none'

이 지시어는 구형 방식인 X-Frame-Options 헤더를 대체하는, 훨씬 더 유연하고 현대적인 기능입니다.


안전하지 않은 요청 업그레이드 (Upgrading insecure requests)

웹 개발자들은 모든 콘텐츠를 HTTPS를 통해 제공하는 것이 강력히 권장됩니다. 사이트를 HTTPS로 업그레이드하는 과도기적 과정에서, 메인 문서는 HTTPS로 제공되면서도 이미지 같은 리소스들은 예전처럼 HTTP를 통해 요청하게 되는 실수가 발생하곤 합니다. 예를 들어 다음과 같은 마크업을 말이죠:

<script src="[http://example.org/my-cat.js](http://example.org/my-cat.js)"></script>

이런 현상을 혼합 콘텐츠(mixed content)라고 부르며, 안전하지 않은 리소스가 단 하나라도 섞여 있으면 HTTPS가 제공하는 강력한 보안성이 크게 약화됩니다. 브라우저가 구현하고 있는 혼합 콘텐츠 알고리즘에 따르면, 문서가 HTTPS로 제공될 때 그 안에 섞인 안전하지 않은(HTTP) 리소스들은 "업그레이드 가능한 콘텐츠"와 "차단 가능한 콘텐츠"로 나뉩니다. 업그레이드 가능한 콘텐츠는 강제로 HTTPS로 자동 변환되어 로드되지만, 차단 가능한 콘텐츠는 말 그대로 차단되어 페이지가 깨지거나 오작동할 수 있습니다.

혼합 콘텐츠 문제에 대한 궁극적인 해결책은 개발자가 모든 리소스 URL을 HTTPS로 고쳐서 로드하게 만드는 것입니다. 하지만 사이트가 실제로 모든 콘텐츠를 HTTPS로 제공할 능력이 됨에도 불구하고, 개발자가 사이트 내부의 모든 오래된 소스코드에서 리소스를 로드하는 수많은 HTTP URL들을 일일이 다 찾아서 'https'로 고쳐 쓰는 것은 매우 힘든 일입니다 (특히 예전 자료가 쌓인 아카이브 콘텐츠 같은 경우는 사실상 불가능에 가깝죠).

upgrade-insecure-requests 지시어는 바로 이 문제를 아주 깔끔하게 해결하기 위해 만들어졌습니다. 이 지시어는 별도의 값이 필요 없습니다: 그냥 지시어 이름만 적어주면 알아서 세팅됩니다:

Content-Security-Policy: upgrade-insecure-requests

만약 이 지시어가 문서에 설정되어 있다면, 브라우저는 다음과 같은 경우에 HTTP URL들을 발견하면 자동으로 http:// 부분을 https://로 업그레이드하여 요청을 보냅니다:

  • 리소스(이미지, 스크립트, 폰트 등)를 로드하기 위한 요청들
  • 현재 문서와 동일 출처(same-origin)인 타깃으로의 내비게이션(이동) 요청들 (예: <a href="..."> 클릭)
  • iframe과 같이 중첩된 브라우징 컨텍스트 안에서의 내비게이션 요청들
  • 폼(form) 제출들

하지만 주의하세요. 타깃이 '다른 출처(cross-origin)'인 최상위 레벨(top-level) 내비게이션 요청(즉, 외부 사이트로 넘어가는 링크 클릭 등)은 업그레이드되지 않고 그냥 HTTP로 나갑니다.

예를 들어, https://example.org의 문서가 upgrade-insecure-requests 지시어를 포함한 CSP와 함께 제공되었다고 가정해 봅시다. 그리고 그 문서 안에는 다음과 같은 마크업이 있습니다:

<script src="[http://example.org/my-cat.js](http://example.org/my-cat.js)"></script>
<script src="[http://not-example.org/another-cat.js](http://not-example.org/another-cat.js)"></script>

브라우저는 이 두 스크립트 요청을 모두 자동으로 HTTPS로 업그레이드해서 안전하게 받아옵니다.

만약 문서에 이런 링크들도 있다고 가정해 볼까요:

<a href="[http://example.org/more-cats](http://example.org/more-cats)">고양이들 더 보러가기!</a>
<a href="[http://not-example.org/even-more-cats](http://not-example.org/even-more-cats)">다른 사이트에서 고양이 더 보기!</a>

브라우저는 첫 번째 링크는 동일 출처이므로 HTTPS로 업그레이드하지만, 두 번째 링크는 다른 출처(외부 도메인)로 내비게이션하는 것이므로 업그레이드하지 않습니다.

이 지시어는 Strict-Transport-Security 헤더(HSTS라고도 부름)를 대체하는 것이 아닙니다. 왜냐하면 이 지시어는 사이트 외부로 나가는 외부 링크들까지 강제로 업그레이드해주지는 않기 때문입니다. 웹사이트는 반드시 이 CSP 지시어와 함께 Strict-Transport-Security 헤더를 같이 적용해야 완벽한 HTTPS 강제가 가능합니다.


신뢰할 수 있는 타입 강제 (Requiring trusted types)

require-trusted-types-fortrusted-types 지시어는 클라이언트 사이드 크로스 사이트 스크립팅 (XSS) 공격에 대한 또 다른 강력한 방어책을 제공합니다. 이는 어떠한 외부 입력값이라도 위험한 웹 플랫폼 API(입력값을 그냥 코드로 실행해 버릴 위험이 있는 API)로 전달되기 전에, 반드시 이 입력값을 안전하게 만드는 특수한 변환 과정을 거치도록 브라우저 단에서 강제하는 기술입니다.
require-trusted-types-fortrusted-types 지시어를 함께 사용하면 웹사이트에 Trusted Types API를 강제로 적용할 수 있습니다.

인젝션 싱크와 검증 (Injection sinks and sanitization)

웹 플랫폼의 일부 API들은 인젝션 싱크(injection sinks, 악성 코드 주입 지점)라고 알려져 있습니다. 이것들은 보통 문자열(string) 형태로 입력값을 받아서, 그 입력값을 실제 코드인 것처럼 해석하고 실행해 버릴 수 있는 위험한 API들입니다. 이 가이드에서 앞서 보았던 eval()이 대표적이며, 그 외에도 Element.innerHTML이나 Document.write() 등 수많은 인젝션 싱크들이 존재합니다.

만약 공격자가 정교하게 조작한 입력값을 여러분의 웹사이트에 주입할 수 있고, 여러분의 웹사이트가 그 위험한 문자열을 무심코 이런 인젝션 싱크 중 하나로 전달해 버린다면, 공격자는 곧바로 웹사이트 내에서 자신의 악성 코드를 마음껏 실행할 수 있게 됩니다.

eval()과 같은 일부 인젝션 싱크는 애초에 안전하게 사용하기가 너무 어려워서, 일반적인 CSP는 보통 아예 이들의 사용을 완전히 차단해 버립니다. 하지만 또 다른 인젝션 싱크(innerHTML 등)는 만약 그들에게 전달되는 문자열 입력값이 안전하지 않은 요소들(예: <script> 태그)을 제거하는 처리 과정을 거친다면 비교적 안전하게 사용될 수 있습니다. 이러한 정화 과정을 검증(sanitization)이라고 부릅니다.

Trusted Types API (The Trusted Types API)

Trusted Types API를 사용하면, 여러분은 인젝션 싱크에 위험천만한 단순 '문자열(strings)' 대신 신뢰할 수 있는 타입(trusted types) 객체를 전달할 수 있습니다. '신뢰할 수 있는 타입' 객체란 잠재적으로 위험한 문자열 입력값을 특정 변환 함수에 통과시킨 결과물로만 만들어지는 특수한 객체입니다. 이 변환 함수는 일반적으로 입력값 속에 숨어 코드로 실행될 수 있는 악성 요소들(예: <script> 태그)을 제거하여 입력값을 완벽하게 검증(sanitize)하는 역할을 합니다.

기본적으로는, 개발자의 마음대로 인젝션 싱크에 이 '신뢰할 수 있는 타입' 객체를 넣을 수도 있고, 아니면 그냥 검증되지 않은 위험한 문자열을 냅다 집어넣을 수도 있습니다. 하지만 만약 여러분이 CSP에 require-trusted-types-for 지시어를 포함하고 그 값으로 'script'를 부여한다면, 브라우저는 여러분의 사이트가 인젝션 싱크에 "반드시 신뢰할 수 있는 타입 객체만" 전달하도록 엄격하게 룰을 강제합니다. 예를 들어, 이 상태에서 다음과 같이 평범한 문자열을 넣는 코드를 작성하면 브라우저가 즉시 예외(에러)를 던지고 실행을 막아버립니다:

const possiblyXSS = "<p>나는 XSS일지도 몰라</p>";
const target = document.querySelector("#target");

target.innerHTML = possiblyXSS;
// `require-trusted-types-for`가 설정되어 있다면 여기서 예외(에러)가 발생합니다!

이 신뢰할 수 있는 타입 객체들은 사용자가 직접 정의한 정책(policy) 객체를 사용해 생성됩니다. 문제는 여러분의 코드가 어떠한 종류의 정책 객체든 자유롭게 만들 수 있다는 것인데, 심지어 입력값을 전혀 검증하지 않는(즉, 아무런 방어 효과가 없는 껍데기뿐인) 가짜 변환 함수를 가진 정책 객체조차 만들 수 있습니다. 이러한 꼼수 위험을 최소화하기 위해, CSP에 trusted-types 지시어를 추가할 수 있습니다. 이 지시어는 '사용 가능한 올바른 정책들의 이름 목록'을 명시하며, 브라우저는 개발자가 오직 그 허락된 이름의 정책만을 사용하여 변환을 수행하도록 강제합니다.


정책 테스트하기 (Testing your policy)

CSP를 실제 서비스에 도입하기 전에 겪을 혼란과 오류를 줄이기 위해, CSP는 '보고 전용(report-only) 모드'로 배포될 수 있습니다.
이 모드에서는 정책 위반 사항을 실제로 차단(강제)하지는 않고, 그저 어떤 정책들이 위반되었는지를 정책에 명시된 특정 보고(reporting) 엔드포인트 주소로 조용히 보내주기만 합니다. 또한, 이 '보고 전용' 헤더를 사용하면 현재 라이브로 돌아가고 있는 기존 정책은 놔둔 채로, 나중에 배포할 '미래의 깐깐한 정책'을 실제 서비스 중단 없이 미리 백그라운드에서 테스트해 볼 수 있습니다.

이를 위해 다음과 같이 Content-Security-Policy-Report-Only HTTP 헤더를 사용해 정책을 지정할 수 있습니다:

Content-Security-Policy-Report-Only: policy

만약 하나의 서버 응답에 Content-Security-Policy-Report-Only 헤더와 Content-Security-Policy 헤더가 동시에 존재한다면, 브라우저는 두 정책을 모두 존중합니다.
Content-Security-Policy 헤더에 지정된 정책은 실제로 브라우저의 리소스 로드를 차단하며 강제 집행되는 반면, Content-Security-Policy-Report-Only 정책은 리소스 로드를 차단하지는 않고 오직 조용히 위반 보고서만 생성해서 서버로 보내줍니다.

참고로, 일반적인 콘텐츠 보안 정책과 달리 이 '보고 전용' 정책은 HTML <meta> 요소를 통해서는 절대 설정할 수 없으며 오직 HTTP 헤더로만 전달해야 합니다.

위반 사항 보고받기 (Violation reporting)

CSP 위반 사항을 보고받기 위해 가장 권장되는 현대적인 방법은 Reporting API를 사용하는 것입니다. Reporting-Endpoints 헤더에 보고서를 받을 엔드포인트 주소들을 선언하고, Content-Security-Policy 헤더 안의 report-to 지시어를 사용해 선언한 엔드포인트 중 하나를 지정하면 됩니다.

경고 (Warning):
이전 방식인 CSP report-uri 지시어를 사용하여 타깃 URL을 직접 명시할 수도 있습니다. 이 구형 방식은 Content-Typeapplication/csp-report로 설정하여 POST 요청으로 약간 다른 형태의 JSON 보고서를 전송합니다.
이 접근 방식은 이제 더 이상 권장되지 않는 폐기 예정(deprecated) 스펙입니다. 하지만 아직 모든 브라우저가 최신 방식인 report-to를 지원하는 것은 아니기 때문에, 당분간은 두 가지 지시어를 모두 선언해 두는 것이 안전합니다. 이 방식에 대한 자세한 내용은 report-uri 문서를 참고하세요.

서버는 Reporting-Endpoints HTTP 응답 헤더를 사용하여 클라이언트(브라우저)에게 보고서를 어디로 보내야 하는지 알려줍니다.
이 헤더는 하나 이상의 엔드포인트 URL들을 쉼표로 구분된 목록으로 정의합니다.
예를 들어, https://example.com/csp-reports 주소로 보고서를 받는 csp-endpoint라는 이름의 엔드포인트를 정의하려면, 서버의 응답 헤더는 다음과 같이 생겨야 합니다:

Reporting-Endpoints: csp-endpoint="[https://example.com/csp-reports](https://example.com/csp-reports)"

만약 서로 다른 유형의 보고서들(예: CSP 위반, 인증서 오류 등)을 각각 다른 곳에서 처리하게끔 여러 개의 엔드포인트를 나누고 싶다면 다음과 같이 지정할 수 있습니다:

Reporting-Endpoints: csp-endpoint="[https://example.com/csp-reports](https://example.com/csp-reports)",
                     hpkp-endpoint="[https://example.com/hpkp-reports](https://example.com/hpkp-reports)"

그런 다음, Content-Security-Policy 헤더의 report-to 지시어에 아까 지어준 엔드포인트 이름(csp-endpoint 등)을 명시하면, 브라우저가 위반 보고서를 해당 URL로 쏘아주게 됩니다.
예를 들어, default-src 정책을 위반했을 때의 보고서를 https://example.com/csp-reports로 보내게 만들고 싶다면, 다음과 같은 응답 헤더들을 전송하면 됩니다:

Reporting-Endpoints: csp-endpoint="[https://example.com/csp-reports](https://example.com/csp-reports)"
Content-Security-Policy: default-src 'self'; report-to csp-endpoint

사용자의 브라우저에서 CSP 위반이 발생하면, 브라우저는 설정된 엔드포인트 URL로 HTTP POST 요청을 보내는데, 이때 데이터는 JSON 객체 형태이고 Content-Typeapplication/reports+json입니다.
이 보고서는 값이 "csp-violation"type 속성을 가진 Report 객체가 직렬화(serialized)된 형태이며, 세부적인 위반 내용은 body 속성 안에 CSPViolationReportBody 객체 형태로 담겨있습니다.

서버에 도착하는 전형적인 JSON 보고서 객체는 다음과 같이 생겼습니다:

{
  "age": 53531,
  "body": {
    "blockedURL": "inline",
    "columnNumber": 39,
    "disposition": "enforce",
    "documentURL": "[https://example.com/csp-report](https://example.com/csp-report)",
    "effectiveDirective": "script-src-elem",
    "lineNumber": 121,
    "originalPolicy": "default-src 'self'; report-to csp-endpoint-name",
    "referrer": "[https://www.google.com/](https://www.google.com/)",
    "sample": "console.log(\"lo\")",
    "sourceFile": "[https://example.com/csp-report](https://example.com/csp-report)",
    "statusCode": 200
  },
  "type": "csp-violation",
  "url": "[https://example.com/csp-report](https://example.com/csp-report)",
  "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
}

여러분은 이 JSON 포맷과 콘텐츠 타입을 수신할 수 있도록 서버(API)를 구축해야 합니다. 그러면 서버는 이 요청들을 받아서 여러분의 팀에 알맞은 방식으로 분석하거나 DB에 저장할 수 있습니다.


같이 보기 (See also)

profile
프론트에_가까운_풀스택_개발자

0개의 댓글