EXERCISE 2 — MongoDB operator injection (auth bypass) and the fix ================================================================= THE VULNERABLE LOGIN: // builds the query object straight from the request body const user = await db.users.findOne({ username: req.body.username, password: req.body.password }); // if a user is found, log them in NORMAL REQUEST (JSON body): { "username": "philip", "password": "secret" } -> query: { username: "philip", password: "secret" } -> matches only if both equal the stored values. Fine. THE ATTACK — supply an OPERATOR OBJECT instead of a string: { "username": "admin", "password": { "$ne": null } } -> query becomes: { username: "admin", password: { $ne: null } } HOW IT BYPASSES AUTHENTICATION: - In MongoDB, { $ne: null } is the operator "NOT EQUAL TO null." So the password condition is "password is not null" — which is TRUE for essentially every real account (passwords aren't null). - The query therefore reduces to "find a user named admin whose password is anything" -> it matches the admin document WITHOUT the attacker knowing the password. findOne returns the admin user, and the app logs the attacker in as admin. (Variants: {"$ne":""}, {"$gt":""}, or username also an operator to match the first user.) - The attacker never supplied a password VALUE; they supplied query LOGIC. WHY IT'S THE SAME ROOT CAUSE AS SQLi: - Identical to SQL injection in structure: UNTRUSTED INPUT CROSSED THE BOUNDARY FROM DATA INTO QUERY SEMANTICS. In SQLi, a ' turns a value into SQL syntax (' OR '1'='1). Here, a JSON object turns a value into a Mongo OPERATOR ({$ne: null}) that changes the query's logic. - The enabling mistake is the same: the code SPREAD untrusted input directly into the query structure (req.body.password placed where a value was assumed), so the input could become part of the query's meaning. Different query language, same data-vs-code confusion (Chapter 1). The always-true-condition auth bypass even mirrors ' OR '1'='1 exactly. THE TYPE-VALIDATION FIX: - Ensure the value is the TYPE you expect (a string), so an object can't be injected as an operator: // (1) Reject non-string credentials outright: if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string') { return res.status(400).send('Invalid input'); } // (2) Or coerce explicitly to a string before querying: const username = String(req.body.username); const password = String(req.body.password); const user = await db.users.findOne({ username, password }); - Now { "$ne": null } sent as the password becomes the STRING "[object Object]" (or is rejected), which equals no stored password -> no match -> no bypass. The operator can't take effect because the field is forced to be a scalar string, not a query object. ADDITIONAL HARDENING: - Use a schema/ODM (e.g. Mongoose with a String-typed password field), which rejects an object where a string is required. - Forbid dangerous operators: never enable/accept $where (runs JavaScript); don't feed user input into $regex unescaped (enables inference / ReDoS). - Don't authenticate by matching a plaintext password field anyway — store hashed passwords (Auth course) and compare in code; that also sidesteps this exact pattern. ONE-LINE TAKEAWAY: Mongo operator injection feeds a query operator ({$ne:null}) where a value was expected, an always-true auth bypass identical in spirit to ' OR '1'='1; fix it by validating/coercing inputs to the expected scalar type so an object can't become query logic.