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

Fix mass assignment in TypeScript and Next.js

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

In short

Never pass await request.json() or Object.fromEntries(formData) to Object.assign, a spread or an ORM create or update. Parse the input with a schema that declares only the fields users may edit (here zod: displayName and bio; z.object() drops undeclared keys) and store only the parsed result, in route handlers and server actions alike. Set role and tenantId server-side.

Stack
  • Language TypeScript
  • Runtime Node.js 26.11.1
  • Framework Next.js 16.4.0 (App Router route handler and server action)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 35 KB
On this page

Steps

  1. Find every route handler and server action that writes request data onto a stored object: Object.assign(record, body), { ...record, ...Object.fromEntries(formData) }, prisma.user.update({ data: body }), db.update(users).set(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 the profile. Give the admin screen its own schema and server action rather than adding role to this one.
  3. Validate with schema.safeParse(input) and return a 400 (route handler) or an error result (server action) when it fails. Check that the JSON body is an object before you use it.
  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.
  5. Set role, tenantId, ownerId and similar fields only in server-side code: an admin action behind its own authorization check, sign-up, an invitation.
  6. Add regression tests that call the route handler with an ordinary update plus role, plus tenantId and plus case variants of both, and call the server action with extra form fields, 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

Vulnerablelib/impl/vulnerable.tsTypeScript
export function updateProfile(user: StoredUser, input: unknown): UpdateResult {
  Object.assign(user, input);
  return { ok: true, user };
}

With IMPL=vulnerable, 4 of 8 tests fail (4 exploit tests).

The fix

Fixedlib/impl/fixed.tsTypeScript
// 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 (including the $ACTION_
// fields a form post adds), and .partial() makes each field optional, as a PATCH needs.
const ProfileUpdate = z.object({
  displayName: z.string().max(80),
  bio: z.string().max(500),
}).partial();

export function updateProfile(user: StoredUser, input: unknown): UpdateResult {
  const parsed = ProfileUpdate.safeParse(input);
  if (!parsed.success) return { ok: false, error: 'displayName and bio must be short strings' };
  Object.assign(user, parsed.data); // parsed.data holds displayName and bio, nothing else
  return { ok: true, user };
}

With IMPL=fixed, 8 of 8 tests pass.

A fix that looks safe but is not

Wrong fixlib/impl/wrong.tsTypeScript
export function updateProfile(user: StoredUser, input: unknown): UpdateResult {
  const { role, ...changes } = input as Record<string, unknown>; // role is set by admins only
  void role;
  Object.assign(user, changes);
  return { ok: true, user };
}

With IMPL=wrong, 3 of 8 tests fail (3 exploit tests).

Route handler: PATCH /me edits the signed-in user's own record

app/me/route.tsTypeScript
// PATCH /me: the signed-in user edits their own profile. currentUser() is your session or token
// check, so the record edited is always the caller's own; the body only says what to change.
export async function PATCH(request: Request) {
  const session = await currentUser();
  const user = session && users.get(session.id);
  if (user === undefined) return Response.json({ error: 'sign in first' }, { status: 401 });
  let input: unknown;
  try {
    input = await request.json();
  } catch {
    return Response.json({ error: 'the body must be JSON' }, { status: 400 });
  }
  if (typeof input !== 'object' || input === null || Array.isArray(input)) {
    return Response.json({ error: 'the body must be a JSON object' }, { status: 400 });
  }
  const result = currentImpl().updateProfile(user, input);
  return result.ok ? Response.json(result.user) : Response.json({ error: result.error }, { status: 400 });
}

Server action: the profile form posts to the same update

app/profile/actions.tsTypeScript
// The profile page's <form action={saveProfile}>. A server action is a public POST endpoint: the
// form fields are whatever the client sends, and Object.fromEntries(formData) also carries the
// $ACTION_ fields Next.js adds to a form post. Only the update function decides what is stored.
export async function saveProfile(formData: FormData): Promise<UpdateResult> {
  const session = await currentUser();
  const user = session && users.get(session.id);
  if (user === undefined) return { ok: false, error: 'sign in first' };
  return currentImpl().updateProfile(user, Object.fromEntries(formData));
}

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.

Testtests/profile.test.tsTypeScript
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 expectServerFieldsUnchanged() {
    const stored = users.get('alice')!;
    expect(stored.role, `role changed: ${JSON.stringify(stored)}`).toBe('member');
    expect(stored.tenantId, `tenantId changed: ${JSON.stringify(stored)}`).toBe('red');
    expect(Object.keys(stored).sort(), 'the stored record gained fields').toEqual(Object.keys(seededAlice).sort());
  }
  function expectIgnoredOrRefused(res: { status: number; body: string }) {
    expect([200, 400], `expected 200 or 400, got ${res.status} ${res.body}`).toContain(res.status);
  }

  test('role in the body does not make a member an admin', async () => {
    expectIgnoredOrRefused(await patchMe('alice', '{"displayName": "Alice Martin", "role": "admin"}'));
    expectServerFieldsUnchanged();
  });

  test('tenantId in the body does not move the user to another tenant', async () => {
    expectIgnoredOrRefused(await patchMe('alice', '{"displayName": "Alice Martin", "tenantId": "blue"}'));
    expectServerFieldsUnchanged();
  });

  test('case variants Role and TenantId do not change the record either', async () => {
    expectIgnoredOrRefused(await patchMe('alice', '{"displayName": "Alice Martin", "Role": "admin", "TenantId": "blue"}'));
    expectServerFieldsUnchanged();
  });

  test('fields added to the profile form do not reach the stored record through the server action', async () => {
    await submitForm('alice', { displayName: 'Alice Martin', bio: 'x', role: 'admin', tenantId: 'blue' });
    expectServerFieldsUnchanged();
  });
});

