블로그 목록

XSS · CSRF · CSP — 프론트엔드 보안의 두 공격과 막는 법

2026-06-2811 min read
보안XSSCSRFCSPReact

"sanitize 했어?", "CSRF 토큰 있어?", "CSP 헤더 내려?" — 보안 리뷰에서 자주 나오는 질문이다. 그런데 이 셋은 서로 다른 공격을 막는 도구다. XSS는 코드 주입, CSRF는 요청 위조. 본질이 다르니 막는 법도 다르다. 헷갈리지 않게 한 번에 정리한다.

XSS와 CSRF는 다른 공격이다 — 막는 방법도 다르다🐛 XSS — 악성 스크립트를 주입해 실행시킨다① 자동 escapeReact가 {} 보간을 텍스트로기본② DOMPurifyHTML 직접 삽입 시 sanitize꼭 필요할 때③ CSP브라우저가 실행 자체를 차단최후 방어선앞단이 뚫려도 다음 층이 막는 깊이 방어(defense in depth)🎣 CSRF — 로그인된 권한으로 위조 요청을 보낸다SameSite=Lax · Strict크로스사이트 요청에 쿠키 미전송기본 방어CSRF 토큰예측 불가 토큰을 요청에 동봉교차 도메인 시XSS는 코드 주입을 막고(escape · DOMPurify · CSP), CSRF는 요청 위조를 막는다(SameSite · 토큰)

1. XSS — 악성 스크립트를 "남의 페이지에서" 실행시키는 공격

XSS(Cross-Site Scripting)는 공격자가 내 사이트에 자바스크립트를 끼워 넣어, 다른 사용자의 브라우저에서 실행시키는 공격이다. 한 번 실행되면 그 사용자의 권한으로 못 할 게 거의 없다.

  • 쿠키·토큰 탈취 (document.cookie를 외부로 전송)
  • 로그인된 상태로 위조 요청 전송 (사실상 CSRF를 내부에서 일으킴)
  • DOM 조작으로 가짜 로그인 폼 삽입(피싱)

대표적인 통로는 사용자 입력을 그대로 HTML로 렌더링하는 곳이다.

// 게시글 본문을 그대로 innerHTML에 박는다 — 전형적인 XSS 구멍
el.innerHTML = post.body;   // body = '<img src=x onerror="steal(document.cookie)">'

React는 기본적으로 XSS를 막아준다

React에서 {} 보간은 항상 텍스트로 처리(escape) 된다. <, > 같은 문자를 태그가 아니라 글자로 출력하기 때문에 스크립트가 실행되지 않는다.

<div>{post.body}</div>   // body에 <script>가 있어도 "글자"로 보일 뿐, 실행 안 됨

그래서 평범하게 쓰면 React 앱은 XSS에 꽤 안전하다. 문제는 이 보호막을 일부러 걷어낼 때다.

dangerouslySetInnerHTML — 보호막을 끄는 스위치

마크다운 변환 결과나 CMS의 리치 텍스트처럼 HTML을 진짜 그대로 그려야 할 때가 있다. 이때 쓰는 게 dangerouslySetInnerHTML인데, 이름 그대로 위험하다. escape를 끄기 때문에 문자열에 섞인 스크립트가 그대로 실행된다.

// 외부/사용자가 만든 HTML이라면 이건 XSS 구멍이다
<div dangerouslySetInnerHTML={{ __html: post.body }} />

그래서 — "꼭 필요하면 DOMPurify로 sanitize한다"

원칙은 두 단계다.

  1. 기본적으로는 dangerouslySetInnerHTML을 쓰지 않는다 (React 자동 escape에 맡긴다).
  2. HTML을 직접 넣어야만 하는 상황이라면, 넣기 전에 DOMPurify로 위험한 코드를 걸러낸다.

DOMPurify는 HTML을 파싱해 <script>, onerror= 같은 실행 가능한 요소·속성만 제거하고 안전한 마크업은 남긴다.

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(post.body);   // 위험 요소 제거
<div dangerouslySetInnerHTML={{ __html: clean }} />

포인트는 "sanitize는 입력을 신뢰할 수 없을 때, 직접 HTML을 그릴 때만" 이라는 점이다. 평범한 {} 텍스트 출력에는 필요 없다. 무조건 sanitize가 아니라, 보호막을 끈 그 지점에서만 보충하는 것이다.

한 발 더 — 출력 위치(컨텍스트)마다 이스케이프가 다르다

XSS 방어의 핵심은 "출력되는 위치에 맞게 처리" 하는 것이다. HTML 본문, 속성값, URL, 인라인 스크립트는 위험 문자가 다르다. 특히 href에 사용자 입력을 넣을 땐 javascript: 스킴을 조심해야 한다.

// javascript:alert(1) 같은 스킴 주입 방지
<a href={isSafeUrl(url) ? url : "#"}>링크</a>

2. CSRF — 로그인된 권한을 "몰래 빌려" 요청을 위조하는 공격

CSRF(Cross-Site Request Forgery)는 결이 완전히 다르다. 코드를 주입하는 게 아니라, 브라우저가 쿠키를 자동으로 보내준다는 점을 악용한다.

동작 원리

  1. 사용자가 bank.com에 로그인 → 인증 쿠키가 브라우저에 저장된다.
  2. 로그아웃 안 한 채 공격자의 evil.com을 방문한다.
  3. evil.com에 이런 게 숨어 있다.
