← all posts
DEV 2026.05.02 · 14 min read Intermediate

XSS부터 Clickjacking까지, 웹 보안 헤더의 설계 철학

Reflected·Stored·DOM-based XSS의 공격 원리부터 CSP Nonce, HSTS, Permissions-Policy, Open Redirect 방어까지, 브라우저와 서버가 신뢰를 구축하는 방식을 추적한다.


웹 보안 헤더를 “그냥 체크리스트”로 다루는 팀이 많다. X-Frame-Options: DENY를 붙이고, CSP를 설정하고, HSTS를 켠다. 그런데 왜 이 헤더들이 필요한지, 어떤 공격 경로를 막는지 모른다면 결국 구멍이 생긴다. XSS, Clickjacking, SSL Strip, Open Redirect — 이 공격들은 모두 하나의 공통 전제를 파고든다. 브라우저는 서버를 신뢰하고, 서버는 입력을 신뢰한다. 그 신뢰를 어떻게 구조적으로 깨뜨리는가?

XSS: 신뢰할 수 없는 입력이 HTML 컨텍스트에 들어갈 때

XSS는 유형이 세 가지다. 이 구분이 단순한 분류가 아니라 방어 방법을 결정한다.

Reflected XSS는 요청 파라미터가 응답 HTML에 그대로 반사된다. 공격자는 악의적인 URL을 만들어 사용자에게 전달하고, 클릭하면 그 브라우저에서만 스크립트가 실행된다. 서버 로그에는 공격 URL이 기록된다.

Stored XSS는 악성 스크립트가 DB에 저장된다. 공격자가 한 번만 주입하면 이후 그 페이지를 방문하는 모든 사용자에게 실행된다. 피해 범위가 가장 넓다.

DOM-based XSS는 서버가 정상 응답을 보내도 클라이언트 JavaScript가 URL 파라미터 같은 입력을 innerHTML이나 eval()에 직접 삽입하면서 발생한다. 서버 로그에 남지 않고 WAF로 탐지도 어렵다.

세 유형의 공통 원인은 하나다.

신뢰할 수 없는 입력 + 검증 없음 + HTML 컨텍스트 렌더링 = XSS

Thymeleaf에서 th:textth:utext의 차이가 이 원인을 가장 명확하게 드러낸다. th:text는 자동으로 이스케이핑한다. th:utext는 raw HTML을 그대로 렌더링한다. th:utext에 정제되지 않은 사용자 입력이 들어가면 Stored XSS가 완성된다.

DOM-based XSS의 방어는 간단하다. innerHTML 대신 textContent를 쓰고, eval()을 쓰지 않는다. URL 파라미터 기반 동적 처리가 필요하면 화이트리스트 디스패치로 대체한다.

// ❌ 취약
document.getElementById('results').innerHTML = `검색: ${query}`;

// ✅ 안전
document.getElementById('results').textContent = `검색: ${query}`;

CSP: 브라우저에게 실행 가능한 스크립트를 명시하는 계약

Content-Security-Policy 헤더는 “이 출처에서 온 스크립트만 실행하라”는 계약을 브라우저와 맺는다. script-src 'self'만으로는 부족하다. 같은 도메인의 JSONP 엔드포인트를 통해 콜백 파라미터를 조작하면 우회된다.

unsafe-inline은 CSP를 사실상 무력화한다. 모든 인라인 스크립트를 신뢰한다는 의미이므로, Stored XSS 페이로드도 그대로 실행된다. 인라인 스크립트가 필요하다면 Nonce 기반으로 대체해야 한다.

// 요청마다 고유한 nonce 생성
String nonce = UUID.randomUUID().toString();
request.setAttribute("cspNonce", nonce);
response.setHeader("Content-Security-Policy",
    "default-src 'self'; " +
    "script-src 'self' 'nonce-" + nonce + "' 'strict-dynamic'; " +
    "frame-ancestors 'none'; " +
    "report-uri /api/csp-report");

템플릿에서는 th:attr="nonce=${cspNonce}"로 인라인 스크립트에 nonce를 부여한다. 공격자는 요청마다 바뀌는 nonce를 알 수 없으므로 자신의 스크립트를 주입해도 실행되지 않는다.

CSP를 처음 배포할 때는 Content-Security-Policy-Report-Only 헤더로 시작한다. 실제 차단 없이 위반 리포트만 수집해 정책을 검증한 뒤, 문제가 없으면 enforce 모드로 전환한다. report-uri /api/csp-report를 설정하면 script-src 위반(스크립트 주입 시도)을 실시간으로 감지할 수 있다.

unsafe-inline + nonce 혼용 금지

