Skip to content

CWE-943: Improper Neutralization of Special Elements in Data Query Logic - Python

Overview

NoSQL Injection in Python applications occurs when untrusted input is used to construct NoSQL database queries (MongoDB, Redis, CouchDB, DynamoDB, etc.) without proper validation. Untrusted input can originate from HTTP requests, external APIs, databases, files, message queues, or any source outside the application's control. An attacker who reshapes a query this way can bypass authentication, read or modify data outside their authorization, or run JavaScript on the database server.

Primary Defence: Never hand a decoded request body to a query. Require the type each value must have, on the code path that builds the filter, and build the filter dictionary in code so the request supplies values and the application supplies every key and operator. Which check that is depends on where the value came from: a JSON body arrives already typed, so isinstance(value, str) is the test and anything else is a 400; a query string arrives as text, so int(raw) or float(raw) is the test, because it either produces the type or raises. Do not reach for str(value) on a field that should be a string - it coerces rather than rejects, turning {"$ne": None} into the harmless but wrong literal "{'$ne': None}" and searching for a username nobody has instead of reporting the problem. Allowlist anything that names a field rather than a value: filter fields, sort targets, projections. PyMongo has no parameterization and no sanitizing layer, so this is the whole of the defence; mongoengine's keyword-argument queries are the equivalent for the ODM, and __raw__ opts back out of it. Run the application's database account with least privilege as well - a query the attacker reshapes can only reach what the credential permits.

Common Python NoSQL Vulnerabilities:

  • MongoDB query injection via operator injection ($ne, $gt, $where, $regex)
  • MongoDB aggregation pipeline injection
  • Redis key-namespace injection via unvalidated keys, and Lua script injection via eval
  • CouchDB view query manipulation
  • DynamoDB expression injection

Popular Python NoSQL Libraries:

  • PyMongo: Official MongoDB driver
  • Motor: Async MongoDB driver for Python
  • mongoengine: MongoDB ODM (Object-Document Mapper)
  • redis-py: Redis client
  • boto3: AWS DynamoDB client

Common Vulnerable Patterns

MongoDB Operator Injection

# VULNERABLE - Direct untrusted input in MongoDB query
from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017/')
db = client['app_database']

def authenticate_user(username, password):
    # VULNERABLE - Untrusted input directly in query
    user = db.users.find_one({
        'username': username,
        'password': password
    })

    return user is not None

# Attack: username = {"$ne": null}, password = {"$ne": null}
# Query becomes: {'username': {'$ne': None}, 'password': {'$ne': None}}
# Returns first user (authentication bypass!)

Why this is vulnerable: authenticate_user assumes both arguments are strings and never checks. An annotation would not help - def authenticate_user(username: str, password: str) is a hint CPython does not enforce - so a dict decoded from request.get_json() reaches the body of the function and goes into the filter as-is. PyMongo serialises it faithfully, and {'$ne': None} becomes an operator that matches the named user whatever their stored password is.

Nothing is concatenated and no character needs escaping - the injection is a change of type, which is why input filtering aimed at quotes or semicolons does not touch it. Note also where it can come from: request.args values are always str, so this arrives through a JSON body, not a query string.

Flask API with JSON Injection

# VULNERABLE - Accepting arbitrary JSON in queries
from flask import Flask, request, jsonify
from pymongo import MongoClient

app = Flask(__name__)
client = MongoClient('mongodb://localhost:27017/')
db = client['shop']

@app.route('/api/products', methods=['POST'])
def search_products():
    # VULNERABLE - Arbitrary query object from untrusted source
    query = request.get_json()

    # No validation on query structure
    products = list(db.products.find(query))

    return jsonify(products)

# Attack POST body: {"price": {"$gt": 0}, "admin_only": {"$ne": true}}
# Bypasses access controls, retrieves admin products

Why this is vulnerable: request.get_json() returns the body as a dict and that dict becomes the filter, so the caller chooses the fields, the operators and the values. Whatever the endpoint was meant to search, every document in the collection is reachable: filter on an internal flag, use $ne to invert a restriction the UI applies, or use $regex to read a field out one character at a time.

An endpoint that takes a filter document from a client cannot be repaired by validating that document - the shapes worth allowing are unbounded. It has to take named parameters and build the filter itself, which is what the allowlist below does.

MongoDB $where Operator Injection

# VULNERABLE - JavaScript code injection via $where
from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017/')
db = client['app']

def find_users_by_age(age):
    # VULNERABLE - String concatenation in $where
    query = {
        '$where': f'this.age > {age}'
    }

    users = list(db.users.find(query))
    return users

# Attack: age = "0 || true"
# Expression becomes: this.age > 0 || true  -> matches every document
# Attack: age = "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 server, which evaluates it once per candidate document, and the f-string drops the caller's text straight into it. An || makes the predicate unconditionally true; a function expression that never returns burns a server thread; this.<field> reaches any field on the document, including ones the endpoint never projects.

$where was deprecated in MongoDB 8.0 and the server logs a warning, but it is not removed and server-side scripting is enabled by default - a payload that reaches it still runs. The payloads above are written as expressions rather than as statement lists (0; return true; //) because a statement list depends on how the server wraps the string, while an expression is true under any wrapping. Use $expr with aggregation operators instead; it compares without evaluating anything.

MongoEngine __raw__ Injection

# VULNERABLE - MongoEngine with raw queries
from mongoengine import Document, StringField, connect
from flask import request

connect('mydb')

class User(Document):
    username = StringField()
    email = StringField()
    role = StringField()

def get_user_profile(username):
    # VULNERABLE - Using __raw__ with untrusted input
    query = {'username': username}
    user = User.objects(__raw__=query).first()

    return user

# Attack: username = {"$ne": None}
# Query becomes: {'username': {'$ne': None}}
# Matches every user with a username, and .first() returns one of them

Why this is vulnerable: __raw__ is the documented way to send a query mongoengine's keyword syntax cannot express, and it does exactly that - it hands the dictionary to PyMongo untouched, so none of the field types declared on the Document are consulted. A username that arrives from request.get_json() as a dict rather than a string becomes a query operator.

Note the shape of the payload. {"$ne": None} works; {"$ne": None, "role": "admin"} does not, because a field's predicate document may hold operators or a literal but not both - MongoDB rejects the mixture rather than treating role as a second condition. An attacker who wants another field has to control the outer dictionary, which is what the Flask example above allows and this one does not.

Unvalidated Key Path in Redis

# VULNERABLE - Redis key chosen by the caller
import redis
from flask import Flask, request

app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379)

@app.route('/cache/<key>')
def get_cache(key):
    # VULNERABLE - Untrusted input in Redis key
    value = r.get(key)
    return value or 'Not found'

@app.route('/set_cache')
def set_cache():
    key = request.args.get('key')
    value = request.args.get('value')

    # VULNERABLE - the caller names the key that gets written
    r.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 reads whatever the URL says, so any value the application caches - session records, password-reset codes, rate-limit counters - is one request away, and set_cache writes over 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. Captured from redis-py 8.1.0, that exact set puts this on the socket:

*3\r\n$3\r\nSET\r\n$15\r\ntest\r\nFLUSHDB\r\n\r\n$1\r\nv\r\n

$15 tells the server to read 15 bytes and treat them as one key, so the CRLF is data and FLUSHDB is stored rather than executed. Nothing strips it, either - removing newlines from a value defends nothing and corrupts data that legitimately contains them. Redis command injection through redis-py 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
from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017/')
db = client['analytics']

def get_user_stats(user_id, sort_field):
    # VULNERABLE - Untrusted input in aggregation pipeline
    pipeline = [
        {'$match': {'user_id': user_id}},
        {'$sort': {sort_field: -1}},
        {'$limit': 10}
    ]

    results = list(db.events.aggregate(pipeline))
    return results

# Attack: sort_field = "password_hash"
# Orders results by a field the caller was never shown, leaking its ordering
# Attack: sort_field = "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 - a $sort value is 1, -1 or {$meta: ...}, so a sort key cannot become something the server executes, and sort_field = {"$where": ...} cannot even be constructed here: a dict is unhashable, so {'$sort': {sort_field: -1}} raises TypeError: unhashable type: 'dict' before any query is sent.

