크로스 사이트 스크립팅 (XSS)

Java 서블릿과 JSP의 HTML 응답에서 발생하는 XSS

설명

크로스 사이트 스크립팅(XSS)은 신뢰할 수 없는 데이터가 브라우저의 현재 출력 컨텍스트에 필요한 보호 없이 HTML 응답에 삽입될 때 발생합니다. 공격자는 마크업을 변경하거나 응답의 오리진에서 JavaScript를 실행하여 계정 작업 수행, 민감 정보 열람, 피싱 UI 표시 등의 피해를 일으킬 수 있습니다.

HTML 텍스트, HTML 속성, URL, JavaScript, CSS는 서로 다른 파서와 문법을 사용합니다. 따라서 한 컨텍스트의 인코더를 다른 컨텍스트에 재사용하거나, 이름만 sanitize인 도우미를 호출하거나, 일부 문자를 제거하는 것으로는 안전을 증명할 수 없습니다. 출력 인코딩은 값을 사용하는 마지막 단계에서 정확한 컨텍스트에 맞게 적용해야 합니다. 사용자가 제한된 HTML 마크업을 작성해야 하는 경우에만 검토된 HTML sanitizer를 사용합니다.

권장 수정 방법

가능하면 직접 HTML 문자열을 조립하지 말고 자동 이스케이프되는 템플릿 출력을 사용합니다. JSTL <c:out>은 기본 escapeXml 설정을 유지하고, Thymeleaf는 th:text 또는 [[...]]을 사용합니다. escapeXml="false", th:utext, [(...)] 같은 비이스케이프 출력은 검토된 리치 HTML에만 제한합니다.

출력 위치 권장 처리 주의할 점
HTML 텍스트 Encode.forHtml 또는 Encode.forHtmlContent 속성, JavaScript, CSS, URL에 그대로 재사용하지 않음
따옴표로 감싼 일반 HTML 속성 Encode.forHtmlAttribute 속성 값은 항상 따옴표로 감싸고 이벤트·URL·style·srcdoc에는 별도 정책 적용
피할 수 없는 레거시 비따옴표 일반 속성 우선 따옴표로 전환하고, 전환할 수 없을 때만 Encode.forHtmlUnquotedAttribute 공식 API도 사용을 권장하지 않으며 값 뒤 구분자를 애플리케이션이 통제해야 함
애플리케이션 소유 URL의 경로·쿼리 구성 요소 Encode.forUriComponent 신뢰할 수 없는 전체 URL을 검증하는 함수가 아니며, deprecated forUri를 대신 사용하지 않음
신뢰할 수 없는 전체 URL 파싱·정규화 후 스킴(일반적으로 https)과 필요한 호스트를 허용 목록으로 검증하고 forHtmlAttribute 적용 문자열 prefix 검사만으로 호스트를 검증하지 않음
<script> 안의 따옴표 문자열 Encode.forJavaScriptBlock 데이터를 실행 코드, 식별자, 템플릿 구조로 삽입하지 않으며, HTML 안에서 안전하지 않은 forJavaScriptSource를 사용하지 않음
이벤트 핸들러의 따옴표 문자열 Encode.forJavaScriptAttribute 가능하면 인라인 이벤트 핸들러 자체를 제거
따옴표로 감싼 CSS 문자열 고정된 속성 구조 안의 Encode.forCssString CSS 문자열을 둘러싼 따옴표가 필요하며 속성명이나 스타일시트 구조를 제어하게 하지 않음
CSS url(...) 값 URL을 먼저 검증하거나 애플리케이션 소유 URL로 구성한 뒤 Encode.forCssUrl forCssUrl 자체는 URL의 스킴이나 호스트 안전성을 검증하지 않음

Apache Commons Text의 escapeHtml4는 아포스트로피를 인코딩하지 않으므로 작은따옴표 속성의 일반적인 수정 방법으로 사용하면 안 됩니다. Spring HtmlUtils.htmlEscape와 레거시 인코더도 정확한 대상 컨텍스트를 확인한 경우에만 사용합니다.

리치 HTML이 필요한 경우

마크업이 필요하지 않다면 sanitizer 대신 텍스트로 인코딩하세요. 마크업이 필요하다면 배포 시점의 최신 패치 버전을 사용하고 애플리케이션이 소유한 최소 허용 정책을 검토합니다. 2026-08-30에 확인한 릴리스는 OWASP Java HTML Sanitizer 20260313.1, jsoup 1.23.2, DOMPurify 3.4.14입니다.

  • 서버 측에서는 최신 OWASP Java HTML Sanitizer 또는 jsoup의 검토된 Safelist를 사용합니다. 사용 중인 공유 정책을 변경하거나 정제된 결과를 다시 문자열 조작하지 않습니다.
  • 브라우저 측 리치 HTML에는 최신 DOMPurify를 사용합니다. 네이티브 Element.setHTML()은 여전히 Baseline 기능이 아니므로 지원 런타임이 확인된 경우에만 사용합니다.
  • setHTMLUnsafe()를 표준 수정 방법으로 권장하지 않습니다.

CSP, X-Content-Type-Options: nosniff, 명시적인 text/html;charset=UTF-8, HttpOnly/Secure/SameSite 쿠키는 심층 방어에 유용하지만 컨텍스트별 출력 보호를 대체하지 않습니다.

예시

변경 전

다음 코드는 요청 값을 HTML 텍스트와 전체 URL 속성에 그대로 삽입합니다.

java
import java.io.IOException;
import java.io.PrintWriter;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public final class ProfileServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        response.setContentType("text/html;charset=UTF-8");
        final PrintWriter out = response.getWriter();

        String bio = request.getParameter("bio");
        String next = request.getParameter("next");
        out.println("<div class=\"bio\">" + bio + "</div>");
        out.println("<a href=\"" + next + "\">계속</a>");
    }
}

변경 후

다음 코드는 HTML 텍스트와 일반 속성을 각각 인코딩하고, URL에는 애플리케이션이 소유한 고정 경로 안의 단일 구성 요소만 삽입합니다.

java
import java.io.IOException;
import java.io.PrintWriter;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.owasp.encoder.Encode;

public final class SafeProfileServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException {
        response.setContentType("text/html;charset=UTF-8");
        response.setHeader("X-Content-Type-Options", "nosniff");
        final PrintWriter out = response.getWriter();

        String bio = request.getParameter("bio");
        String label = request.getParameter("label");
        String profileId = request.getParameter("profileId");

        out.println("<div class=\"bio\">" + Encode.forHtml(bio) + "</div>");
        out.println("<span title=\"" + Encode.forHtmlAttribute(label) + "\">프로필</span>");
        out.println("<a href=\"/profiles/" + Encode.forUriComponent(profileId) +
            "\">계속</a>");
    }
}

사용자가 일부 마크업을 작성해야 한다면 인코딩으로 태그를 보존하려 하지 말고 정책 기반 sanitizer를 사용합니다.

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

public final class RichHtmlSanitizer {
    private static final PolicyFactory POLICY =
        Sanitizers.FORMATTING.and(Sanitizers.LINKS);

    public static String sanitize(String untrustedMarkup) {
        return POLICY.sanitize(untrustedMarkup);
    }
}

참조