← 목록으로 돌아가기

브라우저 보안 강화 실전: CSP, Trusted Types, Nonce 기반 스크립트로 XSS 줄이기

Web

Browser Security CSP Trusted Types XSS

XSS는 프론트엔드 문제가 아니라 서비스 신뢰 문제다

Cross-Site Scripting(XSS)은 오래된 취약점이지만 여전히 강력합니다. 공격자가 사용자의 브라우저에서 임의 스크립트를 실행할 수 있다면 세션 탈취, 계정 조작, 피싱 UI 삽입, 내부 API 호출이 가능해집니다. React와 같은 프레임워크가 기본 escaping을 제공하지만, 그것만으로 충분하지 않습니다. dangerouslySetInnerHTML, 서드파티 스크립트, Markdown 렌더링, 레거시 jQuery 코드, 광고 태그가 들어오는 순간 공격면은 다시 넓어집니다.

브라우저 보안 강화의 목표는 취약한 코드가 들어와도 피해를 줄이는 방어층을 만드는 것입니다. Content Security Policy(CSP)는 어떤 스크립트와 리소스를 허용할지 브라우저에 알려줍니다. Trusted Types는 DOM XSS가 자주 발생하는 sink에 문자열이 직접 들어가는 것을 막습니다. Nonce 기반 스크립트는 서버가 의도한 script만 실행되도록 합니다.

이 글에서는 CSP를 report-only로 시작해 점진적으로 강화하는 방법, nonce와 hash의 차이, Trusted Types 도입 전략, violation report 운영까지 실무 관점에서 정리합니다.


1. CSP는 허용 목록 기반 실행 정책이다

CSP는 응답 헤더로 전달되는 브라우저 정책입니다. script-src, style-src, img-src, connect-src 같은 directive로 어떤 출처의 리소스를 허용할지 정합니다. 잘 설계된 CSP는 공격자가 HTML에 script 태그를 삽입해도 실행을 막을 수 있습니다.

하지만 CSP를 처음부터 강하게 적용하면 서비스가 깨질 수 있습니다. 많은 웹 앱은 analytics, A/B 테스트, 채팅 위젯, CDN, 결제 SDK처럼 다양한 외부 리소스를 사용합니다. 어떤 리소스가 실제로 필요한지 모른 채 default-src 'self'를 enforce하면 사용자 화면이 망가질 수 있습니다. 그래서 report-only 모드로 시작하는 것이 안전합니다.

Report-only 모드에서는 정책 위반을 차단하지 않고 보고만 합니다. 이 데이터를 기반으로 실제 필요한 출처와 제거 가능한 출처를 구분합니다. 단, report에는 URL과 경로 정보가 포함될 수 있으므로 수집 endpoint의 접근 권한과 보존 기간도 관리해야 합니다. 보안 telemetry도 개인정보 관점에서 다뤄야 합니다.


2. Nonce 기반 Script 정책

스크립트 허용 정책에서 가장 위험한 것은 'unsafe-inline'입니다. Inline script를 모두 허용하면 XSS 방어 효과가 크게 줄어듭니다. Nonce 기반 정책은 서버가 요청마다 랜덤 nonce를 만들고, 허용된 script 태그에만 같은 nonce를 붙입니다. 브라우저는 nonce가 맞는 script만 실행합니다.

Nonce는 매 요청마다 충분히 예측 불가능해야 합니다. 정적 nonce나 사용자별 고정 nonce는 의미가 없습니다. 서버 렌더링 환경에서는 middleware나 document rendering 단계에서 nonce를 생성하고, CSP 헤더와 script 태그에 함께 주입합니다. Next.js나 Remix 같은 프레임워크에서는 SSR과 hydration 스크립트에 nonce가 제대로 전달되는지 확인해야 합니다.

Hash 기반 허용도 있습니다. Inline script 내용의 hash를 CSP에 넣는 방식입니다. 정적인 inline script에는 유용하지만, 빌드나 런타임에서 내용이 바뀌면 hash도 바뀝니다. 동적 SSR 환경에서는 nonce가 더 운영하기 쉽습니다. 중요한 것은 inline script를 무조건 허용하지 않고, 서버가 의도한 스크립트만 실행되게 만드는 것입니다.


3. Trusted Types로 DOM XSS를 줄인다