script-src 'unsafe-inline' 'nonce-abc'처럼 두 지시어를 함께 쓰면 nonce가 무의미해진다. unsafe-inline이 먼저 평가되어 모든 인라인 스크립트를 허용하기 때문이다. 둘 중 하나만 선택해야 한다.

Clickjacking: 보이지 않는 iframe이 클릭을 가로챌 때

Clickjacking은 투명한 iframe을 화면 위에 겹쳐 사용자의 클릭을 가로채는 공격이다. “iPhone을 받으세요” 버튼 아래에 opacity: 0인 Facebook “좋아요” 버튼이 실제로 위치한다.

방어는 두 헤더로 완성된다.

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

X-Frame-Options는 구형 브라우저를 위해 필요하고, frame-ancestors는 현대 브라우저의 CSP 메커니즘으로 처리된다. 둘 다 설정하는 것이 안전하다.

JavaScript로만 방어하는 방식(if (window !== window.top))은 공격자가 Object.definePropertywindow.top을 조작해 우회할 수 있다. 서버 헤더 없이 클라이언트 검사만 믿으면 안 된다.

민감한 기능(송금, 계정 삭제)에는 CSRF 토큰 외에 OTP 같은 추가 인증이 필요하다. Clickjacking 환경에서는 CSRF 토큰이 iframe 안에 자동으로 포함되므로 토큰만으로는 방어가 완전하지 않다.

HSTS와 나머지 헤더들: 전송 계층의 신뢰

HSTS(Strict-Transport-Security)는 SSL Strip 공격을 막는다. 공개 WiFi에서 공격자가 HTTP 응답을 가로채 HTTPS 리다이렉트를 HTTP로 바꾸면, HSTS가 없는 브라우저는 그냥 HTTP로 진행한다. HSTS가 있으면 브라우저가 로컬 캐시를 확인하고 스스로 HTTPS로 업그레이드한다.

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

includeSubDomains 없이는 api.example.com이 공격 대상이 될 수 있다. preload는 Google이 관리하는 브라우저 내장 목록에 등록해 첫 방문부터 HSTS를 적용한다. 단, 등록 후 취소가 어려우므로 도메인을 장기 유지할 수 있을 때만 신청해야 한다.

나머지 헤더들도 각자의 역할이 있다.

  • Referrer-Policy: no-referrer — 외부 사이트로 이동 시 Referer 헤더에 내부 URL이 노출되지 않도록 한다.
  • Permissions-Policy: camera=(), microphone=() — 악성 스크립트가 카메라/마이크 권한을 요청하지 못하게 막는다.
  • X-Content-Type-Options: nosniff — IE 같은 구형 브라우저가 MIME 타입을 스니핑해 JSON 응답을 HTML로 해석하는 것을 막는다.

Open Redirect: 신뢰받는 도메인이 피싱의 입구가 될 때

Open Redirect는 redirect_uri 파라미터를 검증 없이 사용할 때 발생한다. 공격자는 정상 도메인에서 시작하는 URL을 만들어 사용자를 피싱 사이트로 유도한다.

https://login.company.com/login?next=https://attacker.com/phishing

도메인 검증도 우회 기법이 다양하다. url.contains("company.com")company.com.attacker.com으로 우회된다. url.startsWith("https://")//attacker.com으로 우회된다. 올바른 검증은 URI 파싱 후 getHost()를 화이트리스트와 비교하는 것이다.

URI uri = new URI(url);
String host = uri.getHost();
if (!ALLOWED_DOMAINS.contains(host)) {
    return "/dashboard"; // 기본값
}

가장 단순하고 안전한 방식은 상대 URL만 허용하는 것이다. /로 시작하는 경로는 같은 도메인이 보장된다.

정리

  • XSS의 유형(Reflected/Stored/DOM)은 방어 위치를 결정한다. 입력 검증, HTML 정제, 출력 이스케이핑의 세 단계가 모두 필요하다.
  • CSP Nonce는 unsafe-inline 없이 인라인 스크립트를 안전하게 허용하는 유일한 방법이다. Report-Only 모드로 검증 후 배포하라.
  • Clickjacking은 X-Frame-Options: DENYframe-ancestors 'none' 둘 다 설정해야 한다. JavaScript 단독 방어는 우회된다.
  • HSTS는 첫 방문 이후 브라우저가 스스로 HTTPS를 강제한다. includeSubDomains는 필수다.
  • Open Redirect 방어는 getHost() 기반 화이트리스트 검증이다. 문자열 포함 확인은 우회된다.

다음 글에서는 IDOR(Insecure Direct Object Reference) — 소유권 검증 없이 ID만으로 자원에 접근하는 취약점과 그 방어를 추적한다.