CopyPasta: Authorization Bypass on DELETE Snippet
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:
- 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. PUT /api/snippets/:idandDELETE /api/snippets/:idare emitted from the same React component and share the same ID source. Both surfaced as candidate targets for an authorization bypass.PUT /api/profileandPUT /api/profile/passwordtake noidorusernamein 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.GET /api/profile/:usernamereturns the user record plus asnippetsarray, a candidate path for leaking other users’ private snippets or share codes.GET /api/snippets/share/:uuidaccepts 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:
PUT /api/snippets/:idrejects requests where the caller does not own the target snippet with403 {"error":"Not authorized to edit this snippet"}.DELETE /api/snippets/:idaccepts the same caller against the same target and returns200with 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 asserts403. 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/:idandDELETE /api/snippets/:idas the same client component’s edit and delete actions;PUTreturned403,DELETEreturned200plus 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