Trusted Types는 innerHTML, insertAdjacentHTML, eval류 API처럼 위험한 DOM sink에 일반 문자열이 들어가는 것을 제한합니다. 정책을 활성화하면 개발자는 명시적인 sanitizer를 거쳐 TrustedHTML 같은 타입을 만들어야 합니다. 이렇게 하면 실수로 사용자 입력을 HTML로 삽입하는 코드를 빌드나 런타임에서 잡을 수 있습니다.

Trusted Types는 레거시 코드가 많은 서비스에 바로 enforce하기 어렵습니다. 먼저 report-only로 어떤 sink가 사용되는지 수집합니다. Markdown 렌더러, WYSIWYG 에디터, 광고 SDK, analytics snippet처럼 HTML 삽입이 필요한 지점을 분류합니다. 이후 공인 sanitizer 함수를 만들고, 해당 함수만 Trusted Types policy를 생성하게 합니다.

프레임워크를 쓴다고 안전한 것은 아닙니다. React는 기본적으로 문자열을 escape하지만, dangerouslySetInnerHTML은 이름 그대로 위험합니다. 이 API를 완전히 금지할 수 없다면 sanitizer와 Trusted Types를 통과한 값만 허용해야 합니다. 코드 리뷰에서 "이 HTML은 어디서 왔는가"를 추적할 수 있어야 합니다.


4. 서드파티 스크립트와 공급망 리스크

현대 웹 앱은 서드파티 스크립트 없이 운영되기 어렵습니다. Analytics, tag manager, payment, chat, heatmap, A/B testing 도구가 들어옵니다. 문제는 이 스크립트들이 사용자 브라우저에서 우리 서비스 권한으로 실행된다는 점입니다. 공급망 공격이나 계정 탈취로 서드파티 스크립트가 악성 동작을 하면 피해는 우리 사용자에게 발생합니다.

CSP는 서드파티 출처를 명시적으로 제한합니다. 하지만 script-src https:처럼 넓게 열면 효과가 약합니다. 필요한 출처만 허용하고, 가능하면 Subresource Integrity(SRI)를 사용해 파일 내용 변조를 탐지합니다. Tag manager 하나를 허용하면 그 안에서 다시 여러 스크립트를 로드할 수 있으므로, 운영 권한과 변경 승인 절차가 필요합니다.

서드파티 스크립트는 성능에도 영향을 줍니다. 보안 정책을 정리하는 과정에서 사용하지 않는 태그를 제거하면 LCP와 INP도 좋아질 수 있습니다. 보안 강화는 종종 성능 개선과 함께 갑니다.


5. Violation Report를 운영 지표로 만든다

CSP와 Trusted Types report는 단순 로그가 아닙니다. 정책을 강화하기 위한 근거입니다. 어떤 route에서 어떤 directive 위반이 발생하는지, 새 배포 이후 위반이 급증했는지, 특정 브라우저나 확장 프로그램에서만 발생하는지 봐야 합니다. 확장 프로그램이 삽입한 스크립트까지 모두 장애로 보면 알림이 시끄러워집니다.

Report 수집 endpoint는 rate limit이 필요합니다. 잘못된 정책을 배포하면 모든 사용자가 report를 보내 수집 서버가 과부하될 수 있습니다. Sampling, deduplication, aggregation을 적용하고, 원본 report에는 민감한 URL query가 포함되지 않게 주의합니다.

Enforce 전환은 단계적으로 합니다. 먼저 report-only로 수집하고, 명백히 안전한 directive부터 enforce합니다. object-src 'none', base-uri 'self', frame-ancestors 같은 정책은 비교적 도입 효과가 크고 위험이 낮습니다. Script 정책과 Trusted Types는 서비스별 의존성을 정리한 뒤 강화합니다.


실무 체크리스트

  • CSP를 report-only로 시작해 실제 필요한 출처를 수집했는가
  • unsafe-inline 제거 계획과 nonce 기반 script 정책이 있는가
  • SSR/hydration 스크립트에 nonce가 정확히 전달되는가
  • dangerouslySetInnerHTML 사용 지점이 sanitizer와 Trusted Types를 통과하는가
  • 서드파티 스크립트 출처와 소유자가 문서화되어 있는가
  • Tag manager 변경 권한과 승인 절차가 있는가
  • CSP/Trusted Types violation report에 rate limit과 deduplication이 적용되어 있는가
  • 정책 enforce 전환을 route 또는 directive 단위로 점진적으로 진행하는가

