By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.
18px_cookie
e-remove
Blog
Glossary
Customer Story
Video
eBook / Report
Solution Brief

It's About Thyme: How a Whitespace Character Broke Thymeleaf's Expression Sandbox (CVE-2026-40478)

A critical severity Thymeleaf sandbox bypass lets attackers run arbitrary code in Spring apps. Here's how it works, what's at risk, and how to fix it.

A critical severity Thymeleaf sandbox bypass lets attackers run arbitrary code in Spring apps. Here's how it works, what's at risk, and how to fix it.

A critical severity Thymeleaf sandbox bypass lets attackers run arbitrary code in Spring apps. Here's how it works, what's at risk, and how to fix it.

Written by
Cris Staicu
Cris Staicu
Published on
April 16, 2026
Updated on
April 16, 2026

A critical severity Thymeleaf sandbox bypass lets attackers run arbitrary code in Spring apps. Here's how it works, what's at risk, and how to fix it.

A critical severity Thymeleaf sandbox bypass lets attackers run arbitrary code in Spring apps. Here's how it works, what's at risk, and how to fix it.

TL;DR

Thymeleaf, the most widely used template engine in the Java Spring ecosystem, has a critical vulnerability (CVSS 9.1) in 3.1.3 and earlier versions that can let attackers execute arbitrary code on affected servers. The engine's built-in security checks had two blind spots — a whitespace parsing gap and an incomplete blocklist — that together allow an attacker to bypass protections and achieve server-side template injection (SSTI), arbitrary file writes, and potentially full remote code execution (RCE).

Exploitation is straightforward. Attackers don't need privileged access or to modify files on the server. They only need to control input that the application passes into Thymeleaf's expression engine, a common pattern in web applications. The vulnerability affects any application running Thymeleaf 3.1.3 or earlier with Spring, which includes a large share of enterprise Java deployments.

Thymeleaf is the default template engine for Spring Boot, the most widely adopted Java web framework in enterprise environments, making the potential blast radius significant. The combination of critical severity, low exploit complexity, and broad deployment means this vulnerability warrants immediate patching, not scheduled maintenance.

The fix in version 3.1.4 closes these specific gaps but uses an expanded blocklist rather than a strict allowlist, meaning the attack surface is reduced, not eliminated. Organizations should upgrade immediately and audit their applications to ensure user input is never passed directly into template expressions.

  • CVE: CVE-2026-40478 (GHSA-xjw8-8c5c-9r79)
  • Severity: Critical, CVSS 9.1
  • Root cause: Improper neutralization (CWE-917 / CWE-1336) - protections existed, but whitespace normalization mismatches and an incomplete type blocklist allowed unauthorized expression execution
  • Threat model: Attacker does not need to edit template files on disk; they need control of inputs that become expression text, view names, or similar parsed channels
  • Remediation: Upgrade to 3.1.4.RELEASE (and matching thymeleaf-spring5 / thymeleaf-spring6 artifacts). There is no workaround

Affected versions

Package Name Advisory Version Published (UTC) Status Severity
org.thymeleaf:thymeleaf CVE-2026-40478 ≤ 3.1.3.RELEASE Apr 15, 2026 Patched Critical
org.thymeleaf:thymeleaf-spring5 CVE-2026-40478 ≤ 3.1.3.RELEASE Apr 15, 2026 Patched Critical
org.thymeleaf:thymeleaf-spring6 CVE-2026-40478 ≤ 3.1.3.RELEASE Apr 15, 2026 Patched Critical

Fixed version: Upgrade all three artifacts to 3.1.4.RELEASE or newer.

<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf</artifactId>
    <version>3.1.4.RELEASE</version>
</dependency>

Introduction

Thymeleaf is the dominant server-side Java template engine in the Spring ecosystem. Templates use Standard Expressions: ${…}, *{…}, @{…}, fragments, preprocessors, and more. "Variable" fragments do not embed a mini-language invented by Thymeleaf; they delegate to OGNL (default core integration) or Spring Expression Language (SpEL) when you use Thymeleaf with Spring. That means the security boundary is Thymeleaf's parsing and execution pipeline plus OGNL/SpEL semantics, not just HTML.

For years Thymeleaf has shipped defense in depth: execution modes (NORMAL vs RESTRICTED), substring scans for dangerous patterns, AST walks in some paths, and ACL-style rules in ExpressionUtils (package prefixes, allowed types, forbidden members). CVE-2026-40478 is a reminder that heuristic layers can diverge from real parsers: when they do, SSTI follows.

