Skip to content

Broken property-level authorization (mass assignment and overexposure)

When is it relevant

A new or changed entry point that creates or updates a record from request body fields, or returns records, where some fields must not be set or seen by that caller. Examples: role, owner, tenant, price, status, verification flags, secrets.

Attack

The attacker adds fields the UI never sends, such as "role": "admin", "companyId": "…" or "isVerified": true. The server spreads the body into the database. Or the response serializes the whole record, exposing password hashes, tokens or internal flags.

Mitigation

  • Validate the body against a schema that allowlists writable fields for this caller and rejects unknown keys.
  • Set ownership, tenant and privileged fields on the server, never from the body.
  • Build responses from an explicit list of fields.

Examples

Mitigation options

Option When it fits Docs
Allowlist the fields you accept Default choice; the shape is explicit in the handler Express routing
Schema validation with unknown keys rejected Bigger payloads, shared between routes Zod
Mongoose strict + explicit select Mongoose already models the document Mongoose schemas

Vulnerable — write side (mass assignment)

router.patch('/me', requireAuth, async (req, res) => {
  const user = await User.findByIdAndUpdate(req.user.id, req.body, { new: true })
  res.json(user)                    // {"role":"admin"} or {"credits":9999} lands here
})

Vulnerable — read side (overexposure)

router.get('/users/:id', requireAuth, async (req, res) => {
  res.json(await User.findById(req.params.id))   // passwordHash, resetToken, email
})

Mitigated — allowlist

const EDITABLE = ['displayName', 'bio', 'avatarUrl']

router.patch('/me', requireAuth, async (req, res) => {
  const update = {}
  for (const key of EDITABLE) {
    if (key in req.body) update[key] = req.body[key]
  }
  const user = await User.findByIdAndUpdate(req.user.id, update, { new: true })
  res.json({ id: user.id, displayName: user.displayName, bio: user.bio })
})

The response is built field by field too, so a column added to the model later is not published by accident.

Docs: Mongoose queries

Mitigated — schema validation

const ProfileUpdate = z.object({
  displayName: z.string().min(1).max(80),
  bio: z.string().max(500).optional(),
}).strict()                          // unknown keys are an error, not ignored

const parsed = ProfileUpdate.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'Invalid body' })
await User.findByIdAndUpdate(req.user.id, parsed.data)

Docs: Zod objects and .strict()

Mitigated — Mongoose schema

const userSchema = new mongoose.Schema({
  displayName: String,
  role: { type: String, default: 'user', immutable: true },
  passwordHash: { type: String, select: false },   // never loaded unless asked
}, { strict: 'throw' })                            // unknown paths throw

Docs: Mongoose schemas

Bypasses to check

  • Privileged field allowed through a second path. The profile route allowlists fields, but registration (new User(req.body)), an import job, or an admin route reuses the raw body.
  • { strict: true } silently drops unknown keys rather than rejecting them, so a review can't tell from the response whether a field was honoured; dotted keys ("profile.role") and $set payloads can still reach nested paths.
  • Zod without .strict() (or .passthrough()) keeps unknown keys.
  • Prototype pollution through __proto__ or constructor.prototype when the body is merged with Object.assign, spread, or a deep-merge helper.
  • Check then change: the body is validated, then re-read from the original req.body instead of from the validated result.
  • Read side: select('-passwordHash') on one query but not on populate(), aggregation, or the list endpoint; a toJSON transform that a .lean() query bypasses.
  • Errors that echo the whole document back in a message or log.

What to grep for

  • req.body passed whole: findByIdAndUpdate(, updateOne(, new User(, Object.assign(, { ...req.body }
  • strict, .strict(), passthrough(, select(, lean(, toJSON

Resources