What an attacker gets instead is real enough. Sorting by a field the endpoint does not return still leaks its order, which is enough to binary-search a hidden value across requests, and choosing an unindexed field turns a cheap indexed scan into a blocking sort over the entire match. Allowlist the sort field for the same reason you allowlist a filter field: it names part of the query, and a value binding will not cover it.

MongoDB Regex Injection

# VULNERABLE - Regex injection in queries
from pymongo import MongoClient
import re

client = MongoClient('mongodb://localhost:27017/')
db = client['app']

def search_users(search_term):
    # VULNERABLE - Untrusted input in regex without escaping
    query = {
        'username': {'$regex': search_term, '$options': 'i'}
    }

    users = list(db.users.find(query))
    return users

# Attack: search_term = ".*"
# Returns ALL users (DoS, data exfiltration)
# Attack: search_term = "^admin.*$"
# Discovers admin usernames

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 of the collection. ^admin turns the endpoint into an oracle: repeat it with one more character each time and the stored usernames come out prefix by prefix, without ever seeing a document you are not allowed to see.

A pattern chosen for backtracking cost is the third risk, and it needs something that forces the engine to fail - (a+)+$ against a long run of a ending in another character, not the (a+)+ usually quoted, which succeeds on a prefix and returns immediately. Note that the pattern runs on the MongoDB server under PCRE, so nothing configured on Python's re module applies to it. Escape the term with re.escape() before putting it in $regex, or use a text index and $text if what you want is search rather than pattern matching.

DynamoDB Expression Injection

# VULNERABLE - DynamoDB filter expression injection
import boto3
from boto3.dynamodb.conditions import Attr

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Users')

def search_users(attribute_name, value):
    # VULNERABLE - Untrusted attribute name
    response = table.scan(
        FilterExpression=Attr(attribute_name).eq(value)
    )

    return response['Items']

# Attack: attribute_name = "admin", value = True
# Bypasses intended search, finds admin users

Why this is vulnerable: The caller chooses which attribute the scan filters on. Attr() prevents expression-syntax injection, but it does not restrict which attribute the condition tests, and nothing here allowlists the name. attribute_name = "admin" filters on an attribute the endpoint never offered, so the search becomes a way to enumerate items by a field the caller was never meant to query.

Secure Patterns

Type Validation, with the Password Kept Out of the Query

# SECURE - the filter holds one validated string and no password
import bcrypt
from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017/')
db = client['app_database']

# A real bcrypt hash (cost 12) of a passphrase no account uses. Comparing
# against it costs what comparing against a stored hash costs.
DUMMY_HASH = b'$2b$12$pU7D7eRetgrwwJ3sAUEJLOzaEeceMxhK9tPwON/jSyJICEmBXoMqu'


def validate_string(value, max_length: int = 100) -> str:
    """Reject anything that is not a plain string of a sane length."""
    if not isinstance(value, str):
        raise ValueError("Expected string value")

    if len(value) > max_length:
        raise ValueError(f"Value exceeds max length {max_length}")

    return value


def authenticate_user(username, password) -> bool:
    """Authenticate without putting the password in the query."""
    # SECURE - a dict carrying {"$ne": None} fails isinstance() here
    clean_username = validate_string(username, max_length=50)
    clean_password = validate_string(password, max_length=100)

    # SECURE - the only user-controlled value in the filter is a str
    user = db.users.find_one({'username': clean_username})

    # SECURE - hash on both paths. Returning early when the user does not
    # exist would make an unknown username far faster than a wrong password.
    stored = user['password_hash'] if user else DUMMY_HASH
    if isinstance(stored, str):
        stored = stored.encode('utf-8')  # a hash stored as BSON string reads back as str
    ok = bcrypt.checkpw(clean_password.encode('utf-8'), stored)

    return user is not None and ok

Why this works: isinstance(value, str) is the control, and it is doing real work here in a way the annotation is not: def authenticate_user(username: str, ...) is a hint that CPython never enforces, so a dict decoded from request.get_json() reaches the body of the function whatever the signature says. The runtime check is what stops {"$ne": None} before it becomes a query operator, and it has to sit on the path that actually builds the filter - a Pydantic or marshmallow model validated at the API boundary does nothing for a filter assembled separately from request.get_json().

The password is not in the query at all. 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 third part and the one most often dropped. return False as soon as find_one comes back None skips bcrypt entirely: measured with bcrypt 5.0.0 at cost 12, the comparison against DUMMY_HASH takes 203.3 ms against 202.2 ms for a real hash, where an early return answers in microseconds. That gap tells an attacker which usernames exist - see CWE-208 and CWE-287.

Dependencies: pip install bcrypt (5.0.0 at the time of writing).

Flask with Query Allowlist

# SECURE - Field allowlist and validation
from flask import Flask, request, jsonify
from pymongo import MongoClient

app = Flask(__name__)
client = MongoClient('mongodb://localhost:27017/')
db = client['shop']

def parse_text(value: str) -> str:
    """Accept a short free-text value."""
    if len(value) > 100:
        raise ValueError("value too long")
    return value


def parse_price(value: str) -> float:
    """Convert to a number, rejecting the whole string if any of it is not."""
    price = float(value)  # raises ValueError on '10; drop', '' or 'nan '
    if not 0 <= price <= 1_000_000:
        raise ValueError("price out of range")
    return price


# SECURE - each allowed field names the parser that produces its value
ALLOWED_FIELDS = {
    'name': parse_text,
    'category': parse_text,
    'price_min': parse_price,
    'price_max': parse_price,
}

def build_safe_query(params: dict) -> dict:
    """Build a MongoDB filter whose every key is written here, not sent."""
    query = {}
    price = {}

    for field, raw in params.items():
        # SECURE - Only allow allowlisted fields
        parse = ALLOWED_FIELDS.get(field)
        if parse is None:
            continue

        # SECURE - convert, rather than check: a query string is all strings
        value = parse(raw)

        # SECURE - the operator is chosen by the field name, not by the caller
        if field == 'price_min':
            price['$gte'] = value
        elif field == 'price_max':
            price['$lte'] = value
        else:
            query[field] = value

    if price:
        query['price'] = price

    return query

@app.route('/api/products', methods=['GET'])
def search_products():
    try:
        # SECURE - Build validated query
        safe_query = build_safe_query(request.args.to_dict())
    except ValueError as exc:
        return jsonify({'error': str(exc)}), 400

    # Excluding _id keeps ObjectId out of the response, which jsonify cannot encode
    products = list(db.products.find(safe_query, {'_id': 0}).limit(100))

    return jsonify(products)

Why this works: Every key in the filter is a literal written in this function. ALLOWED_FIELDS decides which request fields are looked at, the if chain decides which operator each one becomes, and the caller supplies only the value on the right-hand side. A request carrying admin_only or $where finds no entry in the dictionary and is dropped.

The part worth copying is that the values are parsed, not type-checked. Everything in request.args is a str, so the more obvious isinstance(value, (int, float)) matches nothing and silently drops both price bounds - the endpoint keeps working, returns products, and has quietly stopped filtering by price. float(raw) either produces a number or raises, so a field that survives is the type the query needs, and a field that does not gets a 400 instead of being ignored. That distinction only shows up in a test that asserts a price filter actually narrowed the results; a re-scan and a "no operators got through" test both pass either way.

The same check has to be repeated for request.get_json(). There the values are already typed, so isinstance is the right tool - but they are typed by the attacker, and a body is where a dict can arrive in place of a string.

No $where, Use Safe Operators

# SECURE - Avoid $where, use safe operators
from pymongo import MongoClient
from typing import Union

client = MongoClient('mongodb://localhost:27017/')
db = client['app']

def validate_age(age: Union[int, str]) -> int:
    """Validate and convert age to integer."""
    try:
        age_int = int(age)
        if age_int < 0 or age_int > 150:
            raise ValueError("Age out of valid range")
        return age_int
    except (ValueError, TypeError):
        raise ValueError("Invalid age value")

def find_users_by_age(min_age: Union[int, str]) -> list:
    """Find users by minimum age using safe operators."""
    # SECURE - Validate input
    clean_age = validate_age(min_age)

    # SECURE - Use safe $gte operator instead of $where
    query = {
        'age': {'$gte': clean_age}
    }

    users = list(db.users.find(query).limit(100))
    return users

Why this works: int(age) is the control, and it does two things at once: it rejects anything that is not wholly a number, and it produces a value whose type MongoDB compares numerically. A dict cannot survive it, so there is nothing left for $gte to interpret as an operator. The bounds check (0-150) is a separate, ordinary validation - it keeps an absurd value out of the query, not an injection.

Comparing with $gte rather than evaluating this.age >= n in $where is what removes the injection class rather than narrowing it. $gte is a comparison the server performs; there is no expression for the caller's text to become part of. Most predicates written with $where have an equivalent in the standard operators or in $expr, which reaches other fields on the same document without executing anything. The limit(100) bounds the result set so a broad range cannot be used to pull the collection.

MongoEngine with Field Validation

# SECURE - MongoEngine with proper field access
import re

from mongoengine import Document, StringField, EmailField, connect
from flask import request, abort

connect('mydb')

class User(Document):
    username = StringField(required=True, max_length=50)
    email = EmailField(required=True)
    role = StringField(choices=['user', 'admin'])

USERNAME_RE = re.compile(r'[a-zA-Z0-9_.-]{1,50}')

def validate_username(username) -> str:
    """Validate username format."""
    if not isinstance(username, str):
        raise ValueError("Username must be a string")

    if not USERNAME_RE.fullmatch(username):
        raise ValueError("Invalid username format")

    return username

def get_user_profile(username: str):
    """Get user profile securely."""
    # SECURE - Validate input
    clean_username = validate_username(username)

    # SECURE - Use ODM fields, not __raw__
    user = User.objects(username=clean_username).first()

    if not user:
        abort(404)

    return user

Why this works: User.objects(username=clean_username) names the field in Python syntax, so the field and the comparison are both fixed at the call site and only the value comes from the request. mongoengine builds the filter document itself; there is no dictionary for a $ne to appear in.

validate_username() is the part that has to be a fullmatch rather than a str method. username.isalnum() reads like the same check and is not: it rejects alice_99, which the schema permits, and accepts aliçe and Arabic-Indic digits, which a pattern of [a-zA-Z0-9_.-] does not. Pin the alphabet you mean.

Be clear about what the schema contributes, because it is easy to over-credit. StringField and EmailField validate documents on save; they are not consulted when a filter is built, and __raw__ bypasses the query builder entirely. The protection here comes from how the query is written, not from the class it is written against.

Redis with an Application-Composed Key

# SECURE - the application decides the key, the caller supplies one segment
import redis
from flask import Flask, request, abort
import re

app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379)

def validate_redis_key(key: str) -> str:
    """Validate Redis key format."""
    if not isinstance(key, str):
        raise ValueError("Key must be a string")

    # SECURE - fullmatch, not match: re.match(r'...$') accepts a trailing
    # newline, because Python's $ also matches just before one
    if not re.fullmatch(r'[a-zA-Z0-9_-]{1,100}', key):
        raise ValueError("Invalid key format")

    return key

def validate_redis_value(value: str) -> str:
    """Bound the value. Its contents need no filtering - see below."""
    if not isinstance(value, str):
        raise ValueError("Value must be a string")

    if len(value) > 10_000:
        raise ValueError("Value too large")

    return value

@app.route('/cache/<key>')
def get_cache(key):
    try:
        # SECURE - Validate key
        clean_key = validate_redis_key(key)
        value = r.get(clean_key)
        return value.decode('utf-8') if value else 'Not found'
    except ValueError as e:
        abort(400, str(e))

@app.route('/set_cache', methods=['POST'])
def set_cache():
    try:
        key = request.form.get('key', '')
        value = request.form.get('value', '')

        # SECURE - Validate both key and value
        clean_key = validate_redis_key(key)
        clean_value = validate_redis_value(value)

        # SECURE - Use setex with expiration
        r.setex(clean_key, 3600, clean_value)

        return 'OK'
    except ValueError as e:
        abort(400, str(e))

Why this works: validate_redis_key pins the key to [a-zA-Z0-9_-], which excludes :. That is the whole control, and it is a specific one: : is what separates namespaces in a Redis keyspace, so excluding it means a caller cannot climb out of the namespace the handler intends into session: or reset:. A key allowlist that permitted : would look just as strict and defend nothing.

