Bind text with th:text and attributes with th:attr or th:title, which HTML-escape & < > " and ' — never th:utext for user data. Thymeleaf has no unescaped attribute writer, so th:* attributes are always safe. A link needs more: allow only http/https URLs, because escaping leaves javascript: working.
- Find every th:utext and every unescaped inlined expression [(...)]; both write the value as raw HTML.
- Change them to th:text (or [[...]] inlining), which escapes & < > " and '. Bind attributes with th:text's attribute cousins (th:title, th:alt, th:attr); Thymeleaf escapes those too and offers no unescaped attribute form.
- For a URL in th:href or th:src, do not rely on escaping: check the value in the controller or with #strings and render the link only for an http/https (or mailto) scheme, or a relative URL.
- For rich text that must keep some HTML, sanitize it in Java with the OWASP Java HTML Sanitizer or jsoup's safelist Cleaner, then output the result with th:utext; never a regex.
- Add regression tests that render this card's payloads and parse the HTML with jsoup, asserting no script element, no on* attribute and no non-http(s) link, next to functional tests for <, & and quotes.
- Do not return exception messages to clients; keep server error detail in the logs.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
<!--/* Thymeleaf has no unescaped attribute processor: th:alt escapes even here. */-->
<img src="/avatar.svg" th:alt="${name}" width="64" height="64">
<h1 th:utext="${name}">Name</h1>
<p class="bio" th:utext="${bio}">Bio</p>With IMPL=vulnerable, 6 of 9 tests fail (4 exploit tests).
The fix: output encoding
<img src="/avatar.svg" th:alt="${name}" width="64" height="64">
<h1 th:text="${name}">Name</h1>
<p class="bio" th:text="${bio}">Bio</p>With IMPL=fixed, 9 of 9 tests pass.
The fix: URL scheme allowlist
<!--/* Escaping cannot make a URL safe: javascript:alert(1) has nothing to escape.
Link only to http and https URLs. */-->
<a th:if="${#strings.startsWith(website, 'https://') or #strings.startsWith(website, 'http://')}"
th:href="@{${website}}">Website</a>With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
<!--/* The bio may contain formatting, so print it raw, minus any <script> elements. */-->
<img src="/avatar.svg" th:alt="${name}" width="64" height="64">
<h1 th:text="${name}">Name</h1>
<p class="bio" th:utext="${bio.replaceAll('(?is)<script.*?>.*?</script>', '')}">Bio</p>With IMPL=wrong, 4 of 9 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: what a browser would run from the page must stay inert.
/** No script elements, no event-handler attributes, and only http(s) links. */
private static void assertNothingExecutable(Document page) {
assertThat(page.select("script")).as("script element").isEmpty();
List<String> handlers = page.getAllElements().stream()
.flatMap(e -> e.attributes().asList().stream()
.filter(a -> a.getKey().startsWith("on"))
.map(a -> e.tagName() + " " + a.getKey()))
.toList();
assertThat(handlers).as("event-handler attribute").isEmpty();
for (String href : links(page)) {
assertThat(urlScheme(href)).as("scheme of link %s", href).isIn("", "http", "https");
}
}
/**
* The scheme a browser uses for href, or "" for a relative URL. Like the URL Standard's
* parser, strip leading and trailing C0 controls and spaces (String.trim does exactly that)
* and delete every tab and newline before looking for the scheme.
*/
static String urlScheme(String href) {
String url = href.trim().replaceAll("[\t\n\r]", "");
Matcher scheme = Pattern.compile("^([A-Za-z][A-Za-z0-9+.-]*):").matcher(url);
return scheme.find() ? scheme.group(1).toLowerCase(Locale.ROOT) : "";
}
@Test
void exploitScriptElementInTheBioDoesNotRun() throws Exception {
assertNothingExecutable(profile("name", "Mallory", "bio", "<script>alert(document.domain)</script>"));
}
@Test
void exploitEventHandlerAttributeInTheBioDoesNotRun() throws Exception {
assertNothingExecutable(profile("name", "Mallory", "bio", "<img src=x onerror=alert(document.domain)>"));
}
@Test
void exploitQuoteInTheNameCannotBreakOutOfTheAltAttribute() throws Exception {
String name = "\" onerror=\"alert(document.domain)";
Document page = profile("name", name);
assertNothingExecutable(page);
assertThat(avatarAlt(page)).isEqualTo(name);
}
@Test
void exploitJavascriptWebsiteIsNotLinked() throws Exception {
assertNothingExecutable(profile("name", "Mallory", "website", "javascript:alert(document.domain)"));
}
@Test
void exploitTabInsideTheSchemeDoesNotSmuggleAJavascriptUrlThrough() throws Exception {
// Browsers delete tabs and newlines from a URL before parsing it.
assertNothingExecutable(profile("name", "Mallory", "website", "java\tscript:alert(document.domain)"));
}- th:utext is the Thymeleaf footgun, and so is the [(...)] inlining form; both skip escaping. th:text and [[...]] escape. Reserve the unescaped forms for HTML you produced.
- Thymeleaf escapes every attribute set with th:* (there is no th:utext for attributes), so quoted-attribute injection does not happen through th:title or th:attr — which is why this card's attribute-breakout payload is caught even by the vulnerable template.
- Thymeleaf link expressions @{...} resolve context paths and URL parameters; they do not sanitize the scheme, so @{${website}} still emits a javascript: link. The 'wrong' template trusts @{...} for exactly this and fails.
- HTML-escaping a URL does not disarm javascript:alert(1); it has no HTML metacharacters. URLs need a scheme allowlist, not escaping.
- A blocklist misses mixed case, a tab or newline inside the scheme, and other schemes (data:, vbscript:). Allowlist http/https instead.
- th:utext and th:href run under Thymeleaf's restricted expression mode, which blocks new, static T(...) access and ${param.*}, but that is defence in depth, not input validation — a plain context string such as javascript:alert(1) passes straight through.
- The web-layer slice test (@WebMvcTest) renders the real template, so it exercises the actual escaping; a controller unit test that never renders would miss the bug.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs java-spring does this for you).
- There is no Semgrep or Find Security Bugs rule for Thymeleaf XSS, so grep the templates for th:utext and review each by hand, and lean on the regression tests.
- Against your own endpoint, send bio=<img src=x onerror=alert(1)> and website=javascript:alert(1) and confirm the tag shows as text and the link is dropped.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | no Semgrep CE rule for Thymeleaf th:utext | Semgrep CE ships XSS rules for JSP and JSF (java/lang/security/audit/xss/jsp, .../jsf) but none for Thymeleaf. th:utext, th:text and th:href are not matched by any registry rule, so Semgrep adds nothing on this card and the regression tests are the control for this stack.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | java/xss | Likely to miss this card too. semmle/code/java/security/XSS.qll models sinks that write to a servlet, JSP or JSF response and treats HtmlUtils.htmlEscape as a sanitizer, but it does not model Thymeleaf th:utext, so a value rendered unescaped by a template is not flagged. This stack relies on the regression tests.Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs plugin) | XSS_JSP_PRINT / XSS_SERVLETXSS bug patterns | Find Security Bugs has XSS patterns for JSP and servlets but none for Thymeleaf templates, so it would not flag th:utext either.Checked: Bug list reviewed; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring MVC) with Thymeleaf 3.1.5 |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-thymeleaf | 4.1.1 (Thymeleaf 3.1.5.RELEASE) |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
| org.jsoup:jsoup (test only) | 1.23.2 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs xss/java-spring runs all three modes and checks the expected results.
Verified
- Toolchain
- java 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- thymeleaf 3.1.5.RELEASE
- junitJupiter 6.0.3
- jsoup 1.23.2
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 6 of 9 tests fail (4 exploit tests); wrong: 4 of 9 tests fail (3 exploit tests)
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross Site Scripting Prevention Cheat Sheet
- Thymeleaf: Using Thymeleaf — Unescaped Text (th:text vs th:utext)
- Thymeleaf: Appendix D — Expression Restrictions (restricted mode)
- Spring Security: protection against XSS and output encoding
- OWASP Java HTML Sanitizer (for rich text)
- jsoup: cleaning HTML with a Safelist
- WHATWG URL Standard: URL parsing (scheme, tab and newline removal)
Attack patterns: