NoSQL injection
Need
Prevention of NoSQL injection by keeping the structure of database queries fixed
Context
• Usage of Elixir 1.15+ for building application services
• Usage of the mongodb_driver library for MongoDB access
Description
1. Non compliant code
defmodule MyApp.Auth do
def authenticate(topology, params) do
Mongo.find_one(topology, "users", params["credentials"])
end
endThe `authenticate/2` function below passes the `credentials` request parameter, a map decoded from JSON, directly as the filter of `Mongo.find_one/3`. The caller therefore controls the whole query, operators included. Sending `{"email": "admin@example.com", "password": {"$ne": ""}}` matches the administrator account without knowing its password, because MongoDB evaluates `$ne` as "not equal to the empty string". Operators such as `$regex`, `$gt` or `$where` let an attacker enumerate accounts, extract values character by character or run expensive queries. The same weakness appears when a single field such as `%{user_id: params["user_id"]}` receives a map instead of a string, and with `ExAws.Dynamo` when request data is interpolated into `key_condition_expression` or `filter_expression`.
2. Steps
• Never pass request parameters directly as the filter of `Mongo.find/3`, `Mongo.find_one/3`, `Mongo.update_one/4` or `Mongo.delete_one/3`.
• Build filters with fixed field names and check with guards such as `is_binary/1` that every value has the expected type.
• Reject map keys that start with `$` when a dynamic document must be accepted.
• With `ExAws.Dynamo`, keep condition and filter expressions constant and pass request data through `expression_attribute_values`.
• Verify passwords against stored hashes in application code instead of matching them in the query.
3. Secure code example
defmodule MyApp.Auth do
def authenticate(topology, %{"email" => email, "password" => password})
when is_binary(email) and is_binary(password) do
case Mongo.find_one(topology, "users", %{"email" => email}) do
%{"hashed_password" => hash} = user ->
if Bcrypt.verify_pass(password, hash), do: {:ok, user}, else: :error
_ ->...The corrected function builds the filter itself, with a fixed shape and only the `email` field, and accepts it only when the value is a binary. A map such as `%{"$ne" => ""}` fails the `is_binary/1` guard and the function returns `:error` without querying the database. The password is no longer part of the query. The function loads the user by email and checks the submitted password against the stored Bcrypt hash with `Bcrypt.verify_pass/2`; when no user exists, `Bcrypt.no_user_verify/0` runs the same amount of work, so response times do not reveal which emails are registered. For DynamoDB, the equivalent fix is to keep expressions as constant strings with placeholders and send values only through `expression_attribute_values`.
References
• 106. NoSQL injection