CWE-943: Improper Neutralization of Special Elements in Data Query Logic - Go
Overview
NoSQL injection in Go applications occurs when user-controlled data reaches a database query without validation or a type that constrains it, so the caller changes what the query matches: reading records they are not entitled to, or bypassing authentication. NoSQL databases (MongoDB, Redis, CouchDB, Cassandra) use different query languages than SQL - often JSON-like structures, key-value operations, or custom DSLs - but are injectable the same way whenever user input decides part of the query rather than only a value in it.
MongoDB is particularly exposed because its query format is a document: an attacker who can change a value's type changes the query's structure without changing a single character of syntax. Authentication bypass follows from turning {"username": "admin", "password": "user_input"} into {"username": "admin", "password": {"$ne": null}}, which matches whatever the stored password is. Server-side JavaScript ($where, $function, $accumulator) is a second route: it was deprecated in MongoDB 8.0, which logs a warning when you use it, but server-side scripting is enabled by default and the operators still run. Redis is injectable through Lua script source built by concatenation, and CouchDB's map-reduce functions accept JavaScript.
Where Go differs from the dynamic languages this weakness is usually written up in is the source. r.URL.Query() returns url.Values, a map[string][]string, and it does not parse bracket syntax: a request for ?password[$ne]=null produces the single literal key password[$ne], so Query().Get("password") returns the empty string and the classic query-string payload does nothing. What reaches a filter as a nested document in Go is JSON - anything decoded into bson.M, map[string]interface{}, or a struct field typed interface{}/any. Look there, and at CQL and Lua strings built with fmt.Sprintf, rather than at query parameters.
Primary Defence: Decode request bodies into concrete types - a struct of string, int and bool fields - rather than into bson.M, map[string]interface{} or any, so a value that should be a scalar cannot arrive as a document. Build filters with bson.D entries the application writes, keeping the choice of field and operator in code and letting the request supply only values. Bind CQL values with ? placeholders and Lua values through KEYS/ARGV, and allowlist anything that cannot be bound, such as sort fields and column names. Disable server-side scripting (--noscripting) if nothing needs it, and run the database account with least privilege - a query the attacker reshapes can only reach what the credential permits.
Common Vulnerable Patterns
MongoDB Operator Injection via a Decoded JSON Body
// VULNERABLE - MongoDB filter built from a decoded JSON body
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var collection *mongo.Collection
func loginHandler(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// DANGEROUS: the decoded values are `any`, so "password" can arrive as an object
filter := bson.M{
"username": body["username"],
"password": body["password"],
}
var user bson.M
if err := collection.FindOne(context.Background(), filter).Decode(&user); err != nil {
http.Error(w, "Authentication failed", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "Authentication successful: %v", user)
}
// ATTACK:
// POST /login {"username": "admin", "password": {"$ne": null}}
// json.Decode leaves map[string]any{"$ne": nil} in body["password"], and the
// filter marshals to {"username": "admin", "password": {"$ne": null}}.
// MongoDB reads $ne as an operator: admin matches whatever the password is.
Why this is vulnerable: encoding/json decodes into any by shape, not by expectation. A JSON string becomes a string, a JSON object becomes a map[string]interface{}, and the handler cannot tell which arrived because it never asked. The BSON marshaller then serialises that map faithfully, so a value the code treats as a password becomes a query operator. Nothing is concatenated and no character needs escaping - the injection is a change of type, and the same body reaches $gt, $regex, $where or $expr just as easily.
The Go-specific half is where this can and cannot come from. r.URL.Query() returns url.Values, which is map[string][]string and has no nested form: ?password[$ne]=null parses to the single key password[$ne], and Query().Get("password") returns "". So the query-string payload that works against Express 4 or PHP does nothing here, and a Go handler is exposed through decoded JSON, not through parameters. Declaring the body as a struct of string fields closes it, because json.Decode rejects an object where a string is expected.
JSON Deserialization to Query Filter
// VULNERABLE - Accepting raw JSON query from client
import (
"context"
"encoding/json"
"net/http"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var collection *mongo.Collection
func queryDataHandler(w http.ResponseWriter, r *http.Request) {
// DANGEROUS: User provides entire query structure
var filter bson.M
if err := json.NewDecoder(r.Body).Decode(&filter); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Execute user-controlled query
cursor, err := collection.Find(context.Background(), filter)
if err != nil {
http.Error(w, "Query failed", http.StatusInternalServerError)
return
}
defer cursor.Close(context.Background())
var results []bson.M
cursor.All(context.Background(), &results)
json.NewEncoder(w).Encode(results)
}
// ATTACK:
// POST /query
// {"role": {"$ne": "admin"}} -> Returns all non-admin users
// {"role": "admin"} -> Returns all admin users
// {"$where": "this.balance > 1000000"} -> Server-side JavaScript execution
// {"password": {"$regex": "^a"}} -> Password enumeration via regex
Why this is vulnerable: The decoded body is the filter, so the caller writes the query's structure and not only its values. Any MongoDB operator reaches the server: $ne to match everything except a value, $gt/$lt for range queries, $regex for pattern matching (password enumeration), $where for JavaScript execution, or $elemMatch to query array fields. Whatever this endpoint was meant to restrict the result set to, the caller decides instead.
Unvalidated Key Path in Redis
// VULNERABLE - Redis key built from unvalidated user input
import (
"context"
"fmt"
"net/http"
"github.com/redis/go-redis/v9"
)
var redisClient *redis.Client
func getUserData(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
// DANGEROUS: the caller decides which key is read
key := "user:" + userID
val, err := redisClient.Get(context.Background(), key).Result()
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
fmt.Fprintf(w, "User data: %s", val)
}
// ATTACK:
// /getUserData?user_id=1:sessions -> reads "user:1:sessions", another user's tokens
// /getUserData?user_id= -> reads "user:", whatever the app keeps there
// What the attacker controls is the key namespace, not the Redis protocol.
Why this is vulnerable: The handler promises to read one user's cache entry and reads whichever key the caller names instead, because the only thing separating one namespace from another is a : the attacker can also type. Anything the application stores under a user:-prefixed key - session tokens, password-reset codes, cached PII - is reachable by shaping user_id.
What is not happening here is command injection, and the distinction is worth stating because the payload user_id=123%0ADEL%20important_key is widely repeated as though it were. RESP, the Redis wire protocol, sends every argument as a length-prefixed bulk string. Captured from go-redis v9.22.0, a Set with a newline in the key puts this on the socket:
$26 tells the server to read exactly 26 bytes and treat all of them as one key. The newline survives intact and is never read as a separator, so DEL is stored, not executed. The client does not strip it either - so removing newlines from keys or values defends nothing and silently corrupts data that legitimately contains them. Redis command injection in Go 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.
Cassandra CQL Injection
// VULNERABLE - CQL query with string concatenation
import (
"fmt"
"net/http"
"github.com/gocql/gocql"
)
var session *gocql.Session
func getUserByEmail(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
// DANGEROUS: String interpolation in CQL
query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
iter := session.Query(query).Iter()
defer iter.Close()
user := map[string]interface{}{}
if iter.MapScan(user) {
fmt.Fprintf(w, "User: %v", user)
}
}
// ATTACK:
// /getUserByEmail?email=x' AND role = 'admin
// Query becomes: SELECT * FROM users WHERE email = 'x' AND role = 'admin'
// Answers "is x an admin?" - a row comes back only if both hold
//
// /getUserByEmail?email=x' AND role = 'admin' ALLOW FILTERING --
// The -- comments out the trailing quote, so the injected text can append
// clauses of its own rather than only relations
Why this is vulnerable: fmt.Sprintf puts the caller's text inside a quoted CQL literal, so one ' ends the literal and everything after it is parsed as CQL. The attacker is writing part of the statement, and gocql never sees a value it could have bound.
What that buys is narrower than SQL injection, and being specific about it matters because the SQL payloads do not transfer. Apache's CQL grammar defines the WHERE clause as relation ( AND relation )* - AND only, with no OR - and a SELECT "only apply to a single table", so there is no UNION either. The familiar ' OR 1=1 and UNION SELECT payloads are parse errors here, and a page that prints one as a demonstration is describing an attack that never runs. (!= is a supported operator - the grammar lists = < > <= >= != IN CONTAINS CONTAINS KEY - so it is the OR that fails, not the comparison.)
The attack that does run is additive. Extra AND relations turn the endpoint into an oracle: ask for email = 'x' AND role = 'admin' and the presence or absence of a row answers a question about a column the response never shows, one request at a time. CQL comments (--, //, /* */) let the injected text discard the rest of the statement and append its own clauses, such as ALLOW FILTERING to make a non-key relation executable. None of this needs OR to be useful.
JavaScript Injection via $where
// VULNERABLE - MongoDB $where operator (JavaScript injection)
import (
"context"
"fmt"
"net/http"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var collection *mongo.Collection
func searchProducts(w http.ResponseWriter, r *http.Request) {
searchTerm := r.URL.Query().Get("search")
// DANGEROUS: Using $where with user input
filter := bson.M{
"$where": fmt.Sprintf("this.name.includes('%s')", searchTerm),
}
cursor, _ := collection.Find(context.Background(), filter)
defer cursor.Close(context.Background())
// ... return results
}
// ATTACK:
// /searchProducts?search=') || (this.role == 'admin
// Expression becomes: this.name.includes('') || (this.role == 'admin')
// Returns every admin record
//
// Worse:
// /searchProducts?search=') || (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. The user's text lands inside a quoted string, so a single ' closes it and everything after is code: an || that makes the predicate unconditionally true, a function expression that never returns, or a reference to any field on this, whether or not the caller was ever allowed to see it.
Two things about $where are commonly misstated. It is deprecated as of 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. And the payloads written as statement lists (0; return true; //) depend on how the server wraps a $where string; a payload written as an expression (0 || true) is true under any wrapping, which is why the ones above take that form. Never use $where with user input - prefer $expr with standard aggregation operators, which compare without executing anything.
Secure Patterns
Explicit Filter Values and an Application-Side Password Check
// SECURE - filter built only from validated, explicitly typed values
package main
import (
"errors"
"net/http"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"golang.org/x/crypto/bcrypt"
)
var collection *mongo.Collection
// A real bcrypt hash (cost 12) of a passphrase no account uses. Comparing
// against it costs the same as comparing against a stored hash.
var dummyHash = []byte("$2a$12$pkFOuUYd2PytnRr8su73j.BOFGQUk7tY/L3nX2p6e1FcHPgowH2v2")
type User struct {
Username string `bson:"username"`
PasswordHash string `bson:"password_hash"`
Role string `bson:"role"`
}
func secureLogin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
// SECURE - r.FormValue returns a string, so nothing the caller sends can
// arrive as a BSON document. Validate the shape as well.
if !isValidUsername(username) {
http.Error(w, "Invalid username format", http.StatusBadRequest)
return
}
// SECURE - bson.D names exactly one condition. A struct literal would send
// every field it declares, including the zero-valued ones.
filter := bson.D{{Key: "username", Value: username}}
var user User
err := collection.FindOne(r.Context(), filter).Decode(&user)
if err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// SECURE - hash on both paths. Returning early when the user does not exist
// would make an unknown username measurably faster than a wrong password.
stored := dummyHash
if err == nil {
stored = []byte(user.PasswordHash)
}
bcryptErr := bcrypt.CompareHashAndPassword(stored, []byte(password))
if err != nil || bcryptErr != nil {
http.Error(w, "Authentication failed", http.StatusUnauthorized)
return
}
createSession(w, user)
}
func isValidUsername(username string) bool {
if len(username) < 3 || len(username) > 32 {
return false
}
// Only alphanumeric and underscore
for _, r := range username {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_') {
return false
}
}
return true
}
func createSession(w http.ResponseWriter, user User) {}
Why this works: The only user-controlled value in the filter is a string, and it is placed in a bson.D entry the application wrote. A string marshals to a BSON string whatever it contains, so there is no path by which the caller supplies a document and no operator to inject. isValidUsername narrows it further - $ is not in the permitted alphabet - but note the ordering of those two facts: the type is what makes the query safe, and the alphabet is ordinary input validation on top.
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 stolen dump still yields hashes.
Two details in here are the fix rather than decoration, and both are easy to lose:
bson.D, not a struct literal.filter := User{Username: username}looks like the type-safe option and is a trap: the driver marshals every field the struct declares, so the filter becomes{"username":"alice","password_hash":"","role":""}and matches only an account whose hash and role are the empty string. Measured against mongo-driver v2 - the login never succeeds. A struct filter needsomitemptyon every field to behave the way it reads.- Hashing on the unknown-user path. Returning 401 as soon as
FindOnereports no document skips bcrypt entirely, and the difference is not subtle: measured with cost 12, comparing againstdummyHashtakes 192.1 ms against 192.0 ms for a real hash, where an early return answers in microseconds. That gap is a username enumeration oracle - see CWE-208 and CWE-287.
Explicit BSON Building with Validation
// SECURE - Explicit BSON construction with allowlisting
import (
"context"
"net/http"
"strconv"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var collection *mongo.Collection
func secureSearch(w http.ResponseWriter, r *http.Request) {
category := r.URL.Query().Get("category")
minPrice := r.URL.Query().Get("min_price")
// SECURE - Validate and allowlist category
validCategories := map[string]bool{
"electronics": true,
"clothing": true,
"books": true,
}
if !validCategories[category] {
http.Error(w, "Invalid category", http.StatusBadRequest)
return
}
// SECURE - strconv.Atoi rejects the whole string if any part of it is not a
// number. fmt.Sscanf with %d would accept "12; DROP" and hand back 12.
minPriceInt, err := strconv.Atoi(minPrice)
if err != nil || minPriceInt < 0 {
http.Error(w, "Invalid price", http.StatusBadRequest)
return
}
// SECURE - Explicitly build BSON with validated values
filter := bson.D{
{Key: "category", Value: category},
{Key: "price", Value: bson.D{{Key: "$gte", Value: minPriceInt}}},
}
cursor, err := collection.Find(context.Background(), filter)
if err != nil {
http.Error(w, "Search failed", http.StatusInternalServerError)
return
}
defer cursor.Close(context.Background())
// Process results...
}
Why this works: The category is compared against a fixed map, so the only values that reach the filter are three the application chose. min_price goes through strconv.Atoi, which returns an error unless the entire string is a number, so what lands in the filter is an int and cannot carry a document. bson.D puts the $gte operator there in application code; nothing in the request decides which operator applies or which field it applies to. That is the split worth keeping - the caller supplies values, the code supplies structure.
Parameterized Queries for CQL (Cassandra)
// SECURE - CQL with parameterized queries
import (
"fmt"
"net/http"
"net/mail"
"github.com/gocql/gocql"
)
var session *gocql.Session
func secureGetUserByEmail(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
// SECURE - net/mail parses the address rather than looking for characters in it
if _, err := mail.ParseAddress(email); err != nil {
http.Error(w, "Invalid email format", http.StatusBadRequest)
return
}
// SECURE - Parameterized query (? placeholder)
query := "SELECT user_id, username, role FROM users WHERE email = ?"
var userID gocql.UUID
var username, role string
err := session.Query(query, email).Scan(&userID, &username, &role)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
fmt.Fprintf(w, "User: %s, Role: %s", username, role)
}
Why this works: The ? placeholder makes email a bound parameter. gocql sends the query text and the value separately, so the server never re-parses the value as CQL - a single quote in it is a character in a string, not the end of one. mail.ParseAddress is defence in depth rather than the fix, and it is the right shape for it: it parses the address against RFC 5322 instead of looking for an @ and a ., so it rejects malformed input without the caller having to guess which characters matter.
Note the boundary. Placeholders bind values. Table names, column lists and ORDER BY targets cannot be parameterized in CQL any more than they can in SQL, so those parts of a statement must come from an allowlist, never from fmt.Sprintf.
Redis with Structured Commands
// SECURE - Redis with a key the application composes
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"github.com/redis/go-redis/v9"
)
var redisClient *redis.Client
var userIDPattern = regexp.MustCompile(`^[0-9]+$`)
func secureGetUserData(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
// SECURE - Validate user ID is numeric only
if !userIDPattern.MatchString(userID) {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
// SECURE - Use client's Get method (structured API)
key := "user:" + userID
val, err := redisClient.Get(context.Background(), key).Result()
if errors.Is(err, redis.Nil) {
http.Error(w, "User not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(val))
}
func secureIncrementCounter(userID string) error {
// SECURE - Validated input + structured command
if !userIDPattern.MatchString(userID) {
return fmt.Errorf("invalid user ID")
}
key := "counter:" + userID
return redisClient.Incr(context.Background(), key).Err()
}
Why this works: ^[0-9]+$ leaves the caller no way to add a :, so the composed key is always exactly one entry in the user: namespace and cannot be steered into another. That is the control that matters here: the weakness in the vulnerable version was which key got read, and this decides it in application code.
The Get/Incr methods are not what makes this safe, and it is worth being clear about why, because "use the client API" is often given as the whole answer. RESP length-prefixes every argument, so a raw command string is not something the caller could have injected in the first place. What the typed methods do buy is that the command name is chosen by the code. For Lua, that distinction is real: pass values through KEYS/ARGV on Eval, because the script source is text and concatenating into it is injectable.
Defense in Depth with Access Controls
// SECURE - MongoDB with an ownership condition in the filter
import (
"fmt"
"net/http"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var collection *mongo.Collection
type Document struct {
ID string `bson:"_id"`
OwnerID string `bson:"owner_id"`
Data string `bson:"data"`
}
func secureGetDocument(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("doc_id")
// Get authenticated user from session
userID := getUserIDFromSession(r)
if userID == "" {
http.Error(w, "Not authenticated", http.StatusUnauthorized)
return
}
// SECURE - Filter by both document ID AND owner
filter := bson.D{
{Key: "_id", Value: docID},
{Key: "owner_id", Value: userID},
}
// SECURE - Project only necessary fields
projection := bson.D{
{Key: "data", Value: 1},
{Key: "_id", Value: 1},
}
opts := options.FindOne().SetProjection(projection)
var doc Document
err := collection.FindOne(r.Context(), filter, opts).Decode(&doc)
// SECURE - 404 for both "no such document" and "not yours". A 403 for the
// second confirms the document exists to someone who cannot read it.
if err != nil {
http.Error(w, "Document not found", http.StatusNotFound)
return
}
fmt.Fprintf(w, "Document: %s", doc.Data)
}
func getUserIDFromSession(r *http.Request) string {
// Implementation: extract from session cookie
return "user123"
}
Why this works: The ownership condition is part of the filter rather than a check performed on the result, so there is no window in which the document is fetched and then judged - a document belonging to someone else simply does not match. owner_id comes from the session, not from the request, which is what makes it something the caller cannot restate.
The response is the other half. Returning the same 404 whether the document is absent or merely someone else's keeps the endpoint from answering "does this ID exist?" for anyone who can guess IDs. Projection then limits what a matching document gives up, so a field added to the collection later is not exposed by default.
Framework-Specific Guidance
MongoDB Official Driver Best Practices
// SECURE - MongoDB best practices with the official driver
import (
"context"
"fmt"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var collection *mongo.Collection
func secureMongoDBQuery(userInput string, userRole string) ([]bson.M, error) {
// SECURE - Validate input
if !isValidInput(userInput) {
return nil, fmt.Errorf("invalid input")
}
// SECURE - Use bson.D for deterministic field order
filter := bson.D{
{Key: "category", Value: userInput},
{Key: "published", Value: true},
}
// Role-based field projection
projection := bson.D{
{Key: "title", Value: 1},
{Key: "description", Value: 1},
}
if userRole == "admin" {
projection = append(projection, bson.E{Key: "internal_notes", Value: 1})
}
opts := options.Find().
SetProjection(projection).
SetLimit(100).
SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := collection.Find(context.Background(), filter, opts)
if err != nil {
return nil, err
}
defer cursor.Close(context.Background())
var results []bson.M
if err := cursor.All(context.Background(), &results); err != nil {
return nil, err
}
return results, nil
}
func isValidInput(input string) bool {
// Validation logic
return len(input) > 0 && len(input) < 100
}
Why this works: Every key in the filter - category, published - and both entries in the projection are literals written here. userInput appears once, as a value, after isValidInput has run. SetLimit(100) bounds the result set and the projection decides which fields leave the database, with the extra field added on a role the application read rather than one the caller claimed.
bson.D is not what makes this safe, and it is worth saying so plainly because "use bson.D" is a common one-line answer to this CWE. bson.D is an ordered document; it will carry an attacker-chosen Key or a nested operator document just as faithfully as bson.M will, and the page's second vulnerable pattern is exactly that. What bson.D gives you here is that the structure is written out in code rather than decoded from a request - the ordering guarantee is about index selection and shard keys, not security.
Nor does the driver escape anything: BSON is a binary format with typed fields, so there is no quoting layer to escape into. That is precisely why type confusion works against it. The control is that the caller supplies one string and the application supplies every key and operator.
Testing
To verify NoSQL injection protection:
- Operator injection through the body, not the query string: POST
{"username": "admin", "password": {"$ne": null}}and assert the handler returns 400 fromjson.Decode, not 200. Sending?password[$ne]=nullinstead proves nothing -url.Valueshas no nested form, so that request is a pass whether or not the bug is fixed. - A struct filter finds a real document: insert a user with a non-empty
password_hashandrole, then assert the login succeeds. A filter built from a struct literal withoutomitemptymatches nothing, and every negative test still passes. - 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 two medians should be within noise of each other; a difference of more than a few milliseconds means a branch is returning before bcrypt.
- Field allowlist: send an unlisted sort target or filter field and assert a 400, not a silently unfiltered result set.
- Identifiers are not parameterized: confirm table names, column lists,
ORDER BYtargets and Lua script source are never built withfmt.Sprintffrom user input -?placeholders do not cover them.
Common Pitfalls
- Declaring a typed Go struct for the expected filter shape doesn't help if the handler decodes the incoming JSON directly into a
bson.M/map[string]interface{}and passes that straight tocollection.FindOne()- the typed struct exists in the codebase, but the code path that actually builds the query never goes through it, so a body like{"password": {"$ne": null}}decodes into the map unchanged and reaches MongoDB as-is. - Passing a struct value as the filter is the opposite mistake and fails closed rather than open, which is why it survives review: the driver marshals every field the struct declares, so
User{Username: name}becomes{"username": name, "password_hash": "", "role": ""}and matches nothing. Tag each fieldomitempty, or build the filter as abson.D, which says what it sends. go.mongodb.org/mongo-driver(v1) is deprecated in favour ofgo.mongodb.org/mongo-driver/v2, andgithub.com/go-redis/redis/v8was superseded bygithub.com/redis/go-redis/v9. Both still compile, so a page or a codebase can sit on them indefinitely;go getreports the driver deprecation,go builddoes not.- gocql's
?placeholders parameterize CQL values, but table names, column lists, andORDER BYtargets can't be parameterized in CQL - the same limitation prepared statements have in SQL - so building those parts of the query withfmt.Sprintffrom user input stays injectable even after switching value binding to placeholders. - Using
EVAL/EvalShawith go-redis keepsKEYS/ARGVvalues parameterized, but string-concatenating user input into the Lua script source itself, rather than passing it throughARGV, reopens injection inside the script the same way raw command concatenation would.