Skip to content

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

Overview

NoSQL Injection in C#/.NET applications occurs when untrusted input is used to construct NoSQL database queries (MongoDB, Redis, RavenDB, Azure Cosmos DB, 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. Attackers use it to bypass authentication, read documents the endpoint was never meant to return, or overwrite entries another part of the application owns.

Primary Defence: Bind request bodies to a DTO whose properties are string, double and bool rather than to Dictionary<string, object> or BsonDocument, so the model binder rejects an object where a scalar was declared and no query code has to. Build filters with Builders<T>.Filter against a POCO collection, keeping the field and the operator in code and letting the request supply only values. Parameterize Cosmos DB with QueryDefinition.WithParameter and RavenDB with AddParameter, and allowlist anything those cannot bind - sort columns, container names, attribute names. Run the application's database account with least privilege as well - a query the attacker reshapes can only reach what the credential permits.

Common C# NoSQL Vulnerabilities:

  • MongoDB query operator injection using BsonDocument
  • Azure Cosmos DB SQL injection
  • Redis key-namespace injection via StackExchange.Redis, and Lua script injection via ScriptEvaluate
  • RavenDB query injection
  • LiteDB query injection

Popular C# NoSQL Libraries:

  • MongoDB.Driver: Official MongoDB driver for .NET
  • Microsoft.Azure.Cosmos: Azure Cosmos DB SDK
  • StackExchange.Redis: High-performance Redis client
  • RavenDB.Client: RavenDB .NET client
  • LiteDB: Embedded NoSQL database

Common Vulnerable Patterns

MongoDB Operator Injection

// VULNERABLE - Direct untrusted input in MongoDB query
using MongoDB.Driver;
using MongoDB.Bson;

public class UserService
{
    private IMongoCollection<BsonDocument> _users;

    public bool AuthenticateUser(string username, object password)
    {
        // VULNERABLE - Accepting object type allows operator injection
        var filter = Builders<BsonDocument>.Filter.And(
            Builders<BsonDocument>.Filter.Eq("username", username),
            Builders<BsonDocument>.Filter.Eq("password", password)
        );

        var user = _users.Find(filter).FirstOrDefault();
        return user != null;
    }
}

// Attack: password = new BsonDocument("$ne", BsonNull.Value)
// Query becomes: {username: "user", password: {$ne: null}}
// Authentication bypass!

Why this is vulnerable: object password is the whole defect. C#'s type system would have stopped this on its own - a string parameter cannot hold a BsonDocument - so the method has to widen the type before an operator can reach the query. Once it does, Filter.Eq serialises whatever it was given, and MongoDB reads {$ne: null} as an operator rather than a value.

That is the shape to look for in .NET, and it is narrower than in the dynamic languages: object, dynamic, Dictionary<string, object> or BsonDocument somewhere between the request and the filter. Nothing is concatenated and no character needs escaping - the injection is a change of type.

ASP.NET Core with Query Injection

// VULNERABLE - Accepting arbitrary filter from request
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using MongoDB.Bson;
using System.Collections.Generic;

[ApiController]
[Route("api/products")]
public class ProductController : ControllerBase
{
    private IMongoCollection<BsonDocument> _products;

    [HttpPost("search")]
    public async Task<IActionResult> SearchProducts()
    {
        using var reader = new StreamReader(Request.Body);
        var body = await reader.ReadToEndAsync();

        // VULNERABLE - the request body *is* the query
        var bsonDoc = BsonDocument.Parse(body);
        var filter = new BsonDocumentFilterDefinition<BsonDocument>(bsonDoc);

        var products = _products.Find(filter).ToList();
        return Ok(products);
    }
}

// Attack POST body: {"price": {"$gt": 0}, "admin_only": {"$ne": true}}
// Parses to exactly that filter and runs it - access controls bypassed

Why this is vulnerable: BsonDocument.Parse turns the request body into a query document with its structure intact, and BsonDocumentFilterDefinition passes it to the driver unexamined. The caller picks the fields, the operators and the values, so every document in the collection is reachable no matter what the endpoint was meant to search.

Note where the sink is, because the obvious-looking version of this bug does not fire. Binding [FromBody] Dictionary<string, object> and calling new BsonDocument(dictionary) looks like the same thing and throws instead: under ASP.NET Core's default System.Text.Json stack, each value arrives as a JsonElement, and the driver rejects it with ArgumentException: .NET type System.Text.Json.JsonElement cannot be mapped to a BsonValue. Reading the raw body, or binding with Newtonsoft.Json, is what produces the nested BsonValue tree the attack needs.

MongoDB $where Injection

// VULNERABLE - JavaScript code injection via $where
using MongoDB.Driver;
using MongoDB.Bson;

public class UserRepository
{
    private IMongoCollection<BsonDocument> _users;

    public List<BsonDocument> FindUsersByAge(string minAge)
    {
        // VULNERABLE - String concatenation in $where
        var whereClause = $"this.age > {minAge}";
        var filter = new BsonDocument("$where", whereClause);

        return _users.Find(filter).ToList();
    }
}

// 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 interpolated string 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.

Azure Cosmos DB SQL Injection

// VULNERABLE - Cosmos DB SQL query with string concatenation
using Microsoft.Azure.Cosmos;

public class CosmosService
{
    private Container _container;

    public async Task<List<dynamic>> SearchUsers(string role)
    {
        // VULNERABLE - String concatenation in SQL query
        string sql = $"SELECT * FROM c WHERE c.role = '{role}'";

        var query = _container.GetItemQueryIterator<dynamic>(sql);
        var results = new List<dynamic>();

        while (query.HasMoreResults)
        {
            var response = await query.ReadNextAsync();
            results.AddRange(response);
        }

        return results;
    }
}

// Attack: role = "user' OR 1=1--"
// SQL injection in Cosmos DB
// Returns all documents

Why this is vulnerable: The role is interpolated into the query text, so a value containing a quote closes the string literal early and everything after it is parsed as SQL rather than matched as data. user' OR 1=1-- leaves a WHERE clause that is true for every document, and the method returns the whole container. The Cosmos SQL API takes parameters (QueryDefinition.WithParameter); this code does not use them.

Unvalidated Key Path in Redis

// VULNERABLE - Redis key chosen by the caller
using StackExchange.Redis;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("cache")]
public class CacheController : ControllerBase
{
    private IDatabase _redis;

    [HttpGet("{key}")]
    public IActionResult GetCache(string key)
    {
        // VULNERABLE - Untrusted input in Redis key
        var value = _redis.StringGet(key);
        return Ok(value.ToString());
    }

    [HttpPost]
    public IActionResult SetCache([FromBody] CacheRequest request)
    {
        // VULNERABLE - the caller names the key that gets written
        _redis.StringSet(request.Key, request.Value);
        return Ok();
    }
}

// Attack: key = "session:9f2a"
// Reads or overwrites a key belonging to another part of the application

Why this is vulnerable: Both actions 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 action overwrites any of them.

The payload usually shown for this, key = "test\r\nFLUSHDB\r\n", does not work, and hedging it with "though StackExchange.Redis has some protection" leaves a reader guessing when it might. It never does: RESP length-prefixes every argument, so a StringSet with that key sends $15 followed by 15 bytes 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 speaking the same protocol; StackExchange.Redis builds the same multi-bulk request.) Redis command injection from .NET needs a different sink: user input concatenated into the source of a Lua script passed to ScriptEvaluate, or a pattern given to a KEYS-style scan.

MongoDB Aggregation Injection

// VULNERABLE - Aggregation pipeline with untrusted input
using MongoDB.Driver;
using MongoDB.Bson;
using System.Collections.Generic;

public class AnalyticsService
{
    private IMongoCollection<BsonDocument> _events;

    public List<BsonDocument> GetUserStats(string userId, string sortField)
    {
        // VULNERABLE - Untrusted input in aggregation pipeline
        var pipeline = new[]
        {
            new BsonDocument("$match", new BsonDocument("user_id", userId)),
            new BsonDocument("$sort", new BsonDocument(sortField, -1)),
            new BsonDocument("$limit", 10)
        };

        return _events.Aggregate<BsonDocument>(pipeline).ToList();
    }
}

// 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
using MongoDB.Driver;
using MongoDB.Bson;

public class SearchService
{
    private IMongoCollection<BsonDocument> _users;

    public List<BsonDocument> SearchUsers(string searchTerm)
    {
        // VULNERABLE - Untrusted input in regex without escaping
        var filter = Builders<BsonDocument>.Filter.Regex(
            "username", 
            new BsonRegularExpression(searchTerm, "i")
        );

        return _users.Find(filter).ToList();
    }
}

// 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 pattern runs on the MongoDB server under PCRE, not in .NET, so RegexOptions.NonBacktracking and any Regex timeout configured in the application are irrelevant to it. The anchor in the last payload is also the part worth noticing: (a+)+ on its own is repeated everywhere as a ReDoS example and is not one - it succeeds immediately on a prefix. (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.

RavenDB Query Injection

// VULNERABLE - RavenDB with raw RQL
using Raven.Client.Documents;
using Raven.Client.Documents.Session;

public class RavenService
{
    private IDocumentStore _store;

    public List<User> SearchUsers(string username)
    {
        using var session = _store.OpenSession();

        // VULNERABLE - String concatenation in RQL
        var query = $"from Users where username = '{username}'";
        var users = session.Advanced.RawQuery<User>(query).ToList();

        return users;
    }
}

// Attack: username = "' OR 1=1--"
// RQL injection

Why this is vulnerable: The username is interpolated into RQL, so ' OR 1=1-- closes the quoted literal and leaves a where condition that matches every Users document. RawQuery accepts values through .AddParameter(); interpolating them into the query string instead is what makes this injectable.

Secure Patterns

Typed Parameters, with the Password Kept Out of the Query

// SECURE - the filter holds one validated string and no password
using Microsoft.AspNetCore.Identity;
using MongoDB.Bson;
using MongoDB.Driver;

public class AppUser
{
    public ObjectId Id { get; set; }
    public string Username { get; set; } = "";
    public string PasswordHash { get; set; } = "";
}

public class SecureUserService
{
    private readonly IMongoCollection<AppUser> _users;
    private readonly IPasswordHasher<AppUser> _hasher;

    // SECURE - a decoy produced by the configured hasher, not a pasted literal:
    // Identity reads the iteration count out of the stored hash, so a literal
    // keeps costing whatever it was minted at when IterationCount is raised.
    private readonly string _decoyHash;

    public SecureUserService(IMongoCollection<AppUser> users, IPasswordHasher<AppUser> hasher)
    {
        _users = users;
        _hasher = hasher;
        _decoyHash = hasher.HashPassword(new AppUser(), "no account uses this passphrase");
    }

    public bool AuthenticateUser(string username, string password)
    {
        // SECURE - the parameter type is the type check: a BsonDocument cannot
        // be passed where a string is declared
        if (string.IsNullOrEmpty(username) || username.Length > 50)
        {
            throw new ArgumentException("Invalid username");
        }

        // SECURE - the password is not part of the filter; look up by name only
        var user = _users.Find(Builders<AppUser>.Filter.Eq(u => u.Username, username))
                         .FirstOrDefault();

        // SECURE - hash on both paths. Returning early when the user does not
        // exist would make an unknown username far faster than a wrong password.
        var stored = user?.PasswordHash ?? _decoyHash;
        var result = _hasher.VerifyHashedPassword(user ?? new AppUser(), stored, password);

        return user is not null && result != PasswordVerificationResult.Failed;
    }
}

Why this works: The declared parameter type is the type check. AuthenticateUser(string, string) cannot receive a BsonDocument, so the operator injection the vulnerable version allowed has nowhere to enter, and the model binder enforces the same thing one layer up when these come from a typed request model. That is worth stating because the obvious-looking alternative adds nothing: a ValidateString(object value, ...) helper called only from a string-typed method has an is string test that can never fail. It reads as validation while the signature is doing the work.

Builders<AppUser>.Filter.Eq(u => u.Username, username) then names the field with a lambda against the POCO, so a typo is a compile error and the field is not something the request can choose.

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 is null) return false skips verification entirely, and CWE-287 measures the resulting gap on .NET 10 at 63 ms against 0.003 ms. Note that the decoy is generated in the constructor rather than written in as a literal, for the reason in the comment; that page has the detail.

Dependencies: Microsoft.Extensions.Identity.Core supplies IPasswordHasher<T> and PasswordHasher<T>; no wider ASP.NET Core Identity setup is required to use them.

ASP.NET Core with a Typed Request Model

// SECURE - the model binder does the type checking, the code does the rest
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using MongoDB.Driver;

public class Product
{
    public ObjectId Id { get; set; }
    public string Name { get; set; } = "";
    public string Category { get; set; } = "";
    public double Price { get; set; }
}

// SECURE - a typed request model, so an object where a scalar belongs is a 400
public class ProductSearch
{
    public string? Name { get; set; }
    public string? Category { get; set; }
    public double? PriceMin { get; set; }
    public double? PriceMax { get; set; }
}

[ApiController]
[Route("api/products")]
public class SecureProductController : ControllerBase
{
    private readonly IMongoCollection<Product> _products;

    private static readonly string[] AllowedCategories =
        ["electronics", "clothing", "books"];

    private static FilterDefinition<Product> BuildSafeFilter(ProductSearch search)
    {
        var builder = Builders<Product>.Filter;
        var filters = new List<FilterDefinition<Product>>();

        if (search.Name is { } name)
        {
            filters.Add(builder.Eq(p => p.Name, name));
        }

        if (search.Category is { } category)
        {
            // SECURE - a value naming something the app knows about is allowlisted
            if (!AllowedCategories.Contains(category))
            {
                throw new ArgumentException("Unknown category");
            }
            filters.Add(builder.Eq(p => p.Category, category));
        }

        if (search.PriceMin is { } min)
        {
            filters.Add(builder.Gte(p => p.Price, min));
        }

        if (search.PriceMax is { } max)
        {
            filters.Add(builder.Lte(p => p.Price, max));
        }

        // No criteria at all is a deliberate unfiltered browse, bounded by Limit
        return filters.Count > 0 ? builder.And(filters) : builder.Empty;
    }

    [HttpPost("search")]
    public IActionResult SearchProducts([FromBody] ProductSearch search)
    {
        FilterDefinition<Product> filter;
        try
        {
            filter = BuildSafeFilter(search);
        }
        catch (ArgumentException ex)
        {
            return BadRequest(ex.Message);
        }

        var products = _products.Find(filter).Limit(100).ToList();

        return Ok(products);
    }
}

Why this works: ProductSearch moves the type check to the model binder, which is the one place in an ASP.NET Core request that cannot be bypassed by a later code path. A body of {"category": {"$ne": "books"}} fails to bind - System.Text.Json raises JsonException and the framework returns a 400 - so no query code has to recognise an operator. Each filter is then built with a lambda against the POCO, so the field is fixed at compile time and only the value comes from the request.

