Spring Boot Thymeleaf Template Injection: OWASP Remediation 2026
Thymeleaf's fragment expression syntax has a property most developers don't expect: if user-controlled input reaches the view name string before the template resolver processes it, the engine evaluates Spring Expression Language (SpEL) inline, server-side, before any output escaping runs. A single endpoint that does return "welcome::" + name is enough for an attacker to execute arbitrary shell commands by passing __${T(java.lang.Runtime).getRuntime().exec('id')}__::.x as the name parameter. This bug class has appeared in CVE-2023-38286 and similar disclosures, and OWASP A03:2021 (Injection) explicitly covers it. The 2026 ASVS V5 controls tighten the requirements further.
How Thymeleaf Template Injection Works in Spring Boot
Thymeleaf resolves view names through a configurable ITemplateResolver. When the engine encounters a fragment expression of the form templatename::fragmentname, it evaluates both halves as SpEL before fetching the template. The preprocessing marker __${...}__ forces expression evaluation at parse time, so even values that you expect to be treated as plain strings get executed if they arrive inside that syntax.
The canonical vulnerable pattern looks like this:
// Vulnerable controller — user input concatenated directly into the view name
@Controller
public class WelcomeController {
@GetMapping("/welcome")
public String welcome(@RequestParam String name, Model model) {
// name is attacker-controlled; Thymeleaf evaluates the full string as a view expression
return "welcome::" + name;
}
}
An attacker sends:
GET /welcome?name=__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x
Thymeleaf's FragmentExpression parser receives welcome::__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x, preprocesses the __${...}__ block as SpEL, and calls Runtime.exec() before the template is ever read from disk. The response carries the OS-level process output. No authentication required, no special headers.
The same class of bug appears when user input ends up in a ModelAndView constructor argument, in a redirect string built via concatenation, or in any @{...} Thymeleaf link expression that gets passed a raw parameter without the |...| literal syntax.
For a deeper walkthrough of how this attack primitive generalizes across engines, the server-side template injection lesson on Code Review Lab covers FreeMarker, Pebble, and Velocity variants alongside Thymeleaf, which is useful context when you're auditing a codebase that uses multiple renderers.
The Fix: Safe View Resolution and Expression Restriction
The core fix has two parts: stop concatenating user input into view names, and configure the template engine to restrict what SpEL can reach at runtime.
Part 1: Static view names with model attributes
// Patched controller — static view name, user input only ever touches the model
@Controller
public class WelcomeController {
@GetMapping("/welcome")
public String welcome(@RequestParam String name, Model model) {
// name goes into the model, never into the view name string
model.addAttribute("name", name);
return "welcome"; // static literal — no concatenation, no expression evaluation
}
}
In the template, reference it as th:text="${name}". Thymeleaf HTML-escapes model attributes before insertion by default, so <script> in name renders as <script>.
Part 2: Restrict SpEL access at the engine level
Thymeleaf 3.1 introduced IExpressionObjectFactory restrictions. Configure your SpringTemplateEngine bean to disable the reflection-capable expression utilities that make T(...) calls possible:
@Configuration
public class ThymeleafSecurityConfig {
@Bean
public SpringTemplateEngine templateEngine(ITemplateResolver resolver) {
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.setTemplateResolver(resolver);
// Disable SpEL compilation — compiled expressions bypass some sandbox checks
engine.setEnableSpringELCompiler(false);
// Register a restricted dialect that removes the #execInfo and #request
// utility objects; attackers use these to reach servlet internals
engine.addDialect(new RestrictedSpringDialect());
return engine;
}
}
RestrictedSpringDialect is a thin IDialect implementation that overrides getExpressionObjectFactory() and returns only the objects your templates actually need (typically #strings, #dates, #numbers). Every object you remove from that factory is an attack surface you close.
The concern about command injection via Runtime gadgets is real here: even with static view names, if the T(java.lang.Runtime) expression object is reachable inside a template that renders user-supplied fragment parameters, you still have a problem. Restricting the factory removes the foothold.
Note: setEnableSpringELCompiler(false) is the default in Spring Boot's auto-configuration since Boot 2.6, but it is easy to accidentally re-enable it in a SpringTemplateEngine bean you define yourself, overriding the auto-config. Check your context for duplicate bean definitions.
Hardening Thymeleaf Configuration for Production
Beyond fixing individual controllers, the template engine configuration itself should be locked down at the application level.
# application.yml — production Thymeleaf hardening
spring:
thymeleaf:
mode: HTML # Never LEGACYHTML5 — that parser relaxes escaping rules
encoding: UTF-8
cache: true # Disable in dev only; production cache prevents bypass via cache-busting params
prefix: classpath:/templates/ # Fixed prefix; never derive prefix from request parameters
suffix: .html # Fixed suffix; prevents template extension probing
enable-spring-el-compiler: false # Compiled SpEL bypasses some expression restrictions
check-template-location: true # Fail fast on startup if templates directory is missing
A few specifics worth calling out:
LEGACY_HTML5 mode. The LEGACYHTML5 parser (backed by NekoHTML) was deprecated in Thymeleaf 3.0 and removed in 3.1, but some older Spring Boot 2.x projects still have the nekohtml dependency on the classpath and mode: LEGACYHTML5 set explicitly. That parser silently repairs malformed HTML in ways that can break encoding guarantees. Confirm it's gone: mvn dependency:tree | grep nekohtml should return nothing.
Template prefix injection. If your application resolves templates from a prefix that incorporates any request parameter (a pattern that appears in multi-tenant apps that load tenant-specific templates), path traversal through the prefix is possible independent of expression injection. The prefix and suffix in application.yml must be literals.
Content-Type enforcement. Set spring.mvc.contentnegotiation.favor-parameter=false and configure produces = MediaType.TEXT_HTML_VALUE on your @RequestMapping annotations. This prevents an attacker from negotiating a different content type that might cause Thymeleaf to switch rendering context.
Cache-bypass with dev-mode. The cache: false setting you use locally disables the template cache, which causes Thymeleaf to re-read and re-evaluate templates on every request. Never ship cache: false to production; it's primarily a performance issue, but in some configurations it also means modified-on-disk templates take effect immediately, which matters if you have any write-accessible template path.
You can review how these configuration properties interact with the full Spring Boot Thymeleaf auto-configuration at the Code Review Lab homepage, which also indexes labs for other Java frameworks.
Detecting SSTI in Code Review and CI
The grep pattern that catches the majority of real-world cases is simple: find any return statement inside a @Controller or @RestController class where a string literal is concatenated with a variable.
# Find controller methods returning concatenated view names
grep -rn --include="*.java" \
'return\s\+\"[^\"]*\"\s*+\s*' \
src/main/java/
That will produce false positives (any string concatenation in a return statement), so narrow it with a context grep for @Controller in the same file:
grep -rl '@Controller' src/main/java/ | \
xargs grep -n 'return\s\+"[^"]*"\s*+\s*'
For CI, a Semgrep rule is more precise:
# semgrep-ssti.yml
rules:
- id: thymeleaf-view-name-injection
patterns:
- pattern: |
@Controller
class $CLASS {
...
$TYPE $METHOD(..., $USERINPUT, ...) {
...
return "..." + $USERINPUT;
}
}
message: "User input concatenated into Thymeleaf view name — potential SSTI"
languages: [java]
severity: ERROR
metadata:
cwe: "CWE-94"
owasp: "A03:2021"
Run it with semgrep --config semgrep-ssti.yml src/. The pattern matches when $USERINPUT is a parameter that flows from a method argument directly into the return string. It will miss multi-step flows (assign to local variable, then return), so supplement it with a CodeQL taint-flow query if your CI budget allows.
For CodeQL, the relevant source nodes are @RequestParam, @PathVariable, @RequestHeader, and @RequestBody-annotated parameters. The sink is any argument to ModelAndView(String, ...) or any string returned from a @RequestMapping-annotated method. CodeQL's Java standard library already models these as sources; you mainly need to write the sink predicate for the view-name return.
The same string-concatenation injection patterns that CodeQL catches in SQL queries (covered in the string-concatenation injection patterns lab) apply structurally here. If your query pipeline already flags SQL injection sources, adding a Thymeleaf view-name sink predicate is a small delta.
Testing the Patch with Reproducible Payloads
Unit tests alone won't catch this because Thymeleaf expression evaluation happens inside the full template rendering pipeline. You need MockMvc with a real template resolver wired in.
@SpringBootTest
@AutoConfigureMockMvc
class WelcomeControllerSstiTest {
@Autowired
private MockMvc mockMvc;
@Test
void sstiPayloadShouldNotEvaluate() throws Exception {
// Classic preprocessing payload that evaluates 7*7 if SpEL is active
String payload = "__${7*7}__::.x";
mockMvc.perform(get("/welcome").param("name", payload))
.andExpect(status().isOk())
// Response must contain the literal string, not the evaluated result "49"
.andExpect(content().string(containsString(payload.replace("__", ""))))
.andExpect(content().string(not(containsString("49"))));
}
@Test
void rcePayloadShouldNotExecute() throws Exception {
// Runtime.exec gadget — response must not contain command output
String payload = "__${T(java.lang.Runtime).getRuntime().exec('id')}__::.x";
mockMvc.perform(get("/welcome").param("name", payload))
.andExpect(status().isOk())
// Verify the payload is HTML-escaped in the output, not executed
.andExpect(content().string(not(containsString("uid="))));
}
@Test
void htmlIsEscapedInModelAttribute() throws Exception {
mockMvc.perform(get("/welcome").param("name", "<script>alert(1)</script>"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("<script>")))
.andExpect(content().string(not(containsString("<script>"))));
}
}
Note: containsString(payload.replace("__", "")) accounts for the fact that Thymeleaf may strip preprocessing markers even when it doesn't evaluate the expression. Assert on the significant part of the payload (${7*7} or T(java.lang.Runtime)) rather than the full string.
Run these tests against both the vulnerable commit and the patched commit as part of your PR regression gate. A test that passes on the patched version and fails (by finding 49 in the response) on the vulnerable version is a reliable exploit-as-test artifact that documents the fix.
OWASP 2026 Checklist for Spring Boot Template Safety
The following maps directly to OWASP ASVS 5.0 (V5, Validation, Sanitization and Encoding) and the A03 Injection category. Use this as a PR review checklist.
View resolution
- [ ] No
@Controllermethod returns a view name that includes a request parameter, path variable, header, or cookie value via string concatenation. - [ ] No
ModelAndViewconstructor is called with a view name derived from user input. - [ ] Redirects use
RedirectAttributesor static redirect strings, not"redirect:" + userInput.
Expression engine configuration (ASVS V5.2)
- [ ]
spring.thymeleaf.enable-spring-el-compilerisfalsein all deployment environments. - [ ]
spring.thymeleaf.modeisHTML, notLEGACYHTML5orXML(unless XML output is explicitly required and audited). - [ ] A custom
SpringTemplateEnginebean, if present, does not accidentally override Boot's default safe settings. - [ ] The
IExpressionObjectFactoryin use does not expose#request,#response,#session, or#applicationunless your templates require them and those objects have been reviewed.
Template content (ASVS V5.3)
- [ ] All user-supplied values are rendered via
th:textorth:utextwith deliberate choice:th:text(escaped) is the default;th:utext(unescaped) requires a documented justification. - [ ] No template uses
th:fragmentnames derived from query parameters.
Dependency hygiene
- [ ]
org.thymeleaf:thymeleafis 3.1.2.RELEASE or later (3.1.x removed several expression bypass vectors present in 3.0.x). - [ ]
nekohtmlis not on the classpath. - [ ] Dependency check (OWASP Dependency-Check or Dependabot) runs in CI and blocks merges on CVSS >= 8.0 findings in Thymeleaf or Spring Web.
Testing (ASVS V5.5)
- [ ] MockMvc integration tests assert that
__${7*7}__andT(java.lang.Runtime)payloads do not evaluate. - [ ] XSS payloads in model attributes render as HTML entities in response bodies.
- [ ] Test suite runs against the production Spring Boot version, not a snapshot.
These controls map to OWASP ASVS 5.0 requirements V5.2.1 (input validation), V5.3.1 (output encoding context), and V5.5.3 (template injection prevention). A03:2021 Injection covers the primary risk; A06:2021 Vulnerable and Outdated Components covers the dependency hygiene items.
If you ship one thing from this article before the next sprint ends, make it the Semgrep rule in CI. It takes fifteen minutes to integrate, catches new vulnerable endpoints the moment they're written, and doesn't depend on anyone remembering to run a manual audit. The MockMvc tests should follow immediately after, locked to your current patch, so any future regression is caught at PR time rather than in a pentest report.
Top comments (0)