Evaluate user templates with a restricted context: SimpleEvaluationContext.forReadOnlyDataBinding() allows only property access on your data, so T(...) type references, constructors and reflection fail. Never parse input into a StandardEvaluationContext. For plain values, bind them to a constant template. Do not denylist T( or Runtime; ''.class.forName(...) and string concatenation get past it.
- Find every ExpressionParser.parseExpression or JPQL/@Query SpEL whose expression or template is built from request data, and every getValue called with a StandardEvaluationContext.
- When the input is a value, keep the template constant and bind the value as a variable (#{#name}) or a property of the data object.
- When users genuinely author templates, evaluate them with SimpleEvaluationContext.forReadOnlyDataBinding() and expose only the data object; map SpelEvaluationException to a 400.
- Add regression tests with this card's payloads (a T(java.lang.System) type reference and a ''.class.forName reflection chain) next to functional tests for legitimate templates and non-ASCII data.
- Return a generic 400 and never echo SpEL error text; the message names the type or property it could not reach.
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
@Override
public String renderNotification(String template) {
// StandardEvaluationContext allows the whole language: type references
// (T(...)), constructors and method calls on any reachable object.
Expression expr = PARSER.parseExpression(template, TEMPLATE);
return expr.getValue(new StandardEvaluationContext(order), String.class);
}With IMPL=vulnerable, 5 of 10 tests fail (5 exploit tests).
The fix: fixed template and data
@Override
public String greeting(String name) {
// Constant template; the name is a bound variable, so a value like
// "#{T(...)}" is inserted literally instead of being evaluated.
Expression expr = PARSER.parseExpression("Hello #{#name}!", TEMPLATE);
SimpleEvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
ctx.setVariable("name", name);
return expr.getValue(ctx, String.class);
}With IMPL=fixed, 10 of 10 tests pass.
The fix: restricted context for user-authored templates
@Override
public String renderNotification(String template) {
Expression expr = PARSER.parseExpression(template, TEMPLATE);
SimpleEvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
// The order is the root object; only its properties are reachable.
return expr.getValue(ctx, order, String.class);
}With IMPL=fixed, 10 of 10 tests pass.
A fix that looks safe but is not
@Override
public String renderNotification(String template) {
rejectBlocked(template); // same denylist; see BLOCKED above
Expression expr = PARSER.parseExpression(template, TEMPLATE);
return expr.getValue(new StandardEvaluationContext(order), String.class);
}With IMPL=wrong, 3 of 10 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: each payload must stay inert; the secret marker must never appear.
@Test
void exploitGreetingTypeReferenceDoesNotReadTheSecret() throws Exception {
assertThat(greetingBody("#{T(java.lang.System).getenv('SSTI_SECRET')}")).doesNotContain(MARKER);
}
@Test
void exploitGreetingReflectionBypassDoesNotReadTheSecret() throws Exception {
assertThat(greetingBody("#{''.class.forName('java.lang.System').getMethod('getenv').invoke(null).get('SSTI_SECRET')}"))
.doesNotContain(MARKER);
}
@Test
void exploitRenderTypeReferenceDoesNotReadTheSecret() throws Exception {
assertThat(renderBody("#{T(java.lang.System).getenv('SSTI_SECRET')}")).doesNotContain(MARKER);
}
@Test
void exploitRenderReflectionBypassDoesNotReadTheSecret() throws Exception {
assertThat(renderBody("#{''.class.forName('java.lang.System').getMethod('getenv').invoke(null).get('SSTI_SECRET')}"))
.doesNotContain(MARKER);
}
@Test
void exploitRenderConcatenationBypassDoesNotReadTheSecret() throws Exception {
assertThat(renderBody("#{''.class.forName('java.lang.Sys'+'tem').getMethod('getenv').invoke(null).get('SSTI_SECRET')}"))
.doesNotContain(MARKER);
}- parseExpression(input) evaluated with a StandardEvaluationContext gives the whole language: T(...) type references, new constructors and method calls on any reachable object, which is remote code execution.
- SimpleEvaluationContext.forReadOnlyDataBinding() registers only a read-only property accessor and no method resolvers, and forbids type references, constructors and bean references, so T(...), ''.class, forName, getMethod and invoke all fail.
- TemplateParserContext (the #{ } delimiters) only marks where expressions begin; it does not restrict what they can do. The restriction comes entirely from the EvaluationContext.
- A bound variable (#{#name}) or a property of the data root (#{customer}) is safe; concatenating input into the template string is injection.
- Thymeleaf has the same trap: expression preprocessing __${...}__ re-parses its contents as SpEL, so never build that from input. @Query and Spring Data JpaSort.unsafe are other SpEL sinks.
- Do not denylist: ''.class.forName('java.lang.Sys' + 'tem') reaches System with no T( and no literal class name, and '+' concatenation rebuilds any blocked token at evaluation time.
- Spring Boot 4 / Spring Framework 7 test support needs JUnit Jupiter 6 (the SpringExtension requires 6.0 or later), so this card uses the Boot-managed JUnit 6.0.3.
- 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).
- Run semgrep scan --config r/java.spring.security.audit.spel-injection.spel-injection src/main/java; expect it to flag all six parseExpression sites, including the two in the fixed renderer (the audit-rule note in scannerRules).
- Against your own endpoint, post a template of #{T(java.lang.System).getenv('SECRET')} and a ''.class.forName reflection chain and confirm no secret comes back and SimpleEvaluationContext rejects both with a 400.
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 | java.spring.security.audit.spel-injection.spel-injection | Flagged all six SpEL evaluation sites: 2 in VulnerableTemplateRenderer, 2 in WrongTemplateRenderer and 2 in FixedTemplateRenderer. The two fixed hits evaluate with SimpleEvaluationContext, which is the mitigation; it is an audit rule that cannot see the context type, so review and justify those two.Checked: Semgrep 1.180.0 (CE) (semgrep scan --config r/... src/main/java) |
| CodeQL | java/spel-expression-injection | Models an expression parsed by SpelExpressionParser and evaluated against a StandardEvaluationContext as a SpEL-injection sink, with @RequestParam as a remote source, so this query is expected to flag the vulnerable and wrong renderers. SimpleEvaluationContext (the fixed renderer) is the documented mitigation and is not reported.Checked: github/codeql main; not executed |
| CodeQL | java/server-side-template-injection | Companion CWE-094 query for template engines whose templates are built from untrusted input; listed for coverage alongside java/spel-expression-injection.Checked: github/codeql main; not executed |
| Find Security Bugs (SpotBugs plugin) | SPEL_INJECTIONPotential code injection when using Spring Expression | Not run on this card.Checked: Bug pattern listed on the Find Security Bugs site; 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 Framework 7.0.9, SpEL / spring-expression) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework:spring-expression | 7.0.9 (managed by Spring Boot) |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
| tools.jackson.core:jackson-databind (the pom overrides Boot's 3.1.5) | 3.1.7 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the template-injection/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c mvn -B -o -q test
# Expected: a test fails
IMPL=wrong SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c 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 template-injection/java-spring runs all three modes and checks the expected results.
Verified
- Toolchain
- java OpenJDK 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- springFramework 7.0.9
- junitJupiter 6.0.3
- jackson 3.1.7
- Matrix
- fixed: 10 of 10 tests pass; vulnerable: 5 of 10 tests fail (5 exploit tests); wrong: 3 of 10 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
- CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
- Spring Framework: SpEL EvaluationContext (SimpleEvaluationContext vs StandardEvaluationContext)
- Spring Framework API: SimpleEvaluationContext
- Spring Framework: SpEL language reference (type operator T(...), templates)
- Find Security Bugs: SPEL_INJECTION
- OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
- OWASP Top 10:2025 A05 Injection
Attack patterns: