FlawOpen

flawopen.com/xss/Java

● CWE-79 · High
FlawOpen Security Research

Java XSS: Secure Spring MVC and Thymeleaf Output

th:utext and raw JSP output can execute injected JavaScript. Encode for each output context and sanitize rich text with an allow-list (CWE-79).

💡 Plain English Explainer (ELI5)

A stadium scoreboard has a plain name field and a separate rich-display mode. Thymeleaf’s th:text treats a submitted name as literal text; th:utext tells the renderer to treat the value as HTML instructions for the board. The browser receives the stadium site’s text/html and renders that markup with the site’s authority. Keep user text in escaped output, and sanitize it before using an unescaped template slot when rich formatting is required.

Core Concepts & Subsystem Terms

Thymeleaf th:text vs th:utext
th:text HTML-escapes its value; th:utext (unescaped text) inserts it as markup. In Thymeleaf's inlined form, [[${x}]] escapes and [(${x})] does not. A th:utext bound to user input is XSS unless the value was sanitized first.
JSP ${} vs <c:out>
In JSP template text, ${param.q} is written without escaping. <c:out value="${param.q}"/> or ${fn:escapeXml(param.q)} escapes it. Scriptlets such as <%= request.getParameter("q") %> never escape.
Encode.forHtml / forJavaScript
The OWASP Java Encoder has one method per output context: Encode.forHtml for element content, forHtmlAttribute for quoted attributes, forJavaScript for JavaScript string literals and forUriComponent for URL parameters. HTML-encoding a value placed inside a script is not enough.
OWASP Java HTML Sanitizer
For user-supplied HTML, a policy such as Sanitizers.FORMATTING.and(Sanitizers.LINKS) keeps an allowlist of tags and attributes and drops scripts, event handlers and javascript: URLs. Encoding would show the tags as text; sanitizing keeps the safe ones working.
@ResponseBody returning text/html
A @RestController method that returns a String with produces = text/html is written to the response as-is: no template engine runs, so nothing is escaped. Prefer a view template, or encode every value you concatenate.

Framework mappings

Step-by-Step Attack Flow

Step 1

Input arrives in a request parameter

Spring binds q, user or bio from the query string or form body into a String argument. A victim can be sent a crafted link, or a submitted bio is shown to other users.

Step 2

Concatenation builds the response

The controller glues the value into HTML or into a <script> block. With the benign test value <img src=x onerror=alert(1)>, the returned string contains a real tag; with ';alert(1);// the script block gains a new statement.

Step 3

The browser parses it

Because the method declares text/html, the browser renders the body as a page. The image fails and fires onerror, or the script block runs the injected statement.

Step 4

The script runs as the site

The code executes with the application's origin: it can read the DOM, call the application's endpoints with the victim's session cookie and change what the user sees.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// ProfileController.java: each endpoint concatenates request data into HTML
@RestController
public class ProfileController {

    // HTML context: the query value is inserted as markup
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + q + "</h1>";
    }

    // JavaScript context: a quote in user ends the string literal
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + user + "';</script>";
    }

    // rich text is echoed with every tag and handler intact
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + bio + "</div>";
    }
}
HARDENED SECURE PATCH
// ProfileController.java: each value is encoded for its context or sanitized
@RestController
public class ProfileController {

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

    // forHtml encodes < > & " ' for element content
    @GetMapping(value = "/search", produces = MediaType.TEXT_HTML_VALUE)
    public String search(@RequestParam String q) {
        return "<h1>Results for " + Encode.forHtml(q) + "</h1>";
    }

    // forJavaScript escapes quotes, backslashes and </script> sequences
    @GetMapping(value = "/widget", produces = MediaType.TEXT_HTML_VALUE)
    public String widget(@RequestParam String user) {
        return "<script>const user = '" + Encode.forJavaScript(user) + "';</script>";
    }

    // the sanitizer keeps formatting and links and drops everything else
    @PostMapping(value = "/preview", produces = MediaType.TEXT_HTML_VALUE)
    public String preview(@RequestParam String bio) {
        return "<div class=\"bio\">" + BIO_POLICY.sanitize(bio) + "</div>";
    }
}

Engineering & System Hardening Checklist

Sources