Java and Spring Security
Java's defects are stable and well catalogued, which makes them fast to review and easy to miss — the injection is usually a string that predates the framework, and the deserialization is usually a utility class nobody has opened since 2014. Spring adds its own small set on top: an expression language, a binding mechanism, and a proxy that silently does nothing when you call it from the wrong side.
Review the version you deploy
JDBC and JPA injection
The framework does not remove the defect; it removes the reason for the defect. JdbcTemplate will happily execute
a concatenated string, and EntityManager.createQuery will happily parse one. What changes is that the safe call
is now shorter than the unsafe one.
public User find(String name) {
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
return jdbc.queryForObject(sql, User.class);
}
// JPA escape hatches behave the same way
String jpql = "FROM User u WHERE u.name = '" + name + "'";
em.createQuery(jpql).getSingleResult();public User find(String name) {
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
return jdbc.queryForObject(sql, User.class);
}
// JPA escape hatches behave the same way
String jpql = "FROM User u WHERE u.name = '" + name + "'";
em.createQuery(jpql).getSingleResult();public User find(String name) {
return jdbc.queryForObject(
"SELECT * FROM users WHERE name = ?",
(rs, i) -> mapUser(rs),
name);
}
// JPQL named parameter, not concatenation
em.createQuery("FROM User u WHERE u.name = :name", User.class)
.setParameter("name", name)
.getSingleResult();public User find(String name) {
return jdbc.queryForObject(
"SELECT * FROM users WHERE name = ?",
(rs, i) -> mapUser(rs),
name);
}
// JPQL named parameter, not concatenation
em.createQuery("FROM User u WHERE u.name = :name", User.class)
.setParameter("name", name)
.getSingleResult();
Two places hide this from a scan. The first is @Query in Spring Data, where a nativeQuery = true annotation
turns a string into SQL and a missing parameter binding into a defect. The second is query building in report code,
which is usually written by whoever needed the report and lives outside the repository package.
Deserialization
Java deserialization is not a bug in a library — it is a language feature that lets the stream choose which classes
to instantiate and which readObject methods to run. Any class on the classpath is a potential gadget. The control
that actually works is a stream filter with an allowlist, introduced as JEP 290 and available since JDK 9.
// no filter: the stream decides which classes get constructed
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(path))) {
Object o = in.readObject();
return (Session) o;
}// no filter: the stream decides which classes get constructed
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(path))) {
Object o = in.readObject();
return (Session) o;
}private static final ObjectInputFilter ALLOW_SESSION = ObjectInputFilter.Config.createFilter(
"com.example.session.Session;com.example.session.SessionAttribute;" +
"java.base/*;!*");
static {
ObjectInputFilter.Config.setSerialFilter(ALLOW_SESSION);
}
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(path))) {
in.setObjectInputFilter(ALLOW_SESSION);
return (Session) in.readObject();
}private static final ObjectInputFilter ALLOW_SESSION = ObjectInputFilter.Config.createFilter(
"com.example.session.Session;com.example.session.SessionAttribute;" +
"java.base/*;!*");
static {
ObjectInputFilter.Config.setSerialFilter(ALLOW_SESSION);
}
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream(path))) {
in.setObjectInputFilter(ALLOW_SESSION);
return (Session) in.readObject();
}Be precise about what this is: a mitigation that shrinks the attack surface to the classes you named, not a repair. The repair is to stop accepting Java serialization from untrusted input. Where the format is fixed by an upstream system, the filter plus a library inventory is the practical control, and both belong in the finding.
CVE-2016-1000027 — the HttpInvokerServiceExporter deserialization issue — affects 4.x through 5.3.x and was never
patched in place because the entire mechanism was deprecated. If the codebase still exposes an HTTP invoker
endpoint, the finding is "remove the endpoint", not "upgrade the minor version".
Spring-specific sinks
Three Spring features produce findings that generic rules miss. SpEL evaluation on request data is arbitrary code
execution dressed as configuration — the T() operator reaches Runtime directly. @ModelAttribute binds every
request field to a matching property, including ones the form never showed. And the method-security proxy is only
entered from outside the bean, which makes the annotation decorative on an internal call.
// user input becomes an expression, not a value
String expr = request.getParameter("filter");
ExpressionParser parser = new SpelExpressionParser();
String value = parser.parseExpression(expr).getValue(String.class);// user input becomes an expression, not a value
String expr = request.getParameter("filter");
ExpressionParser parser = new SpelExpressionParser();
String value = parser.parseExpression(expr).getValue(String.class);// the expression is a constant chosen by the application;
// the user supplies only the lookup key
Map<String, String> filters = Map.of("active", "#root.active", "inactive", "#root.inactive");
String key = request.getParameter("filter");
if (!filters.containsKey(key)) throw new IllegalArgumentException("unknown filter");
Expression expr = new SpelExpressionParser().parseExpression(filters.get(key));// the expression is a constant chosen by the application;
// the user supplies only the lookup key
Map<String, String> filters = Map.of("active", "#root.active", "inactive", "#root.inactive");
String key = request.getParameter("filter");
if (!filters.containsKey(key)) throw new IllegalArgumentException("unknown filter");
Expression expr = new SpelExpressionParser().parseExpression(filters.get(key));@Service
public class ReportService {
@PreAuthorize("hasRole('ADMIN')")
public Report adminReport(long id) { ... }
// self-invocation: the proxy is never entered, the annotation does nothing
public Report summary(long id) {
return this.adminReport(id);
}
}@Service
public class ReportService {
@PreAuthorize("hasRole('ADMIN')")
public Report adminReport(long id) { ... }
// self-invocation: the proxy is never entered, the annotation does nothing
public Report summary(long id) {
return this.adminReport(id);
}
}@Service
public class ReportService {
@PreAuthorize("hasRole('ADMIN')")
public Report adminReport(long id) { ... }
}
@Service
public class ReportFacade {
private final ReportService reports;
// crossed a bean boundary: the proxy sees the call and enforces the check
public Report summary(long id) {
return reports.adminReport(id);
}
}@Service
public class ReportService {
@PreAuthorize("hasRole('ADMIN')")
public Report adminReport(long id) { ... }
}
@Service
public class ReportFacade {
private final ReportService reports;
// crossed a bean boundary: the proxy sees the call and enforces the check
public Report summary(long id) {
return reports.adminReport(id);
}
}Authorisation
Read the security configuration as three questions: what is permitted by default, where the rules are evaluated,
and what the rules are keyed on. A requestMatchers chain keyed on URL prefixes is fine until a controller grows a
new path under an already-permitted prefix. Method security keyed on roles is fine until the object being returned
belongs to someone else. The defect that survives both is the missing per-object check.
| Configuration | What it actually enforces | Failure mode |
|---|---|---|
| anyRequest().authenticated() | Login, nothing else | Any logged-in user reaches every handler |
| requestMatchers("/api/admin/**") | Path prefix | A controller mapped outside the prefix escapes the rule |
| @PreAuthorize on the controller | The HTTP entry point | A message consumer or scheduler calls the service unchecked |
| @PreAuthorize("hasRole('ADMIN')") | The role, not the object | Admin of tenant A reads tenant B |
SSRF and XXE
XXE is mostly closed by default in modern Java XML parsers, which means a finding here is usually about a parser
configured to be permissive for a legacy integration. Look for DocumentBuilderFactory with DTDs enabled, or a
SAXParser built before the hardening calls. The fix is four lines and belongs in the finding.
SSRF is the more common live issue: any code that takes a URL from a user and fetches it. The control that works is a destination allowlist enforced after DNS resolution, or an egress proxy — not a string check on the hostname, which fails against decimal IPs, IPv6 literals, DNS rebinding and redirects.
Crypto and secrets
Three greps cover most of it: MessageDigest.getInstance("MD5"|"SHA-1"), Cipher.getInstance without an AEAD mode,
and new Random() used for anything that must not be predictable. Password storage has one acceptable answer in
Spring — BCryptPasswordEncoder or Argon2 — and a codebase that hand-rolls salt + sha256 is a finding even when
the salt is per-user.
| Check | Expected | Where to look |
|---|---|---|
| No query is assembled from a string concatenation | Every JDBC, JPA and native query passes user values as bound parameters; the only concatenation left is over constants | grep for executeQuery, createQuery and @Query |
| ObjectInputStream is either absent or filtered by an allowlist | An ObjectInputFilter configured per stream with an explicit class allowlist, not a `!` denylist of known gadgets | The readObject call site |
| Method security is enabled and effective | @EnableMethodSecurity present, @PreAuthorize on the service method rather than only the controller, and no call from inside the same bean | SecurityConfig and the annotated methods |
| Spring Security version is supported | 7.1.x or a maintained 7.0 line; 6.5 is end of life and 5.8 fixes for Spring4Shell are ancient history | The dependency tree (mvn dependency:tree or gradle dependencies) |
Evidence Collection
For a Java finding, the reviewer needs the dependency versions as much as the source line — half of these defects are version-specific:
The resolved version, not the declared one: Spring manages its own BOM; a 5.3.x pulled in transitively will not appear in the pom as written. Quote the tree output.
The gadget or expression, in full: For deserialization, the chain that reaches a runtime exec. For SpEL, the expression string. Both must be reproducible.
Whether a filter or proxy was in the path: ObjectInputFilter, method security proxy, or the container filter chain. The finding is only real if none of them was.
False Positive Identification
- The concatenated string is not a query: String concatenation with a constant, a column name from an enum, or an ORDER BY direction chosen from a switch is not injection. The value must reach the parser.
- The annotation is on the proxied call: Method security is a proxy. If the call crosses the bean boundary, the check runs. Only self-invocation and private methods bypass it.
- The deserialization source is trusted: A stream written and read by the same process from a private file descriptor is not an external source. Say so in the ticket rather than filing a critical.
- The framework already binds parameters: Spring Data derived queries and JPA Criteria API calls are bound. A grep hit on those methods is not a finding.
Java and Spring Review Checklist
Data access
- ☐ No string-concatenated JDBC, JPQL or native query with a variable part.
- ☐ JPA escape hatches (createNativeQuery, EntityManager.createQuery) reached with user data are bound.
- ☐ Database accounts used by the application have the narrowest grants that work.
Deserialization
- ☐ ObjectInputStream is absent, or every stream has an allowlist filter.
- ☐ No Commons Collections, Groovy or XStream on the classpath where user data is deserialized.
- ☐ Jackson polymorphic typing (enableDefaultTyping) is not enabled on external input.
Spring
- ☐ Method security is enabled and used on service methods, not only controllers.
- ☐ @ModelAttribute binding does not populate fields the caller must not set (role, owner, price).
- ☐ No SpEL or expression evaluation on request data.
- ☐ Actuator endpoints are authenticated and not exposed on the public interface.
Crypto and config
- ☐ Password hashing uses BCrypt, Argon2 or PBKDF2 with a per-user salt.
- ☐ No key material in application.properties, the repository or the image.
- ☐ CSRF protection is enabled for state-changing browser flows; stateless APIs use token auth and no cookie session.
Fixing What You Found
Spring gives you the safe primitive in almost every case. Prefer it to a hand-rolled filter:
Spring4Shell: what actually happened
CVE-2022-22965 was a data-binding issue, not an injection issue: a request parameter could reach
class.module.classLoader on a bound object, and from there the servlet container's access log. It affected
Spring MVC on a servlet container with a JDK 9 or later, and it was fixed in 5.3.18 and 5.2.20. In a review it is
useful as a case study in why @ModelAttribute needs a field allowlist — the same shape, without the CVE, still
produces mass-assignment findings today.
Java Review Practice Targets
Projects with real Java and Spring defects, small enough to confirm each finding by reading the path.
Related Source Code Review Guides
C# and .NET Security
The same deserialization and injection classes, with a formatter that was removed rather than fixed.
SAST and Scanning Tools
Which rule packs cover Java, and what they still will not see.
Deserialization
The fix catalogue for the gadget-chain class this page hunts for.
Injection
Parameter binding and query construction across languages.
API Pentesting
What an exposed Spring actuator or error page leaks to a black-box tester.