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$setpayloads can still reach nested paths.- Zod without
.strict()(or.passthrough()) keeps unknown keys. - Prototype pollution through
__proto__orconstructor.prototypewhen the body is merged withObject.assign, spread, or a deep-merge helper. - Check then change: the body is validated, then re-read from the original
req.bodyinstead of from the validated result. - Read side:
select('-passwordHash')on one query but not onpopulate(), aggregation, or the list endpoint; atoJSONtransform that a.lean()query bypasses. - Errors that echo the whole document back in a message or log.
What to grep for
req.bodypassed whole:findByIdAndUpdate(,updateOne(,new User(,Object.assign(,{ ...req.body }strict,.strict(),passthrough(,select(,lean(,toJSON