Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix server-side template (SpEL) injection in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks3 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. Find every ExpressionParser.parseExpression or JPQL/@Query SpEL whose expression or template is built from request data, and every getValue called with a StandardEvaluationContext.
  2. 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.
  3. When users genuinely author templates, evaluate them with SimpleEvaluationContext.forReadOnlyDataBinding() and expose only the data object; map SpelEvaluationException to a 400.
  4. 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.
  5. Return a generic 400 and never echo SpEL error text; the message names the type or property it could not reach.

Vulnerable, fixed and wrong

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

Vulnerable…/templating/impl/VulnerableTemplateRenderer.javaJava
@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

Fixed…/templating/impl/FixedTemplateRenderer.javaJava
@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

Fixed…/templating/impl/FixedTemplateRenderer.javaJava
@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

Wrong fix…/templating/impl/WrongTemplateRenderer.javaJava
@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 regression test

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.

Test…/example/templating/RenderApiTest.javaJava
// 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);
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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).
  3. 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.

Scanner rules

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.

Scanner rules for Fix server-side template (SpEL) injection in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.spring.security.audit.spel-injection.spel-injectionFlagged 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)
CodeQLjava/spel-expression-injectionModels 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
CodeQLjava/server-side-template-injectionCompanion 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 ExpressionNot run on this card.Checked: Bug pattern listed on the Find Security Bugs site; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, SpEL / spring-expression)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework:spring-expression7.0.9 (managed by Spring Boot)
org.springframework.boot:spring-boot-starter-webmvc-test4.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

Run the tests

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).

Shell
mvn -B -q test
Shell
# 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 test

Each 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.

Download the project ZIP, 22 KB

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

  1. CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
  2. Spring Framework: SpEL EvaluationContext (SimpleEvaluationContext vs StandardEvaluationContext)
  3. Spring Framework API: SimpleEvaluationContext
  4. Spring Framework: SpEL language reference (type operator T(...), templates)
  5. Find Security Bugs: SPEL_INJECTION
  6. OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
  7. OWASP Top 10:2025 A05 Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.