Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix mass assignment (over-posting) in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1
  • Framework Express 5.3.0
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Set role, tenantId, ownerId and similar fields only in server-side code: an admin endpoint behind its own authorization check, sign-up, an invitation.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.jsJavaScript
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

Fixedimpl/fixed.jsJavaScript
// 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

Wrong fiximpl/wrong.jsJavaScript
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

app.jsJavaScript
// 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 regression test

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.

Testtest/profile.test.jsJavaScript
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"}'));
  });
});

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix mass assignment (over-posting) in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.express.security.express-data-exfiltration.express-data-exfiltrationFlagged 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)
Semgrepp/owasp-top-tenNo findings on app.js or impl/*.js.Checked: Semgrep 1.180.0 (CE)
CodeQLjs/prototype-pollutionNot 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
CodeQLjs/prototype-polluting-assignmentNot 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
zod4.6.5

Run the tests

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.

Shell
npm ci
Shell
# 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 --silent

Each 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.

Download the project ZIP, 24 KB

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

  1. OWASP Mass Assignment Cheat Sheet
  2. OWASP API Security Top 10 2023: API3 Broken Object Property Level Authorization
  3. CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
  4. Express 5.x API: req.body (undefined until parsed; untrusted input)
  5. Express 5.x API: express.json()
  6. Zod: objects (z.object strips unknown keys; z.strictObject, z.looseObject)
  7. Mongoose: the strict schema option
  8. CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.