Never pass req.body to Object.assign, a spread or an ORM create or update on a stored record. Parse it with a schema that declares only the fields users may edit (here zod: displayName and bio; z.object() drops undeclared keys) and copy only the parsed result. Set role and tenantId in server-side code, never from the request.
- Find every handler that writes request data onto a stored object: Object.assign(record, req.body), { ...record, ...req.body }, new Model(req.body), Model.create(req.body), findByIdAndUpdate(id, req.body), Sequelize's update(req.body) and the like.
- For each action, write a schema with only the fields that caller may change, here z.object({ displayName: z.string(), bio: z.string() }).partial() for PATCH /me. Give the admin screen its own schema and endpoint rather than adding role to this one.
- Validate with schema.safeParse(req.body) and answer 400 when it fails. In Express 5, req.body is undefined when no JSON body was parsed, and safeParse rejects that too.
- Copy only parsed.data onto the record. z.object() strips keys it does not declare; use z.strictObject() instead if you would rather refuse such requests with 400.
- Set role, tenantId, ownerId and similar fields only in server-side code: an admin endpoint behind its own authorization check, sign-up, an invitation.
- Add regression tests that send an ordinary update plus role, plus tenantId and plus case variants of both, then read the stored record rather than only the response.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export function updateProfile(req, res) {
const user = req.app.locals.users.get(req.user.id);
Object.assign(user, req.body);
res.json(user);
}With IMPL=vulnerable, 3 of 6 tests fail (3 exploit tests).
The fix
// The fields a user may change on their own profile. role and tenantId are not here:
// only server-side code sets them. z.object() drops keys it does not list, and
// .partial() makes each field optional, as a PATCH needs.
const ProfileUpdate = z.object({
displayName: z.string(),
bio: z.string(),
}).partial();
export function updateProfile(req, res) {
const parsed = ProfileUpdate.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'displayName and bio must be strings' });
}
const user = req.app.locals.users.get(req.user.id);
Object.assign(user, parsed.data); // parsed.data holds displayName and bio, nothing else
res.json(user);
}With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
export function updateProfile(req, res) {
const { role, ...changes } = req.body; // role is set by admins only
const user = req.app.locals.users.get(req.user.id);
Object.assign(user, changes);
res.json(user);
}With IMPL=wrong, 2 of 6 tests fail (2 exploit tests).
Route: PATCH /me edits the signed-in user's own record
// authenticate is your session or token middleware. It sets req.user = { id } from
// server-side data or answers 401, so PATCH /me always edits the signed-in user.
app.use(authenticate);
app.use(express.json());
app.patch('/me', impl.updateProfile);The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
// The server may ignore the extra fields (200) or refuse the request (400). Either
// way the stored record keeps the role and tenant the server gave it, and gains no
// fields the client named.
function assertServerFieldsUnchanged(res) {
assert.ok(res.status === 200 || res.status === 400, `expected 200 or 400, got ${res.status} ${res.body}`);
const stored = users.get('alice');
assert.equal(stored.role, 'member', `role changed: ${JSON.stringify(stored)}`);
assert.equal(stored.tenantId, 'red', `tenantId changed: ${JSON.stringify(stored)}`);
assert.deepEqual(Object.keys(stored).sort(), Object.keys(seededAlice).sort(), 'the stored record gained fields');
}
test('role in the body does not make a member an admin', async () => {
assertServerFieldsUnchanged(await patchMe('alice', '{"displayName": "Alice Martin", "role": "admin"}'));
});
test('tenantId in the body does not move the user to another tenant', async () => {
assertServerFieldsUnchanged(await patchMe('alice', '{"displayName": "Alice Martin", "tenantId": "blue"}'));
});
test('case variants Role and TenantId do not change the record either', async () => {
assertServerFieldsUnchanged(await patchMe('alice', '{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}'));
});
});- A blocklist such as const { role, ...changes } = req.body, delete body.role or lodash's omit only removes the names you thought of. This card's wrong implementation strips role and lets tenantId through, and every field added to the record later is writable too.
- Object.assign and the spread operator copy every key of the parsed body, so the record also gains keys you never defined: in this card's tests the vulnerable and wrong handlers store Role and TenantId next to role and tenantId. A document database keeps them.
- Mongoose drops keys that are not in the schema (strict mode, on by default), but every path that is in the schema stays writable: new User(req.body) still sets role. Mongoose strictness is not an allowlist for one action.
- z.looseObject() (and .catchall()) keep undeclared keys, which defeats the schema. Check the object mode wherever a schema feeds a database write.
- Express's documentation warns that req.body is user-controlled and must be validated before use; express.json() only parses it. Validate in the handler or in one middleware per route, not in the client.
- A merge that walks nested objects, such as lodash's merge or a hand-written deep merge, can also let __proto__ reach Object.prototype. That is prototype pollution (CWE-1321, a child of CWE-915) and needs its own fix.
- Hidden form fields and read-only inputs are not protection: the client can send any JSON it likes.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs mass-assignment/node-express does this for you).
- Against your own API, sign in as an ordinary member, replay a normal profile update with role, tenantId and their case variants added (for example curl -X PATCH -H 'Content-Type: application/json' -d '{"displayName": "Alice Martin", "role": "admin"}' https://your.app/me with the member's session), then read the record from the database: role and tenantId must be unchanged and no new fields stored.
- Do not rely on a scanner: Semgrep's one Express rule is not in the common packs, and on this card it flagged the fixed code, flagged the vulnerable code and missed the blocklist; CodeQL has no mass assignment query for JavaScript. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.express.security.express-data-exfiltration.express-data-exfiltration | Flagged Object.assign in impl/vulnerable.js and in impl/fixed.js, and nothing in impl/wrong.js. It is a taint rule from req.body, req.query and similar to any Object.assign call: taint survives the zod parse but not the object rest pattern const { role, ...changes } = req.body. On a probe file it flagged the delete and spread versions of the same blocklist and an explicit copy of displayName and bio, so it marks places to review and cannot tell the fix from the bug.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | p/owasp-top-ten | No findings on app.js or impl/*.js.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | js/prototype-pollution | Not expected to report this card. It carries the CWE-915 tag, but it looks for prototype pollution: user input reaching a deep merge or extend call from a library version known to be vulnerable (PrototypePollutionCustomizations.qll). Object.assign is a shallow copy and is not one of its sinks.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | js/prototype-polluting-assignment | Not expected to report this card: it looks for writes such as obj[a][b] = value where the property names come from the user, and the card has none.Checked: github/codeql main at b5b8165; not executed |
In semgrep-rules develop at 9bbf021, express-data-exfiltration is the only JavaScript rule tagged CWE-915 that is about mass assignment; the others are prototype pollution and node-postgres SQL injection rules. In github/codeql main at b5b8165 every JavaScript query tagged CWE-915 is about prototype pollution, and the only mass assignment query is Ruby's rb/insecure-mass-assignment. The regression tests are the control.
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| zod | 4.6.5 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the mass-assignment/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs mass-assignment/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- express 5.3.0
- zod 4.6.5
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 3 of 6 tests fail (3 exploit tests); wrong: 2 of 6 tests fail (2 exploit tests)
- Test results
- fixed 6/6 pass; vulnerable 3/6 fail (all 3 exploit tests); wrong 2/6 fail (the tenantId and case-variant exploit tests: the blocklist lets tenantId through, and Object.assign stores Role and TenantId as new fields); offline in a no-network sandbox, after installs with warm and with empty package caches
- Dependency freshness
- zod 4.6.5 was published on 2026-09-13 (27 days old) and is the latest 4.x. express 5.3.0 was published on 2026-10-09; it is taken at one day old because it is a security release, as in the other Express cards. npm audit found 0 vulnerabilities, npm audit signatures verified all 66 packages, and OSV listed no advisories for any package in package-lock.json on 2026-10-10. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Mass Assignment Cheat Sheet
- OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- Express 5.x API: req.body (undefined until parsed; untrusted input)
- Express 5.x API: express.json()
- Zod: objects (z.object strips unknown keys; z.strictObject, z.looseObject)
- Mongoose: the strict schema option
- CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')