The Dictionary<string, object> version of this is worth understanding because it looks equivalent and fails open. Under the default System.Text.Json stack, every value in a bound Dictionary<string, object> arrives as a JsonElement, so a guard written as if (value?.GetType() != expectedType) continue; takes the continue on every field - the runtime type is never string or double, whatever the JSON said. Measured on .NET 10 with a body of {"name":"widget","category":"tools","price_min":10.0,"admin_only":true}: all four fields skipped, filters.Count 0, and the method returns Builders<Product>.Filter.Empty - so Find(filter).Limit(100) answers 200 with the first hundred documents in the collection, unfiltered. Nothing throws, no operator gets through, and the allowlist has stopped filtering. That is the failure direction to watch for: a control that drops conditions rather than rejecting them widens the query.

An unknown category throws rather than being ignored, for the same reason. .Limit(100) bounds the result set, including the no-criteria browse.

Strongly-Typed MongoDB Entities

// SECURE - Strongly-typed entities with LINQ
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.Linq;
using System.Text.RegularExpressions;

public class User
{
    public ObjectId Id { get; set; }
    public string Username { get; set; }
    public string Email { get; set; }
    public string Role { get; set; }
}

public class SecureUserRepository
{
    private IMongoCollection<User> _users;

    // SECURE - \A and \z, not ^ and $: .NET's $ also matches just before a
    // final newline, so ^...$ would accept "alice\n"
    private static readonly Regex UsernamePattern =
        new Regex(@"\A[a-zA-Z0-9_]{3,50}\z", RegexOptions.Compiled);

    private string ValidateUsername(string username)
    {
        if (string.IsNullOrEmpty(username))
        {
            throw new ArgumentException("Username cannot be empty");
        }

        if (!UsernamePattern.IsMatch(username))
        {
            throw new ArgumentException("Invalid username format");
        }

        return username;
    }

    public User GetUser(string username)
    {
        var cleanUsername = ValidateUsername(username);

        // SECURE - Type-safe LINQ query
        return _users.AsQueryable()
            .Where(u => u.Username == cleanUsername)
            .FirstOrDefault();
    }

    public List<User> GetUsersByRole(string role)
    {
        var allowedRoles = new[] { "user", "moderator", "admin" };

        if (!allowedRoles.Contains(role))
        {
            throw new ArgumentException("Invalid role");
        }

        // SECURE - Type-safe query with validated input
        return _users.AsQueryable()
            .Where(u => u.Role == role)
            .ToList();
    }
}

Why this works: u => u.Username == cleanUsername names the field as a property, so the compiler checks it and the driver translates it to { Username: <value> }. There is no dictionary in which a key could be attacker-chosen and no string in which an operator could be embedded - the expression tree fixes both, and cleanUsername is typed string, so it can only be the right-hand side of an equality.

ValidateUsername and the role allowlist are defence in depth on top of that. They are worth keeping - they bound what a legitimate caller can search for - but the property they enforce is "sensible input", not "no injection", and it is the typed collection that provides the second.

One thing this does not do is make IMongoCollection<User> safe in general. Find(filterFromRequest) on the same typed collection is exactly as injectable as it was on BsonDocument: the type parameter constrains deserialisation, not the filter you hand it.

Azure Cosmos DB with Parameterization

// SECURE - Cosmos DB with parameterized queries
using Microsoft.Azure.Cosmos;

public class SecureCosmosService
{
    private Container _container;

    private static readonly string[] AllowedRoles = { "user", "moderator", "admin" };

    private string ValidateRole(string role)
    {
        if (!AllowedRoles.Contains(role))
        {
            throw new ArgumentException("Invalid role");
        }

        return role;
    }

    public async Task<List<User>> SearchUsers(string role)
    {
        // SECURE - Validate input
        var cleanRole = ValidateRole(role);

        // SECURE - Parameterized query
        var queryDefinition = new QueryDefinition(
            "SELECT * FROM c WHERE c.role = @role")
            .WithParameter("@role", cleanRole);

        var query = _container.GetItemQueryIterator<User>(queryDefinition);
        var results = new List<User>();

        while (query.HasMoreResults)
        {
            var response = await query.ReadNextAsync();
            results.AddRange(response);
        }

        return results;
    }
}

