Flask · NoSQL injection

NoSQL injection in Flask

request.get_json() returns whatever JSON the client sent, including nested objects, and a Flask view that drops one of those values into a PyMongo filter has let the client write part of the query.

Class
NoSQL injection in Python
CWE
CWE-943
web framework
Flask documentation
On this page

How it arises in Flask

MongoDB's own documentation makes the reassuring half of the point: a client program assembles a query as a BSON object, not a string, so traditional SQL injection attacks are not a problem. A value like ' OR 1=1 is just a string that matches nothing.

The other half is about types. In PyMongo a filter is a Python dictionary, and a dictionary value can itself be a dictionary of operators. Flask does not fix the type of a JSON field.

Operator injection. The view expects {"username": "ana", "password": "secret"} and builds {"username": u, "password": p}. The client sends {"username": "admin", "password": {"$ne": null}}. The filter becomes "username is admin and password is not null", and find_one() returns the admin document.

Form and query-string variants. request.args and request.form hold strings, which is safer by accident. Code that parses a filter parameter with json.loads() to support "advanced search" reintroduces dictionaries.

Whole-filter passthrough. collection.find(request.get_json()) for an internal dashboard. The caller chooses every field and operator, including ones that read other tenants' documents.

$where and server-side JavaScript. MongoDB lists $where, mapReduce, $accumulator and $function as operations that run JavaScript expressions on the server and says you must exercise care to prevent users from submitting malicious JavaScript. A $where string built with an f-string is code injection into the database.

Vulnerable and fixed

from flask import Flask, jsonify, request
from pymongo import MongoClient

app = Flask(__name__)
db = MongoClient()["shop"]


@app.post("/login")
def login():
    body = request.get_json()
    user = db.users.find_one({"username": body["username"], "password": body["password"]})
    return jsonify(ok=user is not None)


@app.get("/products")
def products():
    limit = request.args.get("max_price", "100")
    rows = db.products.find({"$where": f"this.price < {limit}"})
    return jsonify([r["name"] for r in rows])

Require the types you expect before building the filter, and express the comparison with an operator and a number, not JavaScript. (Passwords belong in a hash compared in Python; that change is separate from the injection fix and shown here.)

from flask import Flask, abort, jsonify, request
from pymongo import MongoClient
from werkzeug.security import check_password_hash

app = Flask(__name__)
db = MongoClient()["shop"]


@app.post("/login")
def login():
    body = request.get_json(silent=True) or {}
    username, password = body.get("username"), body.get("password")
    if not isinstance(username, str) or not isinstance(password, str):
        abort(400)
    user = db.users.find_one({"username": username})
    ok = user is not None and check_password_hash(user["password_hash"], password)
    return jsonify(ok=ok)


@app.get("/products")
def products():
    limit = request.args.get("max_price", default=100.0, type=float)
    rows = db.products.find({"price": {"$lt": limit}})
    return jsonify([r["name"] for r in rows])

What Flask, PyMongo and MongoDB already do for you

PyMongo sends filters as BSON, so there is no query string to break out of. Werkzeug's request.args.get(..., type=float) converts and falls back to the default on bad input, which is the shortest type check available. MongoDB can disable server-side JavaScript entirely with security.javascriptEnabled: false or --noscripting, and its documentation notes that most operations can be expressed without JavaScript.

What es-python adds

es-python reports when request data reaches a PyMongo Collection method that takes a filter or a pipeline: find, find_one, count_documents, the delete_*, update_* and find_one_and_* methods, and aggregate. The label is nosql mongodb, with the nosql injection impact tag.

The report is narrowed to what makes this class dangerous. A request string used as a plain value, as in the fixed login, is ordinary querying. What es-python singles out are $-prefixed operator keys in the filter that came from the request, and the value of the code-bearing operators such as $where, $expr and $function. So the {"$ne": null} login and the formatted $where string are both reported, and the event shows the operator and the view.

That gives you a direct test. Replay a login with an operator object as the password under es-python with the socket source on. If the event appears, the type check is missing somewhere on that path. An event built from network data is graded Critical.

The ready-made Block backend network attacks template includes nosql mongodb: with it applied, a MongoDB query operator that came from the request makes the call raise a RuntimeError before the query runs, and correct queries are not blocked.

Review checklist

  1. Search for find(, find_one(, update_one( and delete_many( whose filter contains a value from request.
  2. Check isinstance(value, str) (or convert) before a value enters a filter.
  3. Never pass a client-built dictionary as the filter.
  4. Replace $where with query operators; disable server-side JavaScript if you can.
  5. Validate bodies with a schema library so types are enforced in one place.

References

See it on your own Flask code

es-python runs your application unchanged and reports when request data reaches the operation, with where the data came from and the line that used it. A live demo walks through it on a real application.

EyalSec Pricing Docs Security Research Vulnerabilities Security glossary About Contact עברית Login Book a live demo