CWE-943: Improper Neutralization of Special Elements in Data Query Logic - Java
Overview
NoSQL Injection in Java applications occurs when untrusted input is used to construct NoSQL database queries (MongoDB, Redis, Cassandra, DynamoDB, etc.) without validating its type or shape. Untrusted input can originate from HTTP requests, external APIs, databases, files, message queues, or any source outside the application's control. Attackers can exploit this to bypass authentication, read or modify data outside their authorization, or run JavaScript on the database server through $where.
Primary Defence: Declare the types you expect and let the binder enforce them. In Java the injection needs a hole to come through - a parameter typed Object, a @RequestBody Map<String, Object>, a Document accepted from a request - so a DTO of String, int and boolean fields closes it before any query code runs, because Jackson rejects an object where a String was declared. Where a map is genuinely required, allowlist the keys and check each value with isInstance on the path that builds the query. Build filters with the driver's Filters class or Spring Data's Criteria, so the field and the operator are chosen in code. Run the application's database account with least privilege as well - a query the attacker reshapes can only reach what the credential permits.
Common Java NoSQL Vulnerabilities:
- MongoDB query operator injection using BSON documents
- Spring Data MongoDB query injection
- Redis key-namespace injection via Jedis/Lettuce, and Lua script injection via
eval - Cassandra CQL injection
- DynamoDB expression injection
Popular Java NoSQL Libraries:
- MongoDB Java Driver: Official MongoDB driver
- Spring Data MongoDB: Spring framework MongoDB integration
- Morphia: MongoDB ODM for Java
- Jedis / Lettuce: Redis clients
- DataStax Java Driver: Cassandra driver
- AWS SDK for Java v2: DynamoDB client (v1 reached end of support on 31 December 2025)
Common Vulnerable Patterns
MongoDB Operator Injection
// VULNERABLE - Direct untrusted input in MongoDB query
import com.mongodb.client.*;
import org.bson.Document;
public class UserService {
private MongoCollection<Document> users;
public boolean authenticateUser(String username, Object password) {
// VULNERABLE - Accepting Object type allows operator injection
Document query = new Document("username", username)
.append("password", password);
Document user = users.find(query).first();
return user != null;
}
}
// Attack: password = new Document("$ne", null)
// Query becomes: {username: "user", password: {$ne: null}}
// Authentication bypass!
Why this is vulnerable: Object password is the whole defect. Java's type system would have stopped this on its own - a String parameter cannot hold a Document - so the method has to widen the type before an operator can reach the query. Once it does, Document.append stores whatever it was handed, and the driver serialises a nested Document faithfully, where MongoDB reads {$ne: null} as an operator rather than a value.
That is the shape to look for in Java, and it is narrower than in the dynamic languages: Object, Map<String, Object>, Document or a generic type parameter somewhere between the request and the filter. Nothing is concatenated and no character needs escaping - the injection is a change of type.
Spring Data MongoDB Injection
// VULNERABLE - Spring Data with raw query
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.BasicQuery;
import org.springframework.web.bind.annotation.*;
@RestController
public class ProductController {
private final MongoTemplate mongoTemplate;
@PostMapping("/api/products/search")
public List<Product> searchProducts(@RequestBody Map<String, Object> queryMap) {
// VULNERABLE - Untrusted query map
BasicQuery query = new BasicQuery(new Document(queryMap));
return mongoTemplate.find(query, Product.class);
}
}
// Attack POST body: {"price": {"$gt": 0}, "admin_only": {"$ne": true}}
// Bypasses access controls
Why this is vulnerable: BasicQuery exists to accept a query document as-is, and that is exactly what it does here: Jackson binds the body into Map<String, Object> with nested objects intact, and the whole thing becomes the filter. The caller chooses the fields, the operators and the values, so any document in the collection is reachable regardless of what the endpoint was meant to search.
Product.class on the find call is not a control. It says how results are deserialised; it is never consulted when the filter is built, so the type-safety the surrounding code appears to have does not reach this query.
MongoDB $where Injection
// VULNERABLE - JavaScript code injection via $where
import com.mongodb.client.*;
import org.bson.Document;
public class UserRepository {
private MongoCollection<Document> users;
public List<Document> findUsersByAge(String minAge) {
// VULNERABLE - String concatenation in $where
String whereClause = "this.age > " + minAge;
Document query = new Document("$where", whereClause);
return users.find(query).into(new ArrayList<>());
}
}
// Attack: minAge = "0 || true"
// Expression becomes: this.age > 0 || true -> matches every document
// Attack: minAge = "0 || (function(){ while(true){} })()"
// Runs an unbounded loop inside the server's JavaScript engine
Why this is vulnerable: $where hands a JavaScript expression to the MongoDB server, which evaluates it once per candidate document, and the concatenation drops the caller's text straight into it. An || makes the predicate unconditionally true; a function expression that never returns occupies a server thread; this.<field> reaches any field on the document, including ones this method never returns.
$where was deprecated in MongoDB 8.0 - the server logs a warning - but it is not removed, and server-side scripting is enabled by default, so a payload that reaches it runs. The payloads above are expressions rather than statement lists (0; return true; //) because a statement list depends on how the server wraps the string, while an expression holds under any wrapping. $expr with standard aggregation operators covers most uses of $where and executes nothing.
Morphia with Unsafe Queries
// VULNERABLE - Morphia ODM with raw queries
import dev.morphia.Datastore;
import dev.morphia.query.Query;
import org.bson.Document;
@Entity("users")
public class User {
@Id private ObjectId id;
private String username;
private String role;
// getters/setters
}
public class UserService {
private Datastore datastore;
public User findUser(Map<String, Object> criteria) {
// VULNERABLE - Untrusted criteria
Query<User> query = datastore.find(User.class);
for (Map.Entry<String, Object> entry : criteria.entrySet()) {
// No validation on field names or operators
query.filter(entry.getKey(), entry.getValue());
}
return query.first();
}
}
// Attack: criteria = {"role": {"$ne": "user"}}
// Filters on role with an operator the caller supplied - returns an admin
Why this is vulnerable: The loop walks a caller-supplied map and turns every entry into a filter condition, so both halves of each condition come from the request: the field name and the value. filter(String, Object) is the legacy Morphia 1.x signature, still present and deprecated in Morphia 2.x, and it takes the value without inspecting it - a nested map arrives at MongoDB as an operator.
The replacement is the varargs form, query.filter(eq("username", value)), which builds a Filter object per condition. That fixes the operator half by construction, because the comparison is chosen in code. It does not fix the field half: eq(criteria.getKey(), ...) is still the caller naming a field, so an allowlist is needed either way.
Unvalidated Key Path in Redis
// VULNERABLE - Redis key chosen by the caller
import redis.clients.jedis.Jedis;
import org.springframework.web.bind.annotation.*;
@RestController
public class CacheController {
private Jedis jedis = new Jedis("localhost");
@GetMapping("/cache/{key}")
public String getCache(@PathVariable String key) {
// VULNERABLE - Untrusted input in Redis key
return jedis.get(key);
}
@PostMapping("/cache")
public String setCache(@RequestParam String key,
@RequestParam String value) {
// VULNERABLE - the caller names the key that gets written
jedis.set(key, value);
return "OK";
}
}
// Attack: key = "session:9f2a"
// Reads or overwrites a key belonging to another part of the application
Why this is vulnerable: Both handlers let the caller name the key outright. GET /cache/{key} returns whatever is stored under it, so any value the application caches - session records, password-reset codes, rate-limit counters - is one request away, and the POST handler overwrites any of them.
The payload usually shown for this, key = "test\r\nFLUSHDB\r\n", does not work, and repeating it hides the weakness that does. RESP length-prefixes every argument, so a set with that key puts $15 followed by 15 bytes on the socket and the server reads all 15 as one key - the CRLF is data, and FLUSHDB is stored rather than executed. (Measured on two other clients for the same protocol; Jedis builds the same multi-bulk request.) Nothing strips it either, so removing newlines from a value defends nothing and corrupts data that legitimately contains them. Redis command injection through Jedis needs a different sink: user input concatenated into the source of a Lua script passed to eval, or a pattern handed to keys, which scans the whole keyspace.
MongoDB Aggregation Injection
// VULNERABLE - Aggregation pipeline with untrusted input
import com.mongodb.client.*;
import org.bson.Document;
import java.util.*;
public class AnalyticsService {
private MongoCollection<Document> events;
public List<Document> getUserStats(String userId, String sortField) {
// VULNERABLE - Untrusted input in aggregation pipeline
List<Document> pipeline = Arrays.asList(
new Document("$match", new Document("user_id", userId)),
new Document("$sort", new Document(sortField, -1)),
new Document("$limit", 10)
);
return events.aggregate(pipeline).into(new ArrayList<>());
}
}
// Attack: sortField = "password_hash"
// Orders results by a field the caller was never shown, leaking its ordering
// Attack: sortField = "last_seen_ip" (no index)
// Forces a blocking sort over the whole match, spilling to disk
Why this is vulnerable: The caller chooses which field the pipeline sorts on. That is not operator injection, and calling it that points readers at the wrong risk: a $sort value is 1, -1 or {$meta: ...}, so a sort key never becomes something the server executes, and sortField = "$where" yields a sort specification the server rejects rather than a code path.
What the attacker does get is real. Sorting on a field the method does not return still leaks that field's ordering, which is enough to binary-search a hidden value across requests, and naming an unindexed field turns a cheap indexed scan into a blocking sort over the entire match. Anything that names a field - sort target, filter key, projection - needs an allowlist, because validating values does not cover it.
MongoDB Regex Injection
// VULNERABLE - Regex injection in queries
import com.mongodb.client.*;
import org.bson.Document;
import java.util.regex.Pattern;
public class SearchService {
private MongoCollection<Document> users;
public List<Document> searchUsers(String searchTerm) {
// VULNERABLE - Untrusted input in regex without escaping
Document query = new Document("username",
new Document("$regex", searchTerm)
.append("$options", "i"));
return users.find(query).into(new ArrayList<>());
}
}
// Attack: searchTerm = ".*"
// Returns ALL users (data exfiltration)
// Attack: searchTerm = "^admin"
// Confirms which usernames start with a given prefix, one request at a time
// Attack: searchTerm = "(a+)+$"
// Catastrophic backtracking against a long non-matching username
Why this is vulnerable: The search term is used as a pattern, so every regex metacharacter the caller types is honoured. .* turns a search into a full dump; an anchored prefix turns it into an oracle that reveals stored values character by character across repeated requests; a pattern chosen for backtracking cost makes the server do exponential work per document scanned.
The anchor in the last payload is the part worth noticing, because (a+)+ on its own is repeated everywhere as a ReDoS example and is not one - it succeeds immediately on a prefix and returns in microseconds. (a+)+$ against a long run of a ending in a different character is the version that backtracks, because the anchor forces the engine to fail and retry every way of splitting the run.
DynamoDB Expression Injection
// VULNERABLE - DynamoDB with an attacker-chosen attribute name
import java.util.List;
import java.util.Map;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
public class DynamoService {
private DynamoDbClient dynamoDb;
public List<Map<String, AttributeValue>> searchUsers(String attributeName, String value) {
// VULNERABLE - the attribute name is concatenated into the expression
ScanRequest request = ScanRequest.builder()
.tableName("Users")
.filterExpression(attributeName + " = :val")
.expressionAttributeValues(Map.of(":val", AttributeValue.fromS(value)))
.build();
return dynamoDb.scan(request).items();
}
}
// Attack: attributeName = "admin_flag", value = "true"
// Filters on an attribute the endpoint was never meant to expose
// Attack: attributeName = "NOT deleted"
// The expression is text, so an operator fits wherever a name does
Why this is vulnerable: :val binds the value, and that is the only half that is bound. filterExpression is a string, so the attribute name goes in as expression text - which means the caller can name any attribute in the table, or write something that is not a name at all. Binding the value does not make the expression safe; it makes one position in it safe.
The fix is expressionAttributeNames with a #name placeholder, and an allowlist deciding which real attribute that placeholder maps to. Note that this example uses AWS SDK for Java v2 (software.amazon.awssdk); v1 (com.amazonaws) reached end of support on 31 December 2025 and no longer receives security patches.
Secure Patterns
Typed Parameters, with the Password Kept Out of the Query
// SECURE - the filter holds one validated string and no password
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.Filters;
import java.util.regex.Pattern;
import org.bson.Document;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class SecureUserService {
private MongoCollection<Document> users;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
// A real BCrypt hash (cost 12) of a passphrase no account uses. Comparing
// against it costs what comparing against a stored hash costs.
private static final String DUMMY_HASH =
"$2a$12$5KX87BjRsOBw0QJrhoE...PhYkBDOvyN3GAYrSAHaFCVzMd6WA.4q";
private static final Pattern USERNAME_PATTERN =
Pattern.compile("[a-zA-Z0-9_]{3,50}");
private String validateUsername(String username) {
if (username == null || !USERNAME_PATTERN.matcher(username).matches()) {
throw new IllegalArgumentException("Invalid username format");
}
return username;
}
public boolean authenticateUser(String username, String password) {
// SECURE - the parameter type is the type check; validate the shape too
String cleanUsername = validateUsername(username);
// SECURE - the password is not part of the filter; look up by name only
Document user = users.find(Filters.eq("username", cleanUsername)).first();
// SECURE - hash on both paths. Returning early when the user does not
// exist would make an unknown username far faster than a wrong password.
String stored = user != null ? user.getString("password_hash") : DUMMY_HASH;
boolean ok = encoder.matches(password, stored);
return user != null && ok;
}
}
Why this works: The declared parameter type is the type check. authenticateUser(String, String) cannot receive a Document, so the operator injection the vulnerable version allowed has nowhere to enter, and Jackson enforces the same thing one layer up if these come from a @RequestBody DTO. That is worth stating plainly because the obvious-looking alternative is not a check at all: if (!(value instanceof String)) inside a method whose parameter is already String compiles without a warning and can never be true. Its presence reads as validation while doing nothing.
USERNAME_PATTERN with matches() is then ordinary input validation - it pins the alphabet and the length, which is worth having, but it is not what stops the injection.
The password is not in the query. Filtering on it would put the one value worth guessing into the part of the request an attacker reshapes; comparing the hash in the application keeps the query to a lookup, and a database dump then yields hashes rather than passwords. Hashing on both paths is the part most often dropped: if (user == null) return false skips BCrypt entirely, and measured with BCryptPasswordEncoder(12), matches() against DUMMY_HASH takes 244.7 ms against 244.0 ms for a real hash, where an early return answers in microseconds. That difference tells an attacker which usernames exist - see CWE-208 and CWE-287.
Dependencies: org.springframework.security:spring-security-crypto for BCryptPasswordEncoder; no wider Spring Security setup is required to use it.
Spring Data with Query Allowlist
// SECURE - Spring Data with field allowlist
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
public class SecureProductController {
private final MongoTemplate mongoTemplate;
public SecureProductController(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
// SECURE - Define allowed query fields
private static final Map<String, Class<?>> ALLOWED_FIELDS = Map.of(
"name", String.class,
"category", String.class,
"price_min", Number.class,
"price_max", Number.class
);
private Query buildSafeQuery(Map<String, Object> params) {
List<Criteria> conditions = new ArrayList<>();
Criteria price = null;
for (Map.Entry<String, Object> entry : params.entrySet()) {
String field = entry.getKey();
Object value = entry.getValue();
// SECURE - Only allow allowlisted fields
Class<?> expectedType = ALLOWED_FIELDS.get(field);
if (expectedType == null) {
continue;
}
// SECURE - reject a wrong type rather than dropping the condition
if (!expectedType.isInstance(value)) {
throw new IllegalArgumentException(field + " has the wrong type");
}
// SECURE - both price bounds go into ONE Criteria for that field
switch (field) {
case "price_min" -> {
price = price == null ? Criteria.where("price") : price;
price.gte(value);
}
case "price_max" -> {
price = price == null ? Criteria.where("price") : price;
price.lte(value);
}
default -> conditions.add(Criteria.where(field).is(value));
}
}
if (price != null) {
conditions.add(price);
}
Query query = new Query();
for (Criteria criteria : conditions) {
query.addCriteria(criteria);
}
query.limit(100);
return query;
}
@PostMapping("/api/products/search")
public ResponseEntity<?> searchProducts(@RequestBody Map<String, Object> params) {
Query safeQuery;
try {
safeQuery = buildSafeQuery(params);
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(ex.getMessage());
}
return ResponseEntity.ok(mongoTemplate.find(safeQuery, Product.class));
}
}
Why this works: ALLOWED_FIELDS decides which request keys are looked at, the switch decides which operator each becomes, and the caller supplies only the value. A body carrying admin_only finds no entry and is dropped; a body carrying {"category": {"$ne": "tools"}} fails isInstance and is rejected. Because the body arrives through Jackson as a Map<String, Object>, its values already have real types - Integer, Double, String - so isInstance is the right test here. It would be the wrong test against a query string, where every value is a String and a check against Number.class silently drops every numeric filter.
Two details are load-bearing, and both were wrong in the obvious version of this code:
- Both price bounds go into one
Criteria. Callingcriteria.and("price")twice puts two conditions with the same key in one chain, and Spring Data refuses to render that. Measured on spring-data-mongodb 4.5.4, a request withprice_minandprice_max- the ordinary case a price-range search exists for - throwsInvalidMongoDbApiUsageException: Due to limitations of the org.bson.Document, you can't add a second 'price' expression. Every single-bound test passes, so the bug ships. BuildingCriteria.where("price").gte(min).lte(max)produces{"price": {"$gte": 10, "$lte": 50}}instead. - A wrong type throws rather than
continue. Skipping the condition leaves the query wider than the caller asked for, which is the failure direction that goes unnoticed: the endpoint returns results, no operator got through, and a filter quietly disappeared. Catch it in the handler so it becomes a 400 - anIllegalArgumentExceptionescaping a controller method is a 500 by default, which tells the caller less and looks like a fault rather than a rejection.
query.limit(100) bounds the result set.
Spring Data Repository (Type-Safe)
// SECURE - Spring Data Repository with type safety
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.regex.Pattern;
@Document(collection = "users")
public class User {
@Id
private String id;
@Field("username")
@Indexed(unique = true)
private String username;
@Field("email")
private String email;
@Field("role")
private String role;
// getters/setters
}
@Repository
public interface UserRepository extends MongoRepository<User, String> {
// SECURE - Type-safe repository methods
User findByUsername(String username);
List<User> findByRole(String role);
@Query("{ 'email': ?0 }")
User findByEmail(String email);
}
@Service
public class SecureUserService {
private final UserRepository userRepository;
private static final Pattern USERNAME_PATTERN =
Pattern.compile("^[a-zA-Z0-9_]{3,50}$");
private String validateUsername(String username) {
if (username == null || username.isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty");
}
if (!USERNAME_PATTERN.matcher(username).matches()) {
throw new IllegalArgumentException("Invalid username format");
}
return username;
}
public User getUser(String username) {
String cleanUsername = validateUsername(username);
// SECURE - Type-safe repository method
return userRepository.findByUsername(cleanUsername);
}
}
Why this works: A derived method such as findByUsername(String username) fixes the query in the interface: the field comes from the method name and the operator from the naming convention, so the caller can only supply the value. @Query("{ 'email': ?0 }") is the same split written out - ?0 is a bound placeholder, and the surrounding document is a literal, so a value containing $ne is compared as text rather than read as structure. And because the parameter is declared String, a Document cannot be passed at all.
Two things about this are commonly overstated. Spring Data parses method names and creates the repository proxy at application startup, not at compile time - a misspelled property fails when the context loads, which is early but is not a compiler error. And @Document, @Field and @Indexed are mapping annotations: they say how a Java object maps to BSON and which index to create. They do not impose a schema on the collection or reject undeclared fields, so nothing here validates the shape of a query. The safety comes from the fixed query structure and the declared parameter types.
validateUsername() is defence in depth on top of that, pinning the alphabet and length before the lookup.
Redis with an Application-Composed Key
// SECURE - the application decides the key, the caller supplies one segment
import redis.clients.jedis.Jedis;
import org.springframework.web.bind.annotation.*;
import java.util.regex.Pattern;
@RestController
public class SecureCacheController {
private Jedis jedis = new Jedis("localhost");
private static final Pattern KEY_PATTERN =
Pattern.compile("^[a-zA-Z0-9_-]{1,100}$");
private String validateRedisKey(String key) {
if (key == null || key.isEmpty()) {
throw new IllegalArgumentException("Key cannot be empty");
}
// SECURE - Only allow alphanumeric, dash, underscore
if (!KEY_PATTERN.matcher(key).matches()) {
throw new IllegalArgumentException("Invalid key format");
}
return key;
}
private String validateRedisValue(String value) {
if (value == null) {
throw new IllegalArgumentException("Value cannot be null");
}
// SECURE - bound the size. The contents need no filtering; see below.
if (value.length() > 10000) {
throw new IllegalArgumentException("Value too large");
}
return value;
}
@GetMapping("/cache/{key}")
public String getCache(@PathVariable String key) {
String cleanKey = validateRedisKey(key);
String value = jedis.get(cleanKey);
return value != null ? value : "Not found";
}
@PostMapping("/cache")
public String setCache(@RequestParam String key,
@RequestParam String value) {
String cleanKey = validateRedisKey(key);
String cleanValue = validateRedisValue(value);
// SECURE - Use setex with expiration
jedis.setex(cleanKey, 3600, cleanValue);
return "OK";
}
}
Why this works: KEY_PATTERN is ^[a-zA-Z0-9_-]{1,100}$, and the character it leaves out is the control. : separates namespaces in a Redis keyspace, so a caller who cannot type one cannot climb out of the namespace this controller owns into session: or reset:. A key pattern that permitted : would look equally strict and stop nothing. matches() anchors the whole string, which is what makes the bound on length real as well.
The value is bounded but not filtered, deliberately. Stripping \r and \n from a cached value defends against a protocol attack that does not exist - RESP length-prefixes every argument, so a newline in a value is data - and it silently corrupts anything with a legitimate line break, such as a cached document or a PEM block. Bound the size, because that is a real resource limit; leave the bytes alone.
setex attaches a TTL, so a poisoned or stale entry ages out rather than persisting until someone notices. For Lua, pass values through KEYS/ARGV on eval rather than concatenating them into the script text - the script source is the one place in a Jedis call where user input really is parsed as code.
Safe MongoDB Aggregation
// SECURE - MongoDB aggregation with field allowlist
import com.mongodb.client.*;
import org.bson.Document;
import java.util.*;
import java.util.regex.Pattern;
public class SecureAnalyticsService {
private MongoCollection<Document> events;
// SECURE - Define allowed sort fields
private static final Set<String> ALLOWED_SORT_FIELDS =
Set.of("timestamp", "event_type", "user_id");
private static final Pattern USER_ID_PATTERN =
Pattern.compile("^[a-zA-Z0-9_-]{1,50}$");
private String validateUserId(String userId) {
if (userId == null || userId.isEmpty()) {
throw new IllegalArgumentException("User ID cannot be empty");
}
if (!USER_ID_PATTERN.matcher(userId).matches()) {
throw new IllegalArgumentException("Invalid user ID format");
}
return userId;
}
public List<Document> getUserStats(String userId, String sortField) {
// SECURE - Validate user ID
String cleanUserId = validateUserId(userId);
// SECURE - Validate sort field against allowlist
if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
throw new IllegalArgumentException(
"Invalid sort field. Allowed: " + ALLOWED_SORT_FIELDS);
}
// SECURE - Build pipeline with validated values
List<Document> pipeline = Arrays.asList(
new Document("$match", new Document("user_id", cleanUserId)),
new Document("$sort", new Document(sortField, -1)),
new Document("$limit", 100)
);
return events.aggregate(pipeline).into(new ArrayList<>());
}
}
Why this works: The three stages, their order and both operators are written here; the request contributes one match value and the name of one sort field. Because the pipeline is a List<Document> built in code rather than parsed from a body, there is no position in which a caller could add a stage such as $lookup or $function.
ALLOWED_SORT_FIELDS is doing something narrower than stopping code execution. A $sort value is 1, -1 or {$meta: ...}, so a sort key never becomes something the server runs. What the allowlist prevents is sorting on a field the method does not return - which still leaks that field's ordering, one request at a time - and sorting on an unindexed field, which turns an indexed scan into a blocking sort over the whole match. Both are reasons to allowlist anything that names a field, alongside validating anything that supplies a value.
new Document("$limit", 100) caps the documents the stage emits, bounding the response and the sort before it.
Regex Escaping
// SECURE - MongoDB regex with proper escaping
import com.mongodb.client.*;
import org.bson.Document;
import java.util.*;
import java.util.regex.Pattern;
public class SecureSearchService {
private MongoCollection<Document> users;
private String escapeRegex(String input) {
// SECURE - Pattern.quote wraps the input in \Q ... \E, which PCRE
// honours, so every metacharacter in it is matched literally
return Pattern.quote(input);
}
private String validateSearchTerm(String searchTerm) {
if (searchTerm == null || searchTerm.isEmpty()) {
throw new IllegalArgumentException("Search term cannot be empty");
}
if (searchTerm.length() > 100) {
throw new IllegalArgumentException("Search term too long");
}
return searchTerm;
}
public List<Document> searchUsers(String searchTerm) {
// SECURE - Validate input
String cleanTerm = validateSearchTerm(searchTerm);
// SECURE - Escape regex special characters
String escapedTerm = escapeRegex(cleanTerm);
Document query = new Document("username",
new Document("$regex", escapedTerm)
.append("$options", "i"));
return users.find(query).limit(100).into(new ArrayList<>());
}
}
Why this works: Pattern.quote() wraps the input as \Q...\E, a quoting construct PCRE supports, so everything between them is matched as literal text. .* becomes a search for the two characters . and *; (a+)+$ becomes a search for that punctuation. The caller is left supplying a search term rather than a search pattern, which is the distinction the vulnerable version lost.
Use the library call rather than a hand-written character class. An escape routine assembled from replaceAll has to be right about which characters the consumer's engine treats as special - MongoDB's is PCRE, not java.util.regex - and about the replacement string's own escaping, and it is silently wrong rather than loudly wrong when it is not. Pattern.quote sidesteps the question by quoting a range instead of escaping characters one at a time.
Length validation bounds the term, and .limit(100) bounds the result set. Neither is the fix; both keep a legitimate search from becoming expensive.
Testing
To verify NoSQL injection protection:
- Operator injection through the body: POST
{"category": {"$ne": "tools"}}to a search endpoint and assert a 400. If the endpoint binds a DTO ofStringfields, Jackson produces the 400 itself; if it bindsMap<String, Object>, theisInstancecheck has to. - Both bounds of a range filter at once: POST
{"price_min": 10, "price_max": 50}and assert results, not an error. Supplying one bound at a time passes even when the two-bound path throwsInvalidMongoDbApiUsageException, so a single-bound test proves nothing here. - An allowlisted filter still filters: search
price_min20 against a fixture holding items at 10 and 30, and assert one result. A condition that gets dropped instead of applied still returns products and still passes every injection test. - The unknown-user path costs what the known-user path costs: time 20 logins for an existing username with a wrong password and 20 for a username that does not exist. The medians should be within noise; more than a few milliseconds apart means a branch is returning before
BCryptPasswordEncoder.matches. $where,BasicQueryandDocument.parsehave no untrusted input: grep for all three. None of them is checkable at runtime, and each accepts a query document as-is, which is what routes around the repository layer's typing.
Common Pitfalls
- Spring Data MongoDB's derived query methods (
findByUsername(String username)) and@Querywith?0placeholders bind values safely, but building aCriteria/Queryobject from a raworg.bson.Document.parse(requestBody), or accepting aDocumentdirectly as a@RequestBody, bypasses the repository layer's type safety the same way raw JSON does for the native driver. - A
Criteriachain can hold only one condition per field.criteria.and("price").gte(min)followed bycriteria.and("price").lte(max)throwsInvalidMongoDbApiUsageExceptionwhen the query is rendered, not when it is built, so it surfaces as a runtime failure on the one request that supplies both bounds. Chain the bounds onto a singleCriteria.where("price")instead. if (!(value instanceof String))inside a method whose parameter is already declaredStringcompiles cleanly and can never be true. It reads as the type check that stops operator injection while doing nothing; the declared parameter type is what is actually doing that job, and widening it toObjectis what would break it.- Jedis/Lettuce's
eval()methods acceptKEYS/ARGVas separate parameterized arguments, but concatenating user input into the Lua script string passed toeval()reopens script injection even though the call site looks like it's using the parameterized overload. - DynamoDB's
expressionAttributeValues()binds filter values safely through:val-style placeholders, but the attribute name has no equivalent unless the code also usesexpressionAttributeNames()with a#nameplaceholder - a concatenated attribute name leaves the expression string itself carrying attacker-controlled text, however the values are bound. The same split exists in the v1ScanSpecAPI (withValueMapandwithNameMap), which is worth knowing while reading old code, though AWS SDK for Java v1 has been out of support since 31 December 2025.