NoSQL Injection: Attack Patterns, Vulnerable Code, and Prevention in MongoDB, Redis, and Elasticsearch

NoSQL databases eliminated SQL but not injection. MongoDB's query operator injection, Redis command injection via Lua, and Elasticsearch dynamic query construction each have distinct attack patterns that bypass authentication and expose data. This guide covers each with vulnerable and fixed code examples.

NoSQL Injection Is Not the Same as SQL Injection

Developers who moved from relational databases to NoSQL often carry the mental model that parameterised queries were the SQL-specific fix, and that document stores are inherently safer. This is wrong. NoSQL databases accept structured queries — typically JSON or command strings — and when user input is interpolated into those structures unsanitised, the attack surface changes shape but doesn’t disappear.

The injection patterns differ per database: MongoDB’s operator injection manipulates query logic, Redis command injection exploits server-side Lua scripting and client library behaviour, and Elasticsearch’s dynamic query construction exposes search-time code execution. Each requires different prevention strategies.


MongoDB Operator Injection

How it works

MongoDB queries are JSON documents. The query language includes operators like $where, $gt, $ne, $in, and $regex that modify query semantics. When user-supplied JSON is directly embedded in a query, an attacker can inject these operators to alter query logic.

Authentication bypass — the classic pattern:

// VULNERABLE — Express/Node.js
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  
  // Attacker sends: { "username": {"$ne": null}, "password": {"$ne": null} }
  const user = await db.collection('users').findOne({
    username: username,  // becomes { "$ne": null } — matches ALL documents
    password: password   // becomes { "$ne": null } — always true
  });
  
  if (user) {
    // Authentication bypassed — logs in as the first user in the collection
    return res.json({ token: generateToken(user) });
  }
});

The attacker sends a POST body of {"username": {"$ne": null}, "password": {"$ne": null}} — valid JSON that Express parses into objects. MongoDB receives operators instead of strings and returns the first matching document.

$where JavaScript injection:

// VULNERABLE — $where allows arbitrary JavaScript execution
app.get('/search', async (req, res) => {
  const results = await db.collection('products').find({
    $where: `this.name == '${req.query.name}'`
    //                     ^^^^^^^^^^^^^^^^ string interpolation into JS
    // Attacker: name='; sleep(5000); var x='
    // Results in: this.name == ''; sleep(5000); var x=''
  }).toArray();
});

$where executes JavaScript in MongoDB’s server-side JS engine. Command injection here can cause DoS, data exfiltration via blind boolean-based injection, or information disclosure.

Prevention

// FIXED — enforce types, use typed schemas
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  
  // Type enforcement: reject non-string input
  if (typeof username !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ error: 'Invalid input' });
  }
  
  // Sanitise: strip MongoDB operators from strings
  const sanitise = (str) => str.replace(/[\$\.]/g, '');
  
  const user = await db.collection('users').findOne({
    username: sanitise(username),
    password: sanitise(password)
  });
  
  if (user && await bcrypt.compare(password, user.passwordHash)) {
    return res.json({ token: generateToken(user) });
  }
  res.status(401).json({ error: 'Invalid credentials' });
});
# Python with pymongo — safe query construction
from bson import ObjectId

def get_user(username: str, password: str):
    # Type check
    if not isinstance(username, str) or not isinstance(password, str):
        raise ValueError("Username and password must be strings")
    
    # Do NOT pass raw dict from request — build query explicitly
    user = db.users.find_one({
        "username": username,  # literal string, not a parsed dict
        "active": True
    })
    
    if user and bcrypt_check(password, user["password_hash"]):
        return user
    return None

Never use $where with user input. Replace JavaScript-based queries with native MongoDB aggregation pipeline operators ($expr, $match, $filter). Disable server-side JavaScript entirely if not needed: mongod --noscripting.

Use mongoose schemas with sanitizeFilter:

const mongoose = require('mongoose');
mongoose.set('sanitizeFilter', true);
// Automatically strips $ and . from query keys

Redis Command Injection

How it works

Redis is a command-based protocol, not a query language. Injection risks arise in two contexts: client library command construction with unsanitised input, and server-side Lua scripting via EVAL.

Lua injection via EVAL:

# VULNERABLE — Python with redis-py
import redis
r = redis.Redis()

def get_user_score(username: str):
    # Attacker input: "test\r\nDEL important_key\r\n"
    lua_script = f"""
        return redis.call('ZSCORE', 'leaderboard', '{username}')
    """
    # CRLF injection can break out of Lua into raw Redis commands
    return r.eval(lua_script, 0)

Raw string interpolation into Lua allows CRLF injection or Lua code injection depending on how the client processes the script.