Here is how Thymeleaf expressions are evaluated:

  1. Parse. Template text is parsed into an AST of Expression subclasses (VariableExpression, SelectionVariableExpression, LinkExpression, FragmentExpression, literals, etc.).
  2. Execute with a context. Expression.execute(context, StandardExpressionExecutionContext) dispatches to evaluators. Variable and selection expressions go through IStandardVariableExpressionEvaluator (OGNL or SpEL).
  3. Execution mode. StandardExpressionExecutionContext carries flags such as restrictVariableAccess and restrictExternalAccess. RESTRICTED sets both to true: stricter rules for request parameters, "external" constructs (new, static T(…), param, and, after the fix, patterns like SpEL @ bean references), and access to powerful #… expression objects in those contexts. NORMAL does not apply those Thymeleaf-imposed extra limits (ACLs in ExpressionUtils still apply at class/member resolution).
  4. Not one global switch. Whether an attribute runs in RESTRICTED or NORMAL depends on which processor handles it (preprocessing and several built-in features use RESTRICTED; many standard attribute processors default to NORMAL). Security posture is per evaluation site, not "one flag for the whole app."
  5. ACL after parsing. Even in permissive modes, resolving classes and calling methods goes through forbidden type/member logic (ExpressionUtils.isTypeForbidden, isMemberForbidden, and parallel hooks in Spring's ThymeleafEvaluationContext). That is a large deny-by-default package list plus explicit allowlists, not a formal proof of safety, but a substantial net.

The vulnerability

The public advisory states that Thymeleaf failed to properly neutralize specific syntax patterns, so protections could be bypassed and unauthorized expressions could run, i.e., CWE-917 / CWE-1336 style expression and template injection.

Thymeleaf 3.1.3 applied two layers of defense against hostile expressions in SpEL mode:

  1. A string-level pre-check (containsSpELInstantiationOrStaticOrParam) that scanned the raw expression text for dangerous tokens - specifically “new “ (the keyword followed by an ASCII space), T( (SpEL type references), and @ (SpEL bean references in some code paths). If a token was found, evaluation was rejected before SpEL ever parsed the expression.
  2. An ACL-based type/member policy enforced at SpEL resolution time. A blocklist in ExpressionUtils.isTypeBlockedForAllPurposes blocked classes whose package started with java. (except java.time.*) from being used with T() or new. A parallel member-access policy (ThymeleafEvaluationContextACLMethodResolver / ACLPropertyAccessor) blocked specific dangerous members like forName and classLoader on java.lang.Class.

Both layers had gaps:

  • Whitespace normalization mismatch. The string check looked for “new “ (keyword + ASCII space 0x20). But SpEL's parser accepts any whitespace between new and the class name, including tab (0x09), newline (0x0A), and other control characters. Inserting a tab (new\t) bypassed the string check completely while still parsing as a valid constructor invocation in SpEL. The fix (commit c115713) addressed this by extending ExpressionUtils.normalize() to fold tabs, newlines, and other whitespace variants before the check runs, and by expanding the check itself into containsExternalAccess().
  • Type blocklist limited to java.* packages. The isTypeBlockedForAllPurposes method only blocked types whose fully-qualified names started with java. (minus java.time.*). Types from org.springframework.*, ognl.*, javax.*, or any other package were not blocked. Since typical Spring applications have spring-core on the classpath, classes like org.springframework.core.io.FileSystemResource were freely constructable, and that class can create arbitrary files on disk. The fix (commit 85eb7e0) replaced the blocklist with a allowlist (ALLOWED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES), so only explicitly approved type prefixes pass the check.
  • Unrestricted expression objects. Variables like #ctx, #vars, #root, #this, and #execInfo were accessible in restricted evaluation mode. While these did not directly yield RCE in our testing (member-access ACLs blocked the most obvious chains), they expand the attack surface and can serve as pivot points for future bypasses. The fix (commit c115713) added checkRestrictedVariables() to walk the SpEL AST and reject these variable references.
  • Missing @bean detection. SpEL's @beanName syntax for resolving Spring beans was not consistently flagged by the pre-check, especially in OGNL-focused code paths. The expanded containsExternalAccess() now flags @ followed by identifier characters.
  • View-name correlation gaps. SpringRequestUtils.checkViewNameNotInRequest compared the view name against URL parameters but not cookies or headers, and pattern detection missed Thymeleaf-specific delimiters like __…__ (preprocessing) and |…| (literal substitution). The fix extended both.

None of this requires the attacker to replace the application's template files. It requires application-level mistakes: concatenating user input into ${…}, driving dynamic view or fragment resolution from untrusted input, or otherwise feeding the parser/evaluator layer with attacker-controlled text.

The exploit

After investigating the advisory and the relevant commits, we created a minimal proof of concept, which we explain below. The setup: a SpringTemplateEngine in TEXT mode with a StringTemplateResolver, a plausible stand-in for any code path where user-controlled text ends up inside ${…}.

The payload:

[[${new    org.springframework.core.io.FileSystemResource('success.flag').getOutputStream()}]]

(The whitespace between new and org is a tab character, 0x09, not a regular space.)

Step-by-step breakdown:

  1. String-level pre-check. Thymeleaf's containsSpELInstantiationOrStaticOrParam scans the expression for “new “ (new + space), T(, and @. The expression contains new\t (new + tab)- the check does not match. The expression passes.
  2. SpEL parsing. Spring's SpEL parser receives new\torg.springframework.core.io.FileSystemResource('success.flag').getOutputStream(). SpEL treats the tab as ordinary whitespace and parses this as: construct a FileSystemResource with the string argument "success.flag", then call .getOutputStream() on the result.
  3. Type-access check. SpEL's constructor resolver asks Thymeleaf's ThymeleafEvaluationContextACLTypeLocator whether org.springframework.core.io.FileSystemResource is allowed. The 3.1.3 blocklist calls sTypeBlockedForAllPurposes, which checks whether the type name starts with java.. It does not, it starts with org.springframework, so the type passes.
  4. Construction. new FileSystemResource("success.flag") succeeds. The object wraps the path ./success.flag relative to the JVM's working directory.
  5. Method call. .getOutputStream() is invoked. Internally, FileSystemResource calls Files.newOutputStream(this.filePath), which creates the file on disk and returns an OutputStream. The file now exists.
  6. Result rendering. The OutputStream object reference is coerced to a string and rendered into the template output. The attacker does not need to call .close(), the file was already created as a side effect of opening the stream.

The same new<TAB> trick works for any class on the classpath outside java.*. With the right class, an attacker can escalate from file creation to full remote code execution, for example, instantiating a ProcessBuilder wrapper from a third-party library, or leveraging Spring's own GenericApplicationContext to register and invoke arbitrary beans.

Why earlier defenses didn't help:

Defense layer Why it failed
String pre-check (new) Tab character not matched
Type blocklist (java.*) Spring classes not covered
Member-access ACL Never reached, FileSystemResource methods aren't in the deny list
OGNL restrictions Irrelevant; exploit uses SpEL via SpringTemplateEngine

The fix 

The patch set is defense in depth, not a single line. Here is how each change relates to the exploit described above:

  1. Whitespace normalization and keyword detection (commit c115713). ExpressionUtils.normalize() now folds tabs, newlines, carriage returns, and other non-space whitespace into regular spaces before running checks. The old containsSpELInstantiationOrStaticOrParam was renamed and expanded into containsExternalAccess(), which matches new followed by any whitespace character, not just ASCII space. This directly kills the new<TAB> bypass: after normalization, new\torg.springframework… becomes new org.springframework…, which the check matches and rejects.
  2. Extended type deny-list (commit 85eb7e0). The old isTypeBlockedForAllPurposes blocked types starting with java. (except java.time.*), plus a handful of other prefixes (javax.*, jakarta.*). The fix extended BLOCKED_TYPE_REFERENCE_PACKAGE_NAME_PREFIXES with additional dangerous prefixes: org.springframework.core.,    org.springframework.context., and several others.   ALLOWED_ALL_PURPOSES_PACKAGE_NAME_PREFIXES (containing only java.time.) is an exception within the blocked java.* rule, not a default-deny gate. This is still a deny-list, not a allowlist: types from packages not explicitly enumerated in the deny-lists pass through freely. The fix blocks the exploit described abovebecause org.springframework.core.* was added, but others remain unblocked. Method naming was also clarified (isTypeAllowed → isTypeForbidden, isMemberAllowed → isMemberForbidden) to make the intended semantics explicit (commit 76680a7).
  3. Restricted expression objects (commit c115713). SpEL: a new checkRestrictedVariables() method walks the parsed AST for VariableReference nodes and forbids dangerous #-prefixed names (#ctx, #vars, #root, #this, #execInfo) when the expression runs in restricted mode. OGNL: parallel logic walks ASTVarRef; OGNLExpressionObjectsWrapper refuses restricted expression objects when the restriction flag is set; Spring's lookupVariable rejects those names under restriction. This closes pivot-point chains that could use context objects to reach class loaders, evaluation contexts, or other powerful APIs.
  4. Spring MVC integration (commit c115713, commit 333cd7c). SpringRequestUtils.checkViewNameNotInRequest now inspects cookies and headers in addition to URL parameters; containsExpression recognizes Thymeleaf-specific delimiters (__…__ preprocessing, |…| literal substitution) as indicators of injected expressions. Optional allowedClassOverridesForViews lets applications explicitly allow specific classes in view SpEL when the stricter defaults break legitimate functionality, treating that API as a security-sensitive configuration.
  5. No workaround in the advisory beyond upgrade and not passing unvalidated input into the engine's expression surface.

Both the string-normalization fix and the extended type deny-list independently block the specific exploit above. However, because the type policy remains a deny-list rather than an allow-list, classes in non-enumerated packages can still be instantiated, the attack surface is reduced, not eliminated.

Mitigation

  1. Upgrade thymeleaf, thymeleaf-spring5, and/or thymeleaf-spring6 to 3.1.4.RELEASE or newer.
  2. Never build expression strings from request data. Never use user input to choose view names, fragment specifiers, or template names without strict allowlists.
  3. Audit uses of setAllowedClassOverridesForViews (and similar): allowlist only what you need.
  4. Assume RESTRICTED vs NORMAL varies by processor, do not rely on "Thymeleaf blocks everything dangerous everywhere."
  5. Reachability analysis for transitive Thymeleaf versions.

Takeaways for developers and security teams

  1. String checks diverge from real parsers. The new<TAB> bypass is a textbook example: a security filter that matches “new “ (one specific whitespace character) while the downstream parser accepts any whitespace. Whenever you build a pre-check that rejects patterns before a parser runs, test it against the parser's full grammar, not just the common cases. Normalize first, check second.
  2. Deny-lists rot; allow-lists degrade gracefully. The java.*-only type blocklist was reasonable when Thymeleaf was the only library on the classpath. The 3.1.4 fix extended the deny-list with many more prefixes (org.springframework.core.*, Jackson, HikariCP, etc.), but it is still fundamentally a deny-list: packages not explicitly enumerated pass through. A true allow-list, where only explicitly approved type prefixes are permitted and everything else is blocked by default, would close this class of gaps. Until then, new classpath additions and overlooked packages remain potential gadgets.
  3. Template engines are code execution surfaces. The CVE does not assume the attacker uploads a malicious .html to the server. It assumes unvalidated input is passed directly to the template engine in a way that influences parsed expressions. Normal model attributes used as values in safe positions (e.g. th:text="${user.name}" with user.name as data) are not the scenario; the dangerous pattern is user-controlled fragments inside ${…} or dynamic view/fragment resolution driven by untrusted input. Patching closes known bypasses; design (never treat user input as expression syntax) closes the class of bugs.

Conclusion

CVE-2026-40478 is a critical Thymeleaf issue: sandbox-style protections were bypassed because neutralization did not cover the same surface area as the real SpEL/OGNL parsers. A single tab character between new and a class name, combined with a type blocklist that only covered java.* packages, was enough to instantiate arbitrary Spring classes and create files on disk. The 3.1.4 release closes this with whitespace normalization before keyword checks, an extended blocklist-based type policy, restricted expression objects, and expanded view-name correlation. Upgrade promptly, and treat expression text as code, not user "data," unless you fully control and trust its provenance.

References

  1. GitHub Advisory Database, CVE-2026-40478 (GHSA-xjw8-8c5c-9r79): https://github.com/advisories/GHSA-xjw8-8c5c-9r79
  2. Thymeleaf project: https://www.thymeleaf.org/
  3. CWE-917 - Expression Language Injection: https://cwe.mitre.org/data/definitions/917.html
  4. CWE-1336 - Improper Neutralization of Special Elements Used in a Template Engine: https://cwe.mitre.org/data/definitions/1336.html

Find out More

The Challenge

The Solution

The Impact

Book a Demo

Book a Demo

Book a Demo

Welcome to the resistance
Oops! Something went wrong while submitting the form.

Book a Demo

Book a Demo

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Book a Demo