guest@websec: ~/websec/03-unsafe-query-generation — zsh — 100×40 by SaKKo ENTH code:

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:

requestparsed aswhere
?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)nullevery JSON API
{"token": {"$gt": ""}}an objectevery 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.

sim: password reset lookupedit the request, watch the generated query
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
    …