Back to the crumb
The lesson
nodeexpressChewy~25 min

Why the club card upgraded itself

What you saw

A member who'd spent £15.70 was sitting in the top loyalty tier — full discount, free pastry, £50 of store credit, staff perks — and the roster itself flagged the contradiction: earned tier Crumb, held tier Whole Loaf. The back office had granted none of it. And editing a profile through the account page behaved perfectly every time: your four fields changed, nothing else moved.

That last fact is the whole misdirection. The bug is invisible from the UI, because the page is the one client that plays fair — it only ever sends the four fields a member is allowed to edit. The fault only shows itself when a request arrives that didn't come from the page, carrying keys the form would never include.

What was actually happening

Here's the update handler:

app.patch('/api/account/:id', (req, res) => {
  const i = accounts.findIndex((a) => a.id === req.params.id)
  if (i === -1) return res.status(404).json({ error: 'account not found' })

  // ...email format check...

  const updated = { ...accounts[i], ...req.body, id: accounts[i].id }
  accounts[i] = updated

  res.json(/* ... */)
})

Look at { ...accounts[i], ...req.body }. The spread copies every key in the request body over the stored account. The author was thinking about the four fields the form sends — and they even remembered one thing the client shouldn't control, pinning id back on the end so a request couldn't reassign it. But id was never the dangerous field. tier, discountRate, storeCredit, staff, totalSpent — every one of those is a key on the account, and every one of them is fair game for the spread. The handler doesn't decide which fields a member may change. It lets the request decide, and then dutifully writes down whatever it's told.

So Theo's account wasn't touched by any loyalty job or back-office grant. Someone sent this:

curl -X PATCH localhost:3000/api/account/acct_0007 \
  -H 'Content-Type: application/json' \
  -d '{"tier":"whole-loaf","discountRate":0.1,"storeCredit":5000,"staff":true}'

Four keys in, four keys spread over the account, 200 OK. The card upgraded itself because the endpoint let the cardholder write the fields that decide the perks.

This is the bug class: mass assignment (also called over-posting, or an object-injection / auto-binding vulnerability). It shows up any time you bind a request body straight onto a domain object — { ...record, ...req.body }, Object.assign(record, req.body), Model.update(req.body) — without deciding, field by field, what the caller is actually allowed to set. The client sends a superset of the editable fields, and the extra ones ride straight through.

Why the tempting fixes don't fix it

"Validate the input." The endpoint already checks the email format, and it makes no difference. Validation confirms the shape of the fields you're expecting — is the email an email, is the name a non-empty string. It says nothing about the fields you weren't expecting. A request with a perfectly valid email and a bonus "tier":"whole-loaf" passes every format check and still escalates. The attacker isn't sending malformed data; they're sending well-formed extra data.

"Strip the dangerous field." The instinct is to blocklist: delete req.body.tier before the spread. But there isn't a dangerous field — there's a dangerous set, and it grows every time someone adds a column. Block tier and storeCredit, staff, discountRate, and totalSpent still sail through. Add a credits field next quarter and you've reopened the hole without touching this line. A blocklist is a list of the attacks you already thought of; the check for this crumb sends five privileged fields at once precisely so a one-field patch fails.

"Fix the client." Hiding or disabling fields in the account page does nothing — curl never loads your JavaScript. The browser is just the one place you make requests from, not the only place requests come from.

The real fix inverts the default. Instead of taking everything and trying to subtract the bad, take only the fields you've explicitly decided are the member's to change:

const EDITABLE = ['name', 'email', 'address', 'favourite']

app.patch('/api/account/:id', (req, res) => {
  const i = accounts.findIndex((a) => a.id === req.params.id)
  if (i === -1) return res.status(404).json({ error: 'account not found' })

  const patch = {}
  for (const key of EDITABLE) {
    if (key in req.body) patch[key] = req.body[key]
  }
  if ('email' in patch && !isValidEmail(patch.email)) {
    return res.status(400).json({ error: 'that email does not look right' })
  }

  accounts[i] = { ...accounts[i], ...patch }
  res.json(/* ... */)
})

Now tier, storeCredit, staff and the rest can be in the body all day — they're never read. The member decides name, email, address, favourite. The server owns everything that decides a perk, because those are facts the server is responsible for, derived from spend or granted by staff, never asserted by the account holder. (A destructure — const { name, email, address, favourite } = req.body — is the same idea; so is a schema library's "strip unknown keys" mode, or your ORM's explicit field list. The principle is the allowlist, not the syntax.)

Spotting the whole class

This is the same trust-boundary bug as an endpoint that prices an order from the request body — just wearing a CRUD-update costume. The tell is a value that matters flowing from the request into a record the server is supposed to own, with no gate deciding which fields are allowed. The classic signatures:

And it is rampant in AI-generated CRUD. "Update the account from the request body" has one statistically obvious completion — spread the body onto the record — and it works flawlessly in any demo, because the demo only ever sends the innocent fields. The privileged columns are right there in the same object, one keystroke away, and nothing in the happy path ever reveals that they're reachable.

Two reflexes catch it. First, the design question, asked for every write endpoint: which fields on this record is the caller allowed to set, and which does the server own? Name the second list out loud — tier, storeCredit, staff, role, isPaid, verified, userId, totalSpent — and make the code accept only the first. Second, the grep that makes it searchable:

# every place a whole request body is folded into an object — audit each one
grep -rn "\.\.\.req.body\|Object.assign\|req.body)" src/

Read each hit and ask whether the object on the other side has a single field the client shouldn't own. If it does — and it usually does — replace "take everything, subtract the bad" with "take only the good." Allowlist, never blocklist. The safe default is the short list you wrote down on purpose.