Pitfalls

  • Server actions are public POST endpoints. Removing a field from the form does not stop a client from posting it, and Object.fromEntries(formData), which the Next.js forms guide suggests for many fields, also contains the $ACTION_ fields Next.js adds to a form post.
  • A blocklist such as const { role, ...changes } = 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, so the record also gains keys you never defined: the vulnerable and wrong code store Role and TenantId next to role and tenantId.
  • TypeScript types are not validation. Casting await request.json() as ProfileUpdate checks nothing at run time; only a schema parse does.
  • Prisma and Drizzle write every column you put in data or set(): prisma.user.update({ data: body }) sets role if the body has it. Build the data object from parsed fields.
  • z.looseObject() (and .catchall()) keep undeclared keys, which defeats the schema. Check the object mode wherever a schema feeds a database write.
  • 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.

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/typescript-nextjs does this for you).
  2. Against your own app, send PATCH /me with an ordinary update plus "role": "admin" and "tenantId": "other", and post the profile form with extra role and tenantId fields (any HTTP client can post to a server action), then read the user back: neither field may change.
  3. Do not expect a scanner to find this: Semgrep's mass assignment rule only knows Express's req.body, and CodeQL's CWE-915 queries look for prototype pollution. 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 in TypeScript and Next.js
ToolRuleWhat it found here
Semgrepjavascript.express.security.express-data-exfiltration.express-data-exfiltrationNo findings on any implementation. Its sources are Express's req.body, req.query and similar; a Next.js route handler's await request.json() and a server action's FormData are not among them, so the Object.assign in the vulnerable code goes unnoticed.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app lib
Semgrepp/owasp-top-tenNo findings on the route handler, the server action or lib/impl/*.ts.Checked: Semgrep 1.180.0 (CE); on app and lib
CodeQLjs/prototype-pollutionNot expected to report this card. It carries the CWE-915 tag but looks for prototype pollution through deep merge or extend calls from vulnerable library versions; Object.assign is a shallow copy and is not one of its sinks. Every JavaScript query tagged CWE-915 is about prototype pollution, and CodeQL's only mass assignment query is Ruby's rb/insecure-mass-assignment.Checked: github/codeql main at b5b8165; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageTypeScript
RuntimeNode.js 26.11.1
FrameworkNext.js 16.4.0 (App Router route handler and server action)
next16.4.0
react, react-dom19.3.0
zod4.6.5
vitest (test only)5.0.3
typescript (type check and next build only)6.0.3

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/typescript-nextjsfolder.

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/typescript-nextjs runs all three modes and checks the expected results.

Download the project ZIP, 35 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • next 16.4.0
  • react 19.3.0
  • zod 4.6.5
  • vitest 5.0.3 (Vite 8.3.4)
  • typescript 6.0.3
  • semgrep 1.180.0
Matrix
fixed: 8 of 8 tests pass; vulnerable: 4 of 8 tests fail (4 exploit tests); wrong: 3 of 8 tests fail (3 exploit tests)
Test results
fixed 8/8 pass; vulnerable 4/8 fail (4 exploit tests); wrong 3/8 fail (3 exploit tests), offline after npm ci
Dependency freshness
next 16.4.0 was published on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, zod 4.6.5 on 2026-09-13, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (7.0.2 no longer exports the compiler API next build loads). npm audit found 0 vulnerabilities. tsc --noEmit passes. The six route-handler tests send the same JSON bodies as the other stacks; two more call the server action with a FormData. The tests replace lib/auth.ts with a stand-in (vi.mock). 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. Next.js: Forms guide (server actions, Object.fromEntries and $ACTION_ fields)
  5. Next.js: Data Security guide (validating client input in server actions)
  6. Next.js: route.js file conventions
  7. Zod: objects (z.object strips unknown keys; z.strictObject, z.looseObject)
  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.