Pipelining and MULTI/EXEC injection:

// VULNERABLE — constructing raw Redis commands from user input
const command = `SET user:${userId}:session ${sessionData}`;
redisClient.sendCommand(command.split(' '));
// If userId is "foo\r\nFLUSHALL\r\n", the pipeline executes FLUSHALL

Prevention

# FIXED — use parameterised EVAL with KEYS and ARGV
import redis

r = redis.Redis()

def get_user_score(username: str):
    # username passed as ARGV[1], not interpolated into script
    lua_script = """
        return redis.call('ZSCORE', KEYS[1], ARGV[1])
    """
    return r.eval(lua_script, 1, 'leaderboard', username)
    #                         ^  ^key count    ^key    ^arg (username)
// FIXED — use structured command arrays, never string concatenation
const result = await redisClient.zScore('leaderboard', username);
// ioredis and node-redis handle serialisation safely when using typed methods

Key rules for Redis:

  • Always pass user input as KEYS/ARGV parameters to EVAL, never interpolate into Lua source
  • Use typed client methods (GET, SET, ZADD) rather than raw command construction
  • Enable Redis AUTH and ACL rules to limit which commands can be executed per client
  • Disable dangerous commands in production: rename-command FLUSHALL "" in redis.conf

Elasticsearch Query Injection

How it works

Elasticsearch uses a JSON-based Query DSL. Applications that construct queries by concatenating user strings into JSON, or that expose the _search endpoint directly, are vulnerable to query manipulation.

Search endpoint exposure:

# VULNERABLE — constructing ES query with string formatting
def search_products(query: str):
    es_query = f"""
    {{
        "query": {{
            "query_string": {{
                "query": "{query}"
            }}
        }}
    }}
    """
    # Attacker: query = '" } } } }'  (closes the JSON, injects new clauses)
    response = es.search(index='products', body=json.loads(es_query))

query_string abuse:

The query_string query type accepts Lucene syntax including wildcards, field-scoped searches, and Boolean operators. Even when the JSON is valid, an attacker can:

  • Access fields they shouldn’t: query=_source.password:*
  • Perform wildcard scans to extract data: query=ssn:*
  • Cause ReDoS with complex regex: query=/a.*a.*a.*a.*a.*a.*a.*a.*Z/

Prevention

# FIXED — use match query instead of query_string
from elasticsearch import Elasticsearch

es = Elasticsearch()

def search_products(query: str, category: str):
    # Validate inputs
    if not isinstance(query, str) or len(query) > 200:
        raise ValueError("Invalid query")
    
    # Use 'match' — not 'query_string' — to avoid Lucene injection
    response = es.search(
        index='products',
        body={
            "query": {
                "bool": {
                    "must": [
                        {"match": {"name": query}},  # safely handled
                    ],
                    "filter": [
                        {"term": {"category": category}},
                        {"term": {"active": True}}
                    ]
                }
            },
            "_source": ["id", "name", "price", "category"],  # field allowlist
            "size": 20
        }
    )
    return response['hits']['hits']
// Java with Elasticsearch High Level REST Client
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
    .query(
        QueryBuilders.boolQuery()
            .must(QueryBuilders.matchQuery("name", userInput))  // safe
            .filter(QueryBuilders.termQuery("active", true))
    )
    .fetchSource(new String[]{"id", "name", "price"}, null)  // field allowlist
    .size(20);

Key rules for Elasticsearch:

  • Never use query_string with user input — use match, term, range, or multi_match
  • Define _source field allowlists to prevent sensitive field exfiltration
  • Disable _all field mapping (deprecated in ES7, but verify if upgrading from older clusters)
  • Do not expose the _search API endpoint directly to the internet — proxy through application logic
  • Use Elasticsearch security (X-Pack) with document-level security if tenants share an index

General Principles Across NoSQL Databases

Validate types before query construction. Every user-supplied value should be asserted as the expected type — string, integer, ObjectId — before being passed to any query method.

Build queries with driver methods, not string concatenation. MongoDB’s find(), Redis’s typed commands, and Elasticsearch’s query builder DSL all handle escaping correctly when used as designed. String interpolation into query text bypasses these protections.

Minimise query capability exposed to users. The search interface users interact with should accept a narrow, well-defined set of parameters — not a passthrough to the underlying query language. Treat every query parameter as untrusted input that needs mapping to a safe, predefined query structure.

Disable unused features. MongoDB $where and $function require server-side JavaScript — disable it if not needed. Redis’s EVAL, DEBUG, and CONFIG commands can be renamed or restricted. Elasticsearch’s query_string is powerful but rarely necessary in application search implementations.