Use re.fullmatch rather than re.match with ^...$. Python's $ matches at the end of the string and just before a final newline, so re.match(r'^[a-zA-Z0-9_-]{1,100}$', 'abc\n') returns a match - measured on 3.13 - and the key written is not the key that was validated. re.fullmatch has no such exception. The same trap exists in .NET and not in Java, JavaScript or Go, so a pattern copied between these pages is correct on three of them and wrong on two; feed each allowlist its own permitted value with a newline appended rather than reading the regex.

The value is bounded but not filtered, and that is deliberate. Stripping \r and \n from a cached value is a defence 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 puts a TTL on what is written, so a poisoned or stale entry ages out rather than persisting until someone notices. For Lua, pass values through KEYS/ARGV on eval - the script source is text, and concatenating into it is genuinely injectable in a way a key or value is not.

Safe MongoDB Aggregation

# SECURE - MongoDB aggregation with field allowlist
import re

from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017/')
db = client['analytics']

# SECURE - Define allowed sort fields
ALLOWED_SORT_FIELDS = ['timestamp', 'event_type', 'user_id']

USER_ID_RE = re.compile(r'[a-zA-Z0-9_-]{1,50}')

def validate_user_id(user_id) -> str:
    """Validate user ID format."""
    if not isinstance(user_id, str):
        raise ValueError("User ID must be a string")

    if not USER_ID_RE.fullmatch(user_id):
        raise ValueError("Invalid user ID format")

    return user_id

def get_user_stats(user_id: str, sort_field: str) -> list:
    """Get user statistics with safe aggregation."""
    # SECURE - Validate user ID
    clean_user_id = validate_user_id(user_id)

    # SECURE - Validate sort field against allowlist
    if sort_field not in ALLOWED_SORT_FIELDS:
        raise ValueError(f"Invalid sort field. Allowed: {ALLOWED_SORT_FIELDS}")

    # SECURE - Build pipeline with validated values
    pipeline = [
        {'$match': {'user_id': clean_user_id}},
        {'$sort': {sort_field: -1}},
        {'$limit': 100}
    ]

    results = list(db.events.aggregate(pipeline))
    return results

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 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, and it is worth naming precisely. 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 endpoint 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.

$limit: 100 caps the documents the stage emits, bounding both the response and the sort that precedes it.

Testing

To verify NoSQL injection protection:

  • Operator injection through the body: POST {"username": "admin", "password": {"$ne": null}} and assert a 400, not a 200. Note where the payload has to go: request.args values are always str, so a query-string version of this test passes whether or not the bug is fixed.
  • The allowlisted filter still filters: request ?price_min=20 against a fixture holding items priced 10 and 30, and assert one result. A type check against (int, float) drops both price bounds silently - the endpoint returns products, no operator gets through, and the filter is gone. Only an assertion on the narrowed result set sees it.
  • A rejected value fails loudly: request ?price_min=abc and assert a 400. Dropping it and returning the unfiltered set is the failure mode this catches.
  • 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 bcrypt.checkpw.
  • __raw__ and $where have no untrusted input: grep for both, and confirm each occurrence is built from literals or allowlisted values. Neither is reachable through mongoengine's keyword syntax, so a grep is the only thing that finds them.

Common Pitfalls

  • boto3's Attr()/Key() condition builders prevent expression-syntax injection, but they don't restrict which attribute is queried - an attribute name taken from unchecked user input still points the query at whatever attribute the attacker names, so an explicit field allowlist is still required.
  • MongoEngine's ODM query builder (User.objects(username=value)) is the documented fix for __raw__, but a separate "advanced search" or admin filter endpoint that keeps __raw__ for queries the keyword-argument style can't express reopens the same injection outside the endpoints that were fixed.
  • PyMongo and Motor have no built-in query sanitization - there is no equivalent of Mongoose's optional sanitizeFilter from the Node.js ecosystem. Validating a request against a Pydantic or marshmallow model at the API boundary doesn't protect a query filter that's assembled separately from raw request.get_json()/request.args data, since the two are different objects even when the model appears to cover the same fields.

Additional Resources