back to research
LARP-2026-001web4 min read

Forging an audit trail through mass assignment

Create handlers that bound every key in the request body. One extra field made a project with no visible creator and no activity entry, and two others let comments and wiki pages carry any date.

The platform has an activity feed that admins use to see who created, changed or deleted what. For a workspace it is the audit trail. I have a rule for features like that: if it exists to hold people accountable, I try to lie to it. That is how I started hunting mass assignment on purpose.

Mass assignment is a create or update handler copying every key from the request body onto the model, instead of the few fields the form is supposed to change. The form sends a name and an icon. The model also carries fields only the server should set: the owner, timestamps, roles, plan flags, system markers. If the handler binds the whole body, all of those are writable from a proxy.

Most frameworks make binding the whole body a one-liner, which is a big part of why this keeps happening:

jsillustration, not their code
// binds every key the client sent, including ones the form never shows
const project = new Project(req.body);
await project.save();

How I look for it:

  1. Capture a create or update request and note which fields the UI sends.
  2. Read the response, or a GET of the same object. Every field in there that I didn’t send is a candidate.
  3. Add one candidate at a time, with a value I’ll recognize later: an odd date, true, my own user ID.
  4. Check whether the stored object changed, then work out what that field controls elsewhere in the product.

Adding one field at a time matters. If the server rejects unknown keys, a single addition tells me exactly which one it didn’t like.

A project with no creator

Members with the right permission can create projects. Normally a new project shows your name as its creator and the activity feed records it. The request is small:

jsoncreate project
{"name": "q3-review", "icon": "preset"}

The platform also creates a default project for every workspace on its own. I wanted to know whether the server would let me declare one of mine a default project, so I added that one field:

jsoncreate project, one field added
{"name": "q3-review", "isDefault": true, "icon": "preset"}

The project was created normally. Its owner showed up as the system instead of me, and nothing was written to the activity feed. After renaming it, it looked like any other project in the workspace.

I can’t see their code. My guess is that projects the system makes for itself are created without an owner and without logging, and the flag sent my project down that same path. What matters for the report is the result: a member can create projects that admins can’t trace back to anyone.

Backdated comments

Comments on files are where reviews happen, approvals and requested changes included, so when a comment was written matters.

The create request sends an author, a parent and the text. A saved comment comes back with a creation timestamp I never sent, so I sent it:

jsoncreate comment
{"body": "hello", "createdAt": "2001-12-10T19:19:19.410Z"}

The comment showed up dated December 2001. Any date was accepted, past or future.

Backdated wiki pages

Wiki pages had the same problem. Adding the creation and modification timestamps to the create request let me backdate or forward-date a page. With the comments, that means nobody can trust the answer to who approved what, and when.

Setting the record ID

On a different app in the same program, the create request had no id field because the server picks the next number. I added my own id with a very large value and it was accepted. Afterwards some older items in that app couldn’t be deleted, because the counter had jumped.

So client-chosen IDs are mass assignment too, and this one could damage real data. I didn’t run it again. I wrote up what had happened and asked the team not to re-run my PoC on the shared system.

Fix

Bind an allow-list, and set everything else on the server:

jsallow-list
const project = new Project({
  name: req.body.name,
  icon: req.body.icon,
  owner: req.user.id, // decided by the server, never read from the body
});

Owners, timestamps, roles, plan flags and system markers should never be taken from a request body. When a framework binds models automatically, use its allow-list mode, and prefer rejecting unknown keys over silently dropping them so that probing like mine shows up in the logs.

Disclosure

All of this was reported privately to the vendor. The vendor isn’t named here, and paths, field names and IDs are changed. Each request has the same structure as the original.

0xsila
back to research