VYPR
High severity8.2NVD Advisory· Published May 18, 2026· Updated May 18, 2026

parse-nested-form-data has Prototype Pollution via `__proto__` in FormData field names

CVE-2026-45302

Description

Summary

parseFormData() walks bracket and dot-notation FormData field names into nested objects without filtering reserved property keys. A single FormData field whose name begins with __proto__, or contains .__proto__. mid-path, causes the parser to traverse onto Object.prototype and assign properties there, polluting the prototype chain of every plain object in the running process.

Details

The vulnerability is in handlePathPart in src/index.ts, which performs currentObject[pathPart.path] and currentObject[pathPart.path] = val for object-type path segments without rejecting reserved keys. When the segment is __proto__, the read returns Object.prototype, which then becomes the next traversal target, and the next assignment lands on the prototype.

Reproduction on a fresh install of parse-nested-form-data@1.0.0:

import { parseFormData } from 'parse-nested-form-data';
const fd = new FormData();
fd.append('__proto__.polluted', 'yes');
parseFormData(fd);
console.log(({}).polluted); // -> 'yes'
console.log(([]).polluted); // -> 'yes'

Equivalent vectors:

  • __proto__[polluted]=yes
  • a.__proto__.polluted=yes (mid-path traversal)
  • a[0].__proto__.polluted=yes (mid-path through an array element)

constructor.prototype.x was incidentally blocked by an existing duplicate-key guard (because Object is a function and failed the JSON-object check), but relying on that was fragile, so the fix denylists constructor and prototype as well as __proto__. The array branch (a[0], a[]) was not exploitable in practice - the regex restricts array-index segments to digit characters - but the forbidden-key check is applied before the object/array type branching as defense in depth, so any future change to the regex cannot reintroduce the issue.

Impact

Any application that passes attacker-controlled FormData (or any Iterable<[string, string | File]>) to parseFormData() - typically an HTTP server processing form submissions - allows an unauthenticated remote client to mutate Object.prototype of the running process via a single field name. Concrete consequences depend on the host application and may include corrupted application state, altered control flow in code that reads ambient properties off objects, and denial of service.

Patches

Fixed in 1.0.1. handlePathPart now throws a new ForbiddenKeyError (also exported) when any path segment is __proto__, constructor, or prototype, regardless of whether the segment would be used as an object key or an array index. The check runs before object/array type branching for defense in depth.

Upgrade:

npm install parse-nested-form-data@^1.0.1

Workarounds

If upgrading is not possible, validate field names before calling parseFormData():