<img src="https://bank.com/transfer?to=attacker&amount=1000000" />
  1. 브라우저가 이미지를 불러오려 bank.com에 요청을 보내는데, 이때 1번의 인증 쿠키가 자동으로 함께 전송된다.
  2. 서버는 "유효한 쿠키네, 본인 요청이군" → 송금 실행. 사용자는 클릭조차 안 했다.

공격자는 응답을 읽을 필요가 없다. 요청이 실행되는 것 자체가 목적이다.

방어 1 — SameSite 쿠키 (요즘의 핵심)

쿠키에 SameSite를 주면 다른 사이트에서 출발한 요청에는 쿠키를 안 싣는다. CSRF의 근본 전제(쿠키 자동 전송)를 끊는 것이다.

Set-Cookie: session=abc; HttpOnly; Secure; SameSite=Lax
  • Lax — 현대 브라우저 기본값. 외부 사이트의 POST/AJAX엔 쿠키 미전송. 대부분의 CSRF를 자동 차단.
  • Strict — 더 엄격(외부 링크 진입 시 로그아웃처럼 보이는 UX 비용).
  • None — 항상 전송. CSRF가 다시 열리므로 반드시 Secure + 별도 대책 필요.

Lax가 기본이라 요즘은 토큰 없이도 상당 부분 막힌다. 단, Lax도 GET 탐색은 허용하므로 상태를 바꾸는 작업은 절대 GET으로 만들지 않는다는 전제가 깔린다. 조회는 GET, 변경은 POST/PUT/DELETE.

방어 2 — CSRF 토큰

프론트와 API가 다른 도메인이라 SameSite=None이 불가피할 때가 있다. 이땐 예측 불가능한 토큰을 발급해 요청에 동봉하고 서버가 검증한다(공격자는 이 값을 모른다).

<input type="hidden" name="csrf_token" value="a8f3...랜덤값" />

흔히 쓰는 방식이 double-submit 쿠키 — 같은 토큰을 쿠키와 요청 헤더 양쪽에 넣고 서버가 일치 여부를 비교한다.

3. CSP — 마지막 안전망

CSP(Content Security Policy)는 공격을 막는 게 아니라, 방어가 뚫렸을 때를 대비한 브라우저 차원의 안전망이다. 주로 XSS의 2차 방어선으로 쓴다.

"혹시 악성 스크립트가 페이지에 주입되더라도, 허용 목록에 없는 스크립트는 브라우저가 아예 실행하지 마라."

서버가 응답 헤더로 정책을 내린다.

Content-Security-Policy: default-src 'self'; script-src 'self'
  • default-src 'self' — 기본적으로 우리 사이트 출처의 리소스만 허용
  • script-src 'self' — 스크립트는 우리 사이트에서 온 것만 실행

이러면 주입된 <script src="https://evil.com/hack.js">도, 인라인 onerror="..."도 허용 목록에 없어서 실행이 거부된다. 대부분의 XSS가 인라인 코드 형태라, CSP가 인라인 스크립트를 막는다는 게 핵심 효과다.

'unsafe-inline' 없이 nonce로 운영하기

우리 사이트도 인라인 스크립트가 필요할 때가 있다. 이때 'unsafe-inline'(보안 무력화)을 쓰는 대신 nonce(매 요청 새로 만드는 난수)를 쓴다.

Content-Security-Policy: script-src 'self' 'nonce-r4nd0m123'
<script nonce="r4nd0m123">정상 코드</script>   <!-- nonce 일치 → 실행 -->
<script>주입된 악성 코드</script>               <!-- nonce 없음 → 차단 -->

공격자는 서버가 매번 새로 만드는 nonce를 알 수 없으니 주입에 실패한다.

점진적 도입 — Report-Only

처음부터 차단하면 멀쩡한 기능까지 깨질 수 있다. 그래서 차단 없이 위반만 보고받는 모드로 시작해 정책을 다듬은 뒤 실제 차단으로 전환한다.

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

4. 정리 — 무엇이 무엇을 막는가

도구막는 공격한 줄
React 자동 escapeXSS{} 보간은 텍스트로 — 기본 방어
DOMPurifyXSSdangerouslySetInnerHTML 쓸 때만 sanitize
SameSite 쿠키CSRF크로스사이트 요청에 쿠키 미전송 (Lax 기본)
CSRF 토큰CSRF교차 도메인 등 SameSite로 부족할 때
CSPXSS(2차)허용 목록 밖 스크립트는 브라우저가 실행 거부

핵심을 한 번 더 끊어 보면 —

  • XSS와 CSRF는 다른 공격이다. XSS는 코드를 주입하고, CSRF는 요청을 위조한다. 그래서 방어 수단도 섞이지 않는다.
  • XSS 방어는 깊이 방어다. 자동 escape(기본) → DOMPurify(직접 HTML 그릴 때) → CSP(최후 안전망). 앞단이 뚫려도 다음 층이 받친다.
  • sanitize는 만능 주문이 아니다. 보호막을 끈 그 지점(dangerouslySetInnerHTML)에서만 보충한다.
  • CSRF는 SameSite로 거의 끝난다. 교차 도메인일 때만 토큰을 더한다.

한 줄 정리 — XSS는 코드 주입(escape · DOMPurify · CSP), CSRF는 요청 위조(SameSite · 토큰). 둘을 헷갈리지 않고, XSS는 한 겹이 아니라 여러 겹으로 막는 게 핵심이다.

쿠키에 토큰을 어디에 어떻게 저장할지(HttpOnly·Secure·__Host-·메모리 vs 쿠키)는 결이 다른 주제라 토큰을 어디에 둘까 글에서 따로 다뤘다.