공격자의 스크립트를 피해자의 브라우저에서 실행시키는 공격 (Cross-Site Scripting).
왜 치명적인가
브라우저에서 실행되는 스크립트는 그 사이트의 모든 권한을 갖는다.
- 쿠키·localStorage 탈취 → 세션 하이재킹
- 화면 조작 → 가짜 로그인 폼으로 비밀번호 수집
- 사용자 대신 요청 전송 → CSRF 방어를 무력화
- 키 입력 기록
사실상 사용자가 할 수 있는 모든 것을 할 수 있다.
세 유형
① 저장형(Stored) — 가장 위험
- 게시글에 <script>…</script> 를 저장
- 그 글을 보는 모든 사용자에게서 실행된다
② 반사형(Reflected)
- https://site.com/search?q=<script>…</script>
- 검색어를 그대로 화면에 출력하면 실행된다
- 이 링크를 피해자에게 보내야 하므로 저장형보다 범위가 좁다
③ DOM 기반
document.getElementById('out').innerHTML = location.hash.substring(1);
// 서버를 거치지 않고 클라이언트 코드만으로 발생
// 서버 로그에도 안 남아 탐지가 어렵다
방어의 원칙 — 출력 시점 이스케이프
입력 시점 필터링은 보조 수단이다. 같은 문자열이라도 어디에 출력되느냐에 따라 필요한 처리가 다르기 때문이다.
- HTML 본문 — → < > & " ' 를 엔티티로 (< > …)
- HTML 속성 — → 속성값을 반드시 따옴표로 감싸고 이스케이프
- JavaScript — → JSON 인코딩. 사용자 입력을 코드로 넣지 않는다
- URL — → 퍼센트 인코딩
- CSS — → CSS 이스케이프
"어디에 출력되는지"에 따라 방식이 달라진다 — 그래서 출력 시점에 해야 한다.
프레임워크의 기본 방어
// React — 기본적으로 이스케이프된다
<div>{userInput}</div> // 안전 ✅
// 위험한 예외
<div dangerouslySetInnerHTML={{__html: userInput}} /> // ✗
이름에 "dangerously"가 들어간 것은 경고다. 타임리프의 th:text(안전) vs
th:utext(위험)도 같은 구조다.
HTML을 허용해야 한다면(리치 에디터) 반드시 살균 라이브러리를 쓴다. 직접 만든 블랙리스트 필터는 반드시 우회된다.
// OWASP Java HTML Sanitizer, DOMPurify(JS)
String safe = policy.sanitize(userHtml);
CSP — 두 번째 방어선
Content Security Policy는 어떤 스크립트를 실행할 수 있는지 브라우저에 알려 준다.
Content-Security-Policy:
- default-src 'self';
- script-src 'self' 'nonce-r4nd0m';
- object-src 'none';
인라인 스크립트를 막고 지정한 출처만 허용하므로, XSS가 있어도 실행되지 않는다.
❌ script-src 'unsafe-inline' → CSP를 켜 놓고 스스로 무력화하는 설정 ✅ nonce 또는 hash 방식
쿠키 보호
Set-Cookie: SID=abc; HttpOnly; Secure; SameSite=Lax
HttpOnly면 document.cookie로 읽을 수 없어 XSS로 세션을 훔칠 수 없다.
반대로 localStorage에 토큰을 두면 XSS에 그대로 노출된다 —
이것이 "JWT를 localStorage에 두지 말라"는 조언의 근거다.
다만 XSS가 있으면 토큰을 못 읽어도 스크립트가 대신 요청을 보낼 수 있으므로 완전한 방어는 아니다. HttpOnly는 피해를 줄일 뿐 XSS 자체를 막지 못한다.
방어를 겹겹이
- ① 출력 이스케이프 — ← 근본
- ② HTML 살균 — ← 리치 텍스트가 필요할 때
- ③ CSP — ← 뚫렸을 때의 안전망
- ④ HttpOnly 쿠키 — ← 피해 축소
- ⑤ 입력 검증 — ← 보조 어느 하나만으로는 부족하다는 것이 이 목록의 요지다.