BugForge - Daily - Copy Pasta

6 min read

Daily - Copy Pasta

Vulnerabilities Covered

Broken Access Control ORM Operator Injection

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

1

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.

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.

2

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:

Filter notes snippet

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

Filter by language

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.

3

Reading the Response Properties

Before injecting anything, we look closely at what each object in the baseline response actually contains:

Baseline public response

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.

4

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

Operator probe returns empty

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:

  1. The $in operator 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 real id IN (4, 9) condition.
  2. The is_public = 1 condition is still being applied on top, joined with a logical AND. Snippets 4 and 9 are private, so is_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.

5

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:

Flag

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 flag value and other users’ user_id associations
  • 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 = 1 condition 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 operatorsAliases string 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 flag are never selectable, even if a row leaks.
Zw4rts

© 2026 Zw4rts. All rights reserved.