guest@websec ~ % man 03-unsafe-query-generation
03 · Unsafe Query Generation
Your query is parameterized and still returns the wrong row. Why? The request parser turned "a string" into null, an array or an object.
The problem: request parsers create types
HTTP sends text, but frameworks helpfully turn it into other types:
| request | parsed as | where |
|---|---|---|
?token=abc | "abc" | everywhere |
?token[]= | [""] or [nil] | Rails, PHP, Express (qs) |
?token[]=a&token[]=b | ["a","b"] | Rails, PHP, Express |
?token[$ne]=x | { "$ne": "x" } | Express (qs), PHP |
{"token": null} (JSON body) | null | every JSON API |
{"token": {"$gt": ""}} | an object | every JSON API |
Then the query builder does its job: null becomes IS NULL, an array becomes IN (…), and in MongoDB {"$ne": null} matches everyone.
Why it matters
Password reset looks up the user by token. Most users have no token (NULL). Send null and you match a random user, then reset their password. This exact bug hit Rails more than once.
try:
database:
users table
Rails' answer: deep_munge
Rails'
deep_munge cleans up shapes like [nil], and params.permit(:token) drops arrays and hashes. But params[:token] used directly can still be anything, and find_by(token: nil) still means IS NULL.The general fix: validate types at the boundary
Decide the shape of each param and check it first. A token is a non-empty string. Anything else: 400.
- Use a schema validator (zod, pydantic, or strong params plus type checks in Rails).
- MongoDB: strip keys starting with
$. - Never look up by a value that might be empty.
class PasswordsController < ApplicationController
def update
token = params[:token]
return head :bad_request unless token.is_a?(String) && token.present?
user = User.find_by_password_reset_token!(token) # Rails 8: signed + expiring
user.update!(params.permit(:password, :password_confirmation))
redirect_to new_session_path, notice: "Password updated."
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Link invalid or expired."
end
end
# params.permit(:token) keeps only scalar values; arrays/hashes are dropped
import { z } from "zod";
const ResetBody = z.object({
token: z.string().regex(/^[a-f0-9]{32}$/), // exact shape, no null/array/object
password: z.string().min(12).max(200),
});
app.post("/reset", async (req, res) => {
const parsed = ResetBody.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "Invalid request" });
const { token, password } = parsed.data;
const user = await User.findOne({ where: { resetToken: hash(token) } });
if (!user) return res.status(400).json({ error: "Link invalid or expired" });
…
});
from pydantic import BaseModel, Field, ValidationError
class ResetBody(BaseModel):
token: str = Field(pattern=r"^[a-f0-9]{32}$") # rejects None, lists, dicts
password: str = Field(min_length=12, max_length=200)
@app.post("/reset")
def reset():
try:
body = ResetBody.model_validate(request.get_json())
except ValidationError:
return {"error": "Invalid request"}, 400
user = User.query.filter_by(reset_token=hash_token(body.token)).first()
if user is None:
return {"error": "Link invalid or expired"}, 400
…