FlawOpen

flawopen.com/xss/Java

● CWE-79 · Высокий
Исследования · FlawOpen

XSS в Java: защита Spring MVC и Thymeleaf

th:utext и JSP без экранирования могут выполнить JavaScript. Кодируйте по контексту и очищайте HTML по списку разрешений (CWE-79).

💡 Простыми словами (ELI5)

На табло стадиона есть обычное поле имени и отдельный режим форматированного вывода. th:text в Thymeleaf показывает отправленное имя как обычный текст; th:utext просит рендерер трактовать значение как HTML-инструкции для табло. Браузер получает text/html сайта стадиона и отображает разметку с полномочиями сайта. Оставляйте пользовательский ввод в экранируемом выводе и очищайте его перед неэкранированным слотом шаблона, если нужно форматирование.

Ключевые понятия и термины

Thymeleaf th:text vs th:utext
th:text экранирует значение как HTML; th:utext (неэкранированный текст) вставляет его как разметку. Во встроенной форме Thymeleaf [[${x}]] экранирует, а [(${x})] — нет. th:utext, связанный с пользовательским вводом, — это XSS, если значение предварительно не очищено.
JSP ${} vs <c:out>
В тексте шаблона JSP ${param.q} выводится без экранирования. <c:out value="${param.q}"/> или ${fn:escapeXml(param.q)} его экранируют. Скриптлеты вроде <%= request.getParameter("q") %> не экранируют никогда.
Encode.forHtml / forJavaScript
У OWASP Java Encoder по методу на каждый контекст вывода: Encode.forHtml для содержимого элемента, forHtmlAttribute для атрибутов в кавычках, forJavaScript для строковых литералов JavaScript и forUriComponent для параметров URL. HTML-кодирования значения внутри скрипта недостаточно.
OWASP Java HTML Sanitizer
Для HTML от пользователя политика вроде Sanitizers.FORMATTING.and(Sanitizers.LINKS) держит белый список тегов и атрибутов и удаляет скрипты, обработчики событий и URL javascript:. Кодирование показало бы теги как текст; очистка оставляет безопасные теги рабочими.
@ResponseBody returning text/html
Метод @RestController, возвращающий String с produces = text/html, пишется в ответ как есть: никакой шаблонизатор не работает, и ничего не экранируется. Лучше используйте шаблон представления или кодируйте каждое склеиваемое значение.

Связи со стандартами

Пошаговый сценарий атаки

Шаг 1

Ввод приходит в параметре запроса

Spring привязывает q, user или bio из query string или тела формы к аргументу String. Жертве можно прислать подготовленную ссылку, либо отправленное bio показывается другим пользователям.

Шаг 2

Склейка собирает ответ

Контроллер вклеивает значение в HTML или в блок <script>. С безобидным тестовым значением <img src=x onerror=alert(1)> возвращаемая строка содержит настоящий тег; с ';alert(1);// блок скрипта получает новый оператор.

Шаг 3

Браузер её разбирает

Поскольку метод объявляет text/html, браузер отображает тело как страницу. Картинка не загружается и вызывает onerror, или блок скрипта выполняет внедрённый оператор.

Шаг 4

Скрипт выполняется от имени сайта

Код работает с источником (origin) приложения: читает DOM, вызывает эндпоинты приложения с cookie сессии жертвы и меняет то, что видит пользователь.

Исходный код: Уязвимый vs Защищённый вариант

УЯЗВИМАЯ РЕАЛИЗАЦИЯ
// ProfileController.java: каждый эндпоинт вклеивает данные запроса в HTML
@RestController
public class ProfileController {

    // контекст HTML: значение из query вставляется как разметка
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + q + "</h1>";
    }

    // контекст JavaScript: кавычка в user завершает строковый литерал
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + user + "';</script>";
    }

    // форматированный текст возвращается со всеми тегами и обработчиками
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + bio + "</div>";
    }
}
БЕЗОПАСНЫЙ ИСПРАВЛЕННЫЙ ВАРИАНТ
// ProfileController.java: каждое значение кодируется под свой контекст или очищается
@RestController
public class ProfileController {

    private static final PolicyFactory BIO_POLICY = Sanitizers.FORMATTING.and(Sanitizers.LINKS);

    // forHtml кодирует < > & " ' для содержимого элемента
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + Encode.forHtml(q) + "</h1>";
    }

    // forJavaScript экранирует кавычки, обратные слэши и последовательности </script>
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + Encode.forJavaScript(user) + "';</script>";
    }

    // санитайзер сохраняет форматирование и ссылки и отбрасывает всё остальное
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + BIO_POLICY.sanitize(bio) + "</div>";
    }
}

Чек-лист по защите системы для инженеров

Источники