6. Markdown과 사용자 콘텐츠는 별도 경계로 다룬다

블로그, 댓글, 문서, CMS처럼 사용자 또는 운영자가 작성한 Markdown을 HTML로 렌더링하는 서비스는 XSS 경계가 더 복잡합니다. Markdown 자체는 안전해 보여도 HTML inline 허용, 링크 URL, 이미지 src, code block 플러그인, syntax highlighter가 섞이면 공격면이 생깁니다. 렌더러 설정을 기본값으로 믿지 말고 허용할 태그와 속성을 명확히 제한해야 합니다.

링크는 특히 조심해야 합니다. javascript: URL이나 data URL을 허용하면 사용자가 클릭하는 순간 스크립트가 실행될 수 있습니다. 외부 링크에는 rel="noopener noreferrer"를 붙여 opener 공격을 막고, 이미지 도메인도 필요한 출처만 허용합니다. HTML을 허용해야 하는 CMS라면 sanitizer allowlist를 문서화하고 테스트해야 합니다.

사용자 콘텐츠 렌더링 영역은 CSP에서도 별도 정책을 고려할 수 있습니다. 가능하면 sandboxed iframe으로 격리하고, 본문 HTML이 애플리케이션 DOM과 같은 권한으로 실행되지 않게 합니다. 모든 서비스에 iframe 격리가 필요한 것은 아니지만, 외부 작성자가 HTML을 넣을 수 있는 환경에서는 강력한 방어선이 됩니다.


7. 보안 정책은 배포 파이프라인에서 회귀를 막는다

CSP와 Trusted Types는 한 번 설정하고 잊으면 금방 약해집니다. 새 analytics 도구, 결제 SDK, 광고 스크립트가 추가될 때마다 예외가 늘어납니다. 그래서 보안 헤더와 위험 API 사용을 CI에서 확인해야 합니다. unsafe-inline, 넓은 wildcard, 새 dangerouslySetInnerHTML 사용, sanitizer 없는 HTML 삽입은 리뷰에서 드러나야 합니다.

Violation report도 배포 버전과 연결해야 합니다. 새 릴리스 이후 특정 directive 위반이 급증하면 해당 배포에서 스크립트 출처나 nonce 전달이 깨졌을 가능성이 큽니다. report를 단순 저장하지 말고 release, route, browser 기준으로 집계하면 정책 강화 속도가 빨라집니다.

보안 정책 변경은 제품팀과도 조율해야 합니다. Tag manager로 마케팅 태그를 자유롭게 추가하는 조직이라면 CSP가 자주 깨질 수 있습니다. 보안팀이 막기만 하면 우회가 생기고, 제품팀이 마음대로 열면 위험이 커집니다. 승인된 태그 목록과 변경 절차를 만들면 보안과 운영 속도를 함께 지킬 수 있습니다.


8. 정책은 짧게라도 주기적으로 재점검한다

CSP 허용 목록은 시간이 지나며 늘어납니다. 분기마다 실제 사용되지 않는 출처를 제거하고, report-only 데이터를 다시 확인하면 정책이 느슨해지는 것을 막을 수 있습니다.


결론: 브라우저에 실행 가능한 것의 경계를 알려줘야 한다

XSS 방어는 코드에서 escape를 잘하는 것만으로 끝나지 않습니다. 복잡한 웹 앱에는 레거시 코드, 서드파티 스크립트, 동적 HTML, 사용자 콘텐츠가 섞입니다. CSP와 Trusted Types는 브라우저에게 "무엇이 실행되어도 되는가"와 "어떤 값이 DOM에 들어가도 되는가"를 명시하는 안전장치입니다.

강한 정책은 한 번에 완성되지 않습니다. Report-only로 관찰하고, 필요한 출처를 줄이고, nonce와 sanitizer를 도입하고, 위반 보고를 운영 지표로 삼아 점진적으로 enforce해야 합니다. 브라우저 보안은 프론트엔드의 부가 기능이 아니라 사용자 신뢰를 지키는 런타임 계약입니다.