BugForge - Daily - Copy Pasta
Daily - Copy Pasta
Vulnerabilities Covered
Summary
The CopyPasta application exposes a public snippet listing at /api/snippets/public that is only meant to return snippets marked is_public = 1. A seeded help snippet documents an undocumented-in-the-UI filter syntax, filter[field]=value. The backend parses these bracketed parameters into a nested object and merges it straight into the ORM query without whitelisting keys or re-applying the visibility constraint. By injecting an operator onto the guard field itself, filter[is_public][$ne]=1, an attacker overrides the access-control scope and retrieves every private snippet belonging to other users, including admin records that carry the flag.
Reference
Application Analysis
We start by registering an account and mapping the application. CopyPasta is a code-snippet sharing app: you create snippets, mark them public or private, and browse a Public page listing everyone else’s public snippets.

Watching the traffic, the Public page is driven by a single call with no parameters:
GET /api/snippets/public
On its own this looks like a dead end. It returns a fixed list and the UI never sends anything else to it, so there is no obvious input to attack. The interesting lead does not come from the request, it comes from the data the endpoint returns.
The Lead: A Seeded Help Snippet
Scrolling the public list, one of the snippets is a set of notes titled “CopyPasta search API - notes”. It documents how to query the snippet search, and this is the whole reason we start hammering the public endpoint the way we do:

Quick notes for anyone scripting against our snippet search.
Basic:
GET /api/snippets/public?search=<text>&language=<lang>
Advanced field filters (narrow the public list by a stored field):
GET /api/snippets/public?filter[language]=python
GET /api/snippets/public?filter[title]=Fetch API Helper
This endpoint only ever lists snippets that are marked public.
Two things jump out. First, the endpoint accepts a filter[field]=value syntax that the UI never uses. Second, the notes make a promise: “This endpoint only ever lists snippets that are marked public.” Any time an application hands us a filter feature and a security guarantee in the same breath, the guarantee is the thing worth testing.
We confirm the documented behaviour works. Filtering by language narrows the list to python snippets, so the filter really is applied server-side against a stored column:
GET /api/snippets/public?filter[language]=python

The filter[field] bracket notation is itself a fingerprint. Node/Express parses query strings with the qs library, which turns bracketed keys into a nested object, so filter[language]=python arrives at the handler as { filter: { language: "python" } }. The handler then merges that object into the ORM query builder. That is the exact pattern that leads to operator injection, because the same bracket parsing lets us push extra nested keys into the object the ORM trusts.
Reading the Response Properties
Before injecting anything, we look closely at what each object in the baseline response actually contains:

Every returned snippet carries an is_public property, and every one of them is set to 1. That single field is clearly the access-control switch the endpoint filters on. We also line up the id values that come back:
ids returned: 8, 7, 6, 5, 3, 2, 1
Ids 4 and 9 are missing from an otherwise continuous sequence. That gap tells us private snippets exist (they simply were not returned), and it tells us the field standing between us and them is is_public. So our target field is decided by the data itself: whatever we inject needs to defeat the is_public = 1 condition.
Confirming Operators Reach the Query
The notes only showed equality filters (filter[field]=value). The question is whether the backend will also accept an operator nested a level deeper. ORMs in the Node ecosystem (Sequelize, and the same idea in Mongoose) express conditions with operator keys such as $ne, $in, $gt and $lt. Because qs will happily parse filter[id][$in][]=4 into { filter: { id: { $in: ["4"] } } }, we can test whether those operator keys survive all the way into the query.
We aim the first probe at the two ids we know are hidden:
GET /api/snippets/public?filter[id][$in][]=4&filter[id][$in][]=9

The response is an empty array [], not a 400 or a 500. That empty-but-valid answer is exactly what we want to see, and it tells us two separate things:
- The
$inoperator was accepted and executed by the ORM. A backend that did not understand the key would either error or ignore it and hand back the full list. Instead it ran a realid IN (4, 9)condition. - The
is_public = 1condition is still being applied on top, joined with a logical AND. Snippets 4 and 9 are private, sois_public = 1 AND id IN (4, 9)matches nothing.
In other words, adding a filter on id cannot reach private rows, because the visibility guard is anded in alongside our filter. The guard is not something we can dodge by filtering a different column. We have to attack the guard column directly.
The Bypass
The endpoint lets us filter arbitrary stored fields, and is_public is a stored field. So instead of filtering on id, we inject an operator onto is_public itself and ask for everything that is not public:
GET /api/snippets/public?filter[is_public][$ne]=1
Authorization: Bearer <token>
Because our filter[is_public] object is merged into the query on top of the built-in is_public = 1, and the code never re-pins the constraint, our value wins. The visibility scope is overridden and the private snippets fall out:

The two previously hidden records (id: 4 and id: 9, both is_public: 0) are now returned, including an admin “infra runbook (private)” snippet. Each private object carries a flag field:
{
"id": 9,
"user_id": 1,
"title": "infra runbook (private)",
"is_public": 0,
"username": "admin",
"flag": "bug{DGI4z3T6UUxt4kjtBI2Co8DGkWlAENYl}"
}
Flag: bug{DGI4z3T6UUxt4kjtBI2Co8DGkWlAENYl}
Impact
- Full read access to every private snippet belonging to any user, regardless of the owner’s visibility setting
- Disclosure of sensitive internal content, including an admin infrastructure runbook describing credential rotation and bastion access
- Exposure of fields never intended for the public projection, such as the
flagvalue and other users’user_idassociations - A reliable, low-effort access-control bypass that needs only a self-registered account and a single crafted query parameter
Vulnerability Classification
- OWASP Top 10: A01:2021 - Broken Access Control
- Vulnerability Type: ORM / query operator injection leading to access-control bypass
- Attack Surface: Public snippet listing (
/api/snippets/public) filter parameters - CWE: CWE-639 - Authorization Bypass Through User-Controlled Key
- CWE: CWE-915 - Improperly Controlled Modification of Dynamically-Determined Object Attributes (mass assignment of query conditions)
Root Cause
The handler for /api/snippets/public builds its ORM where clause by spreading the user-controlled filter object straight into the query. Express parses bracketed query parameters with the qs library into a nested object, so a client can supply not just field values but ORM operator keys ($ne, $in, $gt, and similar). The endpoint neither whitelists which fields and operators are allowed, nor re-applies the is_public = 1 constraint after merging the user filter. As a result, a request for filter[is_public][$ne]=1 overrides the visibility scope and the query returns records the caller should never see.
Remediation
- Never merge a client-supplied filter object directly into an ORM query. Map incoming filters through an explicit allowlist of permitted fields and operators.
- Re-apply security constraints last and unconditionally. The
is_public = 1condition should be added to the final query after any user filters, so it can never be overridden. - Disable legacy string operator aliases in the ORM (for Sequelize, avoid
operatorsAliasesstring keys) so that$-prefixed keys in user input are not interpreted as operators. - Validate and coerce filter values to expected types, rejecting nested objects on fields that should only accept scalars.
- Enforce authorization at the data layer with scoped queries or row-level checks, rather than relying on a single flag added to an otherwise user-shaped query.
- Return a strict field projection for public endpoints so that sensitive columns such as
flagare never selectable, even if a row leaks.