Why this works: Parameterized queries (QueryDefinition with .WithParameter()) separate SQL code from data: the @role placeholder is bound as a literal value, so quotes and SQL keywords in the role are matched rather than parsed, where in $"SELECT * FROM c WHERE c.role = '{role}'" they would become part of the query text. Input validation against the allowlist rejects an unknown role before it reaches the database - defence in depth on top of the parameter, not the thing that stops the injection.

Redis with an Application-Composed Key

// SECURE - the application decides the key, the caller supplies one segment
using StackExchange.Redis;
using Microsoft.AspNetCore.Mvc;
using System.Text.RegularExpressions;

[ApiController]
[Route("cache")]
public class SecureCacheController : ControllerBase
{
    private IDatabase _redis;

    // SECURE - \A and \z, not ^ and $ (see the note below this example)
    private static readonly Regex KeyPattern =
        new Regex(@"\A[a-zA-Z0-9_-]{1,100}\z", RegexOptions.Compiled);

    private string ValidateRedisKey(string key)
    {
        if (string.IsNullOrEmpty(key))
        {
            throw new ArgumentException("Key cannot be empty");
        }

        // SECURE - Only allow alphanumeric, dash, underscore
        if (!KeyPattern.IsMatch(key))
        {
            throw new ArgumentException("Invalid key format");
        }

        return key;
    }

    private string ValidateRedisValue(string value)
    {
        if (value == null)
        {
            throw new ArgumentException("Value cannot be null");
        }

        // SECURE - bound the size. The contents need no filtering; see below.
        if (value.Length > 10000)
        {
            throw new ArgumentException("Value too large");
        }

        return value;
    }

    [HttpGet("{key}")]
    public IActionResult GetCache(string key)
    {
        try
        {
            var cleanKey = ValidateRedisKey(key);
            var value = _redis.StringGet(cleanKey);
            return Ok(value.ToString());
        }
        catch (ArgumentException ex)
        {
            return BadRequest(ex.Message);
        }
    }

    [HttpPost]
    public IActionResult SetCache([FromBody] CacheRequest request)
    {
        try
        {
            var cleanKey = ValidateRedisKey(request.Key);
            var cleanValue = ValidateRedisValue(request.Value);

            // SECURE - Use setex with expiration
            _redis.StringSet(cleanKey, cleanValue, TimeSpan.FromHours(1));

            return Ok();
        }
        catch (ArgumentException ex)
        {
            return BadRequest(ex.Message);
        }
    }
}

public class CacheRequest
{
    public string Key { get; set; }
    public string Value { get; set; }
}

Why this works: KeyPattern allows [a-zA-Z0-9_-], 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.

The anchors are \A and \z rather than ^ and $ for a reason that is invisible in the pattern. In .NET, $ matches at the end of the string and just before a final newline, so Regex.IsMatch("abc\n", "^[a-zA-Z0-9_-]{1,100}$") returns true - measured on .NET 10 - and the key that gets written is not the key that was validated. \z is the only strict end anchor; \Z has the same newline exception as $. Python's re behaves the same way and wants re.fullmatch, while Java's matcher().matches(), JavaScript's unflagged $ and Go's MatchString do not - 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, 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.

The TimeSpan.FromHours(1) expiry means a poisoned or stale entry ages out rather than persisting until someone notices. For Lua, pass values through KEYS/ARGV on ScriptEvaluate rather than concatenating them into the script text - the script source is the one place in a StackExchange.Redis call where user input really is parsed as code.

Regex Escaping

// SECURE - MongoDB regex with proper escaping
using MongoDB.Bson;
using MongoDB.Driver;
using System.Text.RegularExpressions;

public class SecureSearchService
{
    private IMongoCollection<User> _users;

    private string EscapeRegex(string input)
    {
        // SECURE - Escape special regex characters
        return Regex.Escape(input);
    }

    private string ValidateSearchTerm(string searchTerm)
    {
        if (string.IsNullOrEmpty(searchTerm))
        {
            throw new ArgumentException("Search term cannot be empty");
        }

        if (searchTerm.Length > 100)
        {
            throw new ArgumentException("Search term too long");
        }

        return searchTerm;
    }

    public List<User> SearchUsers(string searchTerm)
    {
        // SECURE - Validate input
        var cleanTerm = ValidateSearchTerm(searchTerm);

        // SECURE - Escape regex special characters
        var escapedTerm = EscapeRegex(cleanTerm);

        var filter = Builders<User>.Filter.Regex(
            u => u.Username,
            new BsonRegularExpression(escapedTerm, "i")
        );

        return _users.Find(filter).Limit(100).ToList();
    }
}

Why this works: Regex.Escape() turns metacharacters into literals: .* becomes \.\*, ^admin becomes \^admin, (a+)+$ becomes \(a\+\)\+\$. The caller is left supplying a search term rather than a search pattern, which is the distinction the vulnerable version lost.

The pattern is evaluated by MongoDB under PCRE, not by .NET, so it is worth confirming the escaping survives the trip rather than assuming it. It does: Regex.Escape leaves ], } and - unescaped, and PCRE treats all three as literals outside a character class, so the output matches only the intended text with no parse error. Checked by feeding the escaped strings to PCRE2 10.44.

The corollary is that .NET's own regex safety features do not apply here. RegexOptions.NonBacktracking, a Regex timeout, and AppContext regex switches govern the .NET engine; nothing in this code path runs in it. Length validation bounds the term and .Limit(100) bounds the result set - neither is the fix, but both keep a legitimate search from becoming expensive.

Testing

To verify NoSQL injection protection:

  • Operator injection through the body: POST {"category": {"$ne": "books"}} and assert a 400. With a typed request model the binder produces it; with Dictionary<string, object> the query-building code has to, and that is the case worth testing explicitly.
  • The allowlisted filter still filters: POST {"priceMin": 20} against a fixture holding items at 10 and 30, and assert exactly one result. A filter whose conditions are silently dropped returns 200 with products and passes every injection test - it is only visible in an assertion on the narrowed result set.
  • A rejected value fails loudly: POST an unknown category and assert a 400, not an unfiltered list.
  • Both price bounds at once: POST {"priceMin": 10, "priceMax": 50} and assert the range is applied. Single-bound tests pass in implementations where combining the two throws or overwrites.
  • 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; see CWE-287, which measures a 22,000x gap in the version that returns early.
  • $where, BsonDocument.Parse and BsonDocumentFilterDefinition have no untrusted input: grep for all three. Each accepts a query document as-is, so no runtime check downstream can help.

Common Pitfalls

  • Declaring a strongly-typed C# class for MongoDB documents (with BsonClassMap or POCO attributes) protects document serialization, but building a FilterDefinition<T> from a raw BsonDocument.Parse(requestBody) or Dictionary<string, object> taken straight from the request bypasses that typing entirely - the POCO's field types are never consulted because the filter was constructed from untyped JSON, not from the class.
  • Azure Cosmos DB's QueryDefinition.WithParameter() binds values safely, but the SQL API has no parameter mechanism for identifiers - a user-selectable sort column or container name that's string-formatted into the query text is still injectable even after the WHERE-clause values are parameterized.
  • Dropping from RavenDB's LINQ provider (session.Query<T>(), safe by default) to session.Advanced.RawQuery<T>(rql) with a hand-built RQL string re-introduces the same risk as raw SQL, even though the surrounding code still looks idiomatic - RawQuery supports .AddParameter() for values, and using string interpolation instead of it is the actual mistake, not the use of RawQuery itself.
  • StackExchange.Redis's ScriptEvaluateAsync accepts KEYS/ARGV as separate parameterized arguments, but concatenating user input into the Lua script text passed to EVAL reopens injection inside the script itself, since parameterization only covers what's passed through those arrays, not text embedded directly in the script source.

Additional Resources