const FORBIDDEN = /(^|\.)(__proto__|constructor|prototype)($|[.[])/;
for (const [name] of formData.entries()) {
  if (FORBIDDEN.test(name)) throw new Error('Unsafe field name');
}

Resources

  • CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')
  • Fix commit: 527ad58eb486e32438f7198fb88315c20449d792

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

parse-nested-form-data@1.0.0 allows prototype pollution via __proto__ in FormData field names, enabling remote attackers to pollute Object.prototype.

Vulnerability

The vulnerability resides in the handlePathPart function in src/index.ts of parse-nested-form-data version 1.0.0 [1]. The parser walks bracket and dot-notation FormData field names into nested objects without filtering reserved property keys such as __proto__, constructor, or prototype. When a field name contains __proto__ as a segment, the read operation currentObject[pathPart.path] returns Object.prototype, and the subsequent assignment currentObject[pathPart.path] = val writes onto the prototype chain [2][4].

Exploitation

An attacker can exploit this by sending a single FormData field whose name begins with __proto__ or contains .__proto__. mid-path (e.g., __proto__.polluted=yes, a.__proto__.polluted=yes, or a[0].__proto__.polluted=yes). No authentication or special privileges are required; the attacker only needs the ability to supply a FormData (or any Iterable<[string, string | File]>) to an application that calls parseFormData() on it—typically an HTTP server processing form submissions [2][4]. The parser then traverses the path and assigns the value to Object.prototype.

Impact

Successful prototype pollution allows an unauthenticated remote attacker to mutate Object.prototype of the running Node.js process. This can lead to property injection, denial of service, or potentially remote code execution depending on how the application uses object properties [2][4]. Every plain object in the process inherits the polluted property, which can alter application behavior globally.

Mitigation

The fix was committed in commit 527ad58 [3] and is expected in a future release. The patch denylists __proto__, constructor, and prototype keys, throwing a ForbiddenKeyError when encountered. As a workaround, applications should avoid passing untrusted FormData to parseFormData() until the patched version is available [2][4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Patches

1
527ad58eb486

fix: prevent prototype pollution via __proto__ in FormData field names (#4)

https://github.com/milamer/parse-nested-form-dataChristian SchurrMay 8, 2026via ghsa
6 files changed · +155 2
  • .github/workflows/validate.yml+1 1 modified
    @@ -16,7 +16,7 @@ jobs:
         if: ${{ !contains(github.head_ref, 'all-contributors') }}
         strategy:
           matrix:
    -        node: [12, 14, 16]
    +        node: [14, 16]
         runs-on: ubuntu-latest
         steps:
           - name: 🛑 Cancel Previous Runs
    
  • package.json+1 0 modified
    @@ -44,6 +44,7 @@
       "devDependencies": {
         "@remix-run/web-file": "^3.0.2",
         "@remix-run/web-form-data": "^3.0.3",
    +    "@types/minimatch": "^3.0.5",
         "kcd-scripts": "^12.3.0",
         "rimraf": "^3.0.2",
         "typescript": "^4.8.4"
    
  • SECURITY.md+41 0 added
    @@ -0,0 +1,41 @@
    +# Security Policy
    +
    +## Reporting a vulnerability
    +
    +If you believe you've found a security vulnerability in
    +`parse-nested-form-data`, please report it privately. **Do not open a public
    +issue.**
    +
    +Preferred channel:
    +[open a private vulnerability report](https://github.com/milamer/parse-nested-form-data/security/advisories/new)
    +on this repository. GitHub's private reporting flow keeps the discussion private
    +until a fix is ready and supports CVE assignment.
    +
    +If you cannot use GitHub's private reporting, email **chris@schurr.dev** with
    +`[security] parse-nested-form-data` in the subject line.
    +
    +When reporting, please include:
    +
    +- A description of the issue and its impact
    +- Affected version(s)
    +- A minimal reproduction (PoC code, input that triggers the bug, expected vs.
    +  actual behavior)
    +- Any suggested mitigation, if you have one
    +
    +You will receive an acknowledgement within a few business days. I'll keep you
    +updated as the fix progresses and credit you in the published advisory unless
    +you'd prefer to remain anonymous.
    +
    +## Disclosure
    +
    +Coordinated disclosure is preferred. The default window is 90 days from initial
    +report to public disclosure, which can be shortened if a fix ships sooner or
    +extended by mutual agreement.
    +
    +Once a patched release is available on npm, the corresponding GitHub Security
    +Advisory is published so that downstream users are notified through Dependabot
    +and `npm audit`.
    +
    +## Supported versions
    +
    +Only the latest published version on npm receives security fixes.
    
  • src/index.ts+26 0 modified
    @@ -73,6 +73,29 @@ export class MixedArrayError extends Error {
         this.key = key
       }
     }
    +/**
    + * Thrown when a path part would access or assign a property on
    + * `Object.prototype` (`__proto__`, `constructor`, `prototype`). Rejected
    + * regardless of whether pollution would actually occur, to keep input
    + * unambiguous.
    + *
    + * @example
    + * ```ts
    + * const formData = new FormData()
    + * formData.append('__proto__.polluted', 'yes')
    + * parseFormData(formData)
    + * // throws ForbiddenKeyError('__proto__')
    + * ```
    + */
    +export class ForbiddenKeyError extends Error {
    +  key: string
    +  constructor(key: string) {
    +    super(`Forbidden key at path part ${key}`)
    +    this.key = key
    +  }
    +}
    +
    +const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
     
     type JsonObject = {[Key in string]?: JsonValue}
     type JsonArray = Array<JsonValue>
    @@ -333,6 +356,9 @@ function handlePathPart(
       nextPathValue: JsonValue | undefined,
       setNextPathValue: (value: JsonValue) => void,
     ] {
    +  if (FORBIDDEN_OBJECT_KEYS.has(pathPart.path)) {
    +    throw new ForbiddenKeyError(pathPart.pathToPart)
    +  }
       if (pathPart.type === 'object') {
         if (Array.isArray(currentPathObject)) {
           throw new DuplicateKeyError(pathPart.pathToPart)
    
  • src/__tests__/index.ts+83 1 modified
    @@ -1,6 +1,11 @@
     import {FormData} from '@remix-run/web-form-data'
     import {File} from '@remix-run/web-file'
    -import {DuplicateKeyError, MixedArrayError, parseFormData} from '../'
    +import {
    +  DuplicateKeyError,
    +  ForbiddenKeyError,
    +  MixedArrayError,
    +  parseFormData,
    +} from '../'
     
     describe('basic functionality', () => {
       describe('transform value', () => {
    @@ -409,3 +414,80 @@ describe('complex examples', () => {
         })
       })
     })
    +
    +describe('prototype pollution protection', () => {
    +  const proto = Object.prototype as unknown as {[key: string]: unknown}
    +  afterEach(() => {
    +    // Defensive: clean any test-leaked properties off Object.prototype so a
    +    // failure can't silently affect later tests.
    +    delete proto.polluted
    +  })
    +
    +  it('rejects `__proto__` as a top-level key', () => {
    +    const formData = new FormData()
    +    formData.append('__proto__.polluted', 'yes')
    +    expect(() => parseFormData(formData)).toThrowError(
    +      new ForbiddenKeyError('__proto__'),
    +    )
    +    expect(proto.polluted).toBeUndefined()
    +  })
    +
    +  it('rejects `__proto__` as a nested key', () => {
    +    const formData = new FormData()
    +    formData.append('a.__proto__.polluted', 'yes')
    +    expect(() => parseFormData(formData)).toThrowError(
    +      new ForbiddenKeyError('a.__proto__'),
    +    )
    +    expect(proto.polluted).toBeUndefined()
    +  })
    +
    +  it('rejects `__proto__` reached through an array element', () => {
    +    const formData = new FormData()
    +    formData.append('a[0].__proto__.polluted', 'yes')
    +    expect(() => parseFormData(formData)).toThrowError(
    +      new ForbiddenKeyError('a[0].__proto__'),
    +    )
    +    expect(proto.polluted).toBeUndefined()
    +  })
    +
    +  it('rejects `constructor` as a key', () => {
    +    const formData = new FormData()
    +    formData.append('constructor.prototype.polluted', 'yes')
    +    expect(() => parseFormData(formData)).toThrowError(
    +      new ForbiddenKeyError('constructor'),
    +    )
    +  })
    +
    +  it('rejects `prototype` as a key', () => {
    +    const formData = new FormData()
    +    formData.append('prototype.polluted', 'yes')
    +    expect(() => parseFormData(formData)).toThrowError(
    +      new ForbiddenKeyError('prototype'),
    +    )
    +  })
    +
    +  it('rejects `__proto__` as a leaf assignment', () => {
    +    const formData = new FormData()
    +    formData.append('a.__proto__', 'yes')
    +    expect(() => parseFormData(formData)).toThrowError(
    +      new ForbiddenKeyError('a.__proto__'),
    +    )
    +  })
    +
    +  // The cases below pin the invariant for the array branch. Today the regex
    +  // restricts array-index segments to digit characters, so a forbidden key
    +  // can't reach the array branch (the cases parse as an object pathPart with
    +  // a trailing `]` and trip the array/object duplicate-key guard). If the
    +  // regex is ever loosened to accept arbitrary content inside `[...]`, these
    +  // tests will start failing and the array branch will need its own
    +  // forbidden-key check.
    +  it.each(['__proto__', 'constructor', 'prototype'])(
    +    'does not pollute via `a[%s]`',
    +    key => {
    +      const formData = new FormData()
    +      formData.append(`a[${key}]`, 'yes')
    +      expect(() => parseFormData(formData)).toThrow()
    +      expect(proto.polluted).toBeUndefined()
    +    },
    +  )
    +})
    
  • .vscode/settings.json+3 0 added
    @@ -0,0 +1,3 @@
    +{
    +  "js/ts.tsdk.path": "node_modules/typescript/lib"
    +}
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

4

News mentions

0

No linked articles in our index yet.