Skip to content

fix: strip prototype pollution keys from FormData JSON parsing#1849

Closed
eddieran wants to merge 1 commit intoelysiajs:mainfrom
eddieran:fix/formdata-prototype-pollution
Closed

fix: strip prototype pollution keys from FormData JSON parsing#1849
eddieran wants to merge 1 commit intoelysiajs:mainfrom
eddieran:fix/formdata-prototype-pollution

Conversation

@eddieran
Copy link
Copy Markdown

@eddieran eddieran commented Apr 10, 2026

Summary

Fixes #1848 (Finding 2: FormData auto-parse prototype pollution)

  • normalizeFormValue in src/dynamic-handle.ts and the compiled formData parser in src/adapter/web-standard/index.ts auto-parse form field values starting with { or [ as JSON via JSON.parse. The parsed objects can carry __proto__, constructor, or prototype as enumerable own properties, creating a prototype pollution risk if downstream code merges or spreads these objects.
  • After JSON.parse, recursively strip __proto__, constructor, and prototype keys from the parsed object in both the dynamic and compiled code paths.
  • Added tests verifying dangerous keys are stripped from parsed JSON form values (including nested objects) while valid JSON parsing continues to work correctly.

Test plan

  • New test: test/security/formdata-prototype-pollution.test.ts — 4 tests covering __proto__, constructor/prototype, nested objects, and valid JSON passthrough
  • Existing formdata tests pass: bun test test/type-system/formdata.test.ts (28/28)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Reinforced form data security by implementing sanitization that removes dangerous object properties from all JSON-parsed form values, ensuring protection across multiple parsing flows and file attachment scenarios.
  • Tests

    • Added comprehensive security test suite to validate proper handling of edge cases and malicious inputs in form data processing.

When FormData values starting with `{` or `[` are auto-parsed as JSON,
the resulting objects could carry `__proto__`, `constructor`, or
`prototype` as enumerable own properties. If downstream code merges or
spreads these objects, this creates a prototype pollution vector.

After JSON.parse, recursively delete `__proto__`, `constructor`, and
`prototype` keys from the parsed object in both the dynamic handler
(`normalizeFormValue` in `src/dynamic-handle.ts`) and the compiled
handler (`formData` parser in `src/adapter/web-standard/index.ts`).

Fixes #1848

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 10, 2026

Walkthrough

Oh wow, so you finally decided to fix that embarrassing prototype pollution vulnerability in FormData parsing~? How cute ♡ These changes add stripDangerousKeys sanitization to remove __proto__, constructor, and prototype keys from JSON objects parsed during form field processing, across both the web-standard adapter and dynamic form handling layers.

Changes

Cohort / File(s) Summary
FormData Prototype Pollution Sanitization
src/adapter/web-standard/index.ts, src/dynamic-handle.ts
Adds recursive stripDangerousKeys helper that traverses parsed JSON objects and deletes dangerous prototype-pollution keys. Applied to form field values that are auto-parsed as JSON before attaching file data and assigning final values.
Security Test Suite
test/security/formdata-prototype-pollution.test.ts
Introduces comprehensive Bun test suite validating that FormData endpoints properly sanitize dangerous keys from parsed JSON payloads, including nested cases, while confirming benign values remain intact and Object.prototype stays unpolluted.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

The changes require careful attention to ensure the recursive sanitization logic correctly handles deeply nested objects and arrays without false positives, though the pattern is consistent and testable~

Possibly related PRs

Poem

✧・゚: ✧・゚: Ohohoho, so paranoid~ :・゚✧:・゚✧
Scrubbing those dangerous keys away (´▽`♡)
__proto__ won't pollute today~
Your objects are safuu... for now ♡♡♡
Mesugaki security guard strikes again~! ε=ε=ε=┌(๑•́ﻬ•̀)┘

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary change: stripping prototype pollution keys from FormData JSON parsing, which directly matches the changeset modifications.
Linked Issues check ✅ Passed The pull request fully addresses the FormData auto-parse prototype pollution objective from issue #1848: dangerous keys are stripped from both code paths with comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are scoped to the FormData prototype pollution fix; no unrelated modifications were introduced outside the specified objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/adapter/web-standard/index.ts (1)

109-116: ⚠️ Potential issue | 🟠 Major

Ara ara~ Did you forget to sanitize here too? How careless~ ♡♡♡ (σ≧▽≦)σ

When handling nested key paths, you parse an existing string value as JSON at line 112, but you never call stripDangerousKeys on the result before assigning it at line 116! If an attacker crafts a form with a field like nested[0] containing {"__proto__": {"bad": true}}, followed by nested[0].something, the intermediate parse would retain the dangerous keys~

🐛 Proposed fix
 `if(typeof existing==='string'&&existing.charCodeAt(0)===123){\n` +
 `try{` +
 `parsed=JSON.parse(existing)\n` +
-`if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))parsed=undefined` +
+`if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))parsed=undefined\n` +
+`else stripDangerousKeys(parsed)` +
 `}catch{}\n` +
 `}\n` +

Run this script to check if there's any other path that sanitizes this:

#!/bin/bash
# Search for any other sanitization of parsed objects in the nested key handling
rg -nC5 'parseArrayKey|arrayInfo' src/adapter/web-standard/index.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/adapter/web-standard/index.ts` around lines 109 - 116, The code parses an
existing string into parsed and assigns it directly to
current[arrayInfo.name][arrayInfo.index] without sanitization; call
stripDangerousKeys(parsed) (or equivalent sanitizer) after successful JSON.parse
and before assigning to current[arrayInfo.name][arrayInfo.index] so dangerous
keys like __proto__ are removed; ensure you only call stripDangerousKeys when
parsed is an object (the same condition that sets parsed) and preserve the
existing fallback to {} if parsed becomes undefined after sanitization.
🧹 Nitpick comments (3)
src/dynamic-handle.ts (2)

40-40: Ehh~? Two constants for the same thinggg~? How sloppy, baka ♡

You have DANGEROUS_KEYS as a Set on line 40 and dangerousKeys as an array on line 126, both containing the exact same values. This duplication is asking for bugs when someone updates one but forgets the other~ (´-ω-`)

Consider consolidating into a single source of truth~

♻️ Suggested consolidation
 const ARRAY_INDEX_REGEX = /^(.+)\[(\d+)\]$/
 const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
+const dangerousKeysArray = ['__proto__', 'constructor', 'prototype'] as const
 
 const isDangerousKey = (key: string): boolean => {
 	if (DANGEROUS_KEYS.has(key)) return true

Then use dangerousKeysArray in stripDangerousKeys, or better yet, just use DANGEROUS_KEYS everywhere:

-const dangerousKeys = ['__proto__', 'constructor', 'prototype']
-
 const stripDangerousKeys = (obj: unknown): unknown => {
 	if (typeof obj !== 'object' || obj === null) return obj
 
 	if (Array.isArray(obj)) {
 		for (let i = 0; i < obj.length; i++)
 			obj[i] = stripDangerousKeys(obj[i])
 		return obj
 	}
 
-	for (const key of dangerousKeys)
+	for (const key of DANGEROUS_KEYS)
 		if (key in obj) delete (obj as Record<string, unknown>)[key]

Also applies to: 126-126

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/dynamic-handle.ts` at line 40, Consolidate the duplicated dangerous key
definitions by removing the duplicate symbol dangerousKeys (array) and using the
single source DANGEROUS_KEYS (Set) everywhere; update stripDangerousKeys and any
other usages to reference DANGEROUS_KEYS (convert to array if needed inside
functions by Array.from(DANGEROUS_KEYS) or use Set.has for membership checks) so
there's only one canonical definition of ['__proto__','constructor','prototype']
(symbols: DANGEROUS_KEYS, dangerousKeys, stripDangerousKeys).

185-186: Inconsistent usage detected~ Are you even paying attention? (¬‿¬)

On line 157, you use the return value: return stripDangerousKeys(parsed), but here on line 185 you just call it and ignore the return, relying on mutation~ Both work because the function mutates in-place, but this inconsistency could cause bugs if someone refactors to a pure function later ♡

♻️ Consistent usage suggestion
-	stripDangerousKeys(parsed)
+	parsed = stripDangerousKeys(parsed) as typeof parsed
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/dynamic-handle.ts` around lines 185 - 186, The call to stripDangerousKeys
is used inconsistently: one place returns its value (return
stripDangerousKeys(parsed)) while another just calls stripDangerousKeys(parsed)
and relies on mutation; choose one consistent pattern—either make
stripDangerousKeys a pure function and always capture/return its result, or keep
it mutating and never use its return value. Update the code around the lone call
to stripDangerousKeys(parsed) so you assign and/or return its result (e.g.,
parsed = stripDangerousKeys(parsed) or return stripDangerousKeys(parsed)), or
alternatively change the earlier return to call stripDangerousKeys(parsed);
ensure all usages of stripDangerousKeys (the function name) follow the same
convention.
test/security/formdata-prototype-pollution.test.ts (1)

4-120: Ehhhh~? Only 4 tests for a security fix? How lazy~ (¬_¬)

Your tests cover the basics but miss some important edge cases, silly~ Consider adding tests for:

  1. Dangerous keys as form field names - e.g., a field literally named __proto__
  2. Nested form field paths - e.g., user.profile or items[0] syntax
  3. JSON arrays - values starting with [ are also parsed (line 153 in dynamic-handle.ts)
  4. Deeply nested dangerous keys - multiple levels deep like a.b.c.__proto__
♻️ Example additional test cases
it('should reject dangerous form field names', async () => {
	const form = new FormData()
	form.append('__proto__', JSON.stringify({ polluted: true }))
	form.append('safe', 'value')

	const response = await app.handle(
		new Request('http://localhost/submit', {
			method: 'POST',
			body: form
		})
	)

	const result = (await response.json()) as Record<string, unknown>
	expect(Object.keys(result)).not.toContain('__proto__')
	expect(result.safe).toBe('value')
})

it('should strip dangerous keys from parsed JSON arrays', async () => {
	const form = new FormData()
	form.append(
		'data',
		JSON.stringify([{ __proto__: { polluted: true }, ok: 1 }])
	)

	const response = await app.handle(
		new Request('http://localhost/submit', {
			method: 'POST',
			body: form
		})
	)

	const result = (await response.json()) as Record<string, unknown>
	const data = result.data as Array<Record<string, unknown>>
	expect(data[0].ok).toBe(1)
	expect(Object.keys(data[0])).not.toContain('__proto__')
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/security/formdata-prototype-pollution.test.ts` around lines 4 - 120, Add
additional tests to the existing "FormData prototype pollution prevention" suite
to cover the missing edge cases: create new it-blocks that use the same app
(const app = new Elysia().post('/submit', ({ body }) => body) ) to verify (1) a
form field literally named "__proto__" is not included in the parsed request
body, (2) JSON array values (e.g., form.append('data', JSON.stringify([{
__proto__: {...}, ok: 1 }])) ) are parsed and stripped of dangerous keys, (3)
nested form-field path syntaxes like "user.profile" and "items[0]" are handled
without allowing prototype pollution, and (4) deeply nested dangerous keys
(e.g., { a: { b: { c: { __proto__: {...} } } } }) are recursively sanitized;
follow the style of existing tests (use app.handle(new Request(...)) and
assertions on Object.keys(...) and resulting properties) so the parser logic
referenced in dynamic-handle.ts (JSON parsing branch) is exercised for these
cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/adapter/web-standard/index.ts`:
- Around line 109-116: The code parses an existing string into parsed and
assigns it directly to current[arrayInfo.name][arrayInfo.index] without
sanitization; call stripDangerousKeys(parsed) (or equivalent sanitizer) after
successful JSON.parse and before assigning to
current[arrayInfo.name][arrayInfo.index] so dangerous keys like __proto__ are
removed; ensure you only call stripDangerousKeys when parsed is an object (the
same condition that sets parsed) and preserve the existing fallback to {} if
parsed becomes undefined after sanitization.

---

Nitpick comments:
In `@src/dynamic-handle.ts`:
- Line 40: Consolidate the duplicated dangerous key definitions by removing the
duplicate symbol dangerousKeys (array) and using the single source
DANGEROUS_KEYS (Set) everywhere; update stripDangerousKeys and any other usages
to reference DANGEROUS_KEYS (convert to array if needed inside functions by
Array.from(DANGEROUS_KEYS) or use Set.has for membership checks) so there's only
one canonical definition of ['__proto__','constructor','prototype'] (symbols:
DANGEROUS_KEYS, dangerousKeys, stripDangerousKeys).
- Around line 185-186: The call to stripDangerousKeys is used inconsistently:
one place returns its value (return stripDangerousKeys(parsed)) while another
just calls stripDangerousKeys(parsed) and relies on mutation; choose one
consistent pattern—either make stripDangerousKeys a pure function and always
capture/return its result, or keep it mutating and never use its return value.
Update the code around the lone call to stripDangerousKeys(parsed) so you assign
and/or return its result (e.g., parsed = stripDangerousKeys(parsed) or return
stripDangerousKeys(parsed)), or alternatively change the earlier return to call
stripDangerousKeys(parsed); ensure all usages of stripDangerousKeys (the
function name) follow the same convention.

In `@test/security/formdata-prototype-pollution.test.ts`:
- Around line 4-120: Add additional tests to the existing "FormData prototype
pollution prevention" suite to cover the missing edge cases: create new
it-blocks that use the same app (const app = new Elysia().post('/submit', ({
body }) => body) ) to verify (1) a form field literally named "__proto__" is not
included in the parsed request body, (2) JSON array values (e.g.,
form.append('data', JSON.stringify([{ __proto__: {...}, ok: 1 }])) ) are parsed
and stripped of dangerous keys, (3) nested form-field path syntaxes like
"user.profile" and "items[0]" are handled without allowing prototype pollution,
and (4) deeply nested dangerous keys (e.g., { a: { b: { c: { __proto__: {...} }
} } }) are recursively sanitized; follow the style of existing tests (use
app.handle(new Request(...)) and assertions on Object.keys(...) and resulting
properties) so the parser logic referenced in dynamic-handle.ts (JSON parsing
branch) is exercised for these cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c68c9773-e0dc-4569-bd19-b26eafaaa409

📥 Commits

Reviewing files that changed from the base of the PR and between 56310be and 2e3dfc1.

📒 Files selected for processing (3)
  • src/adapter/web-standard/index.ts
  • src/dynamic-handle.ts
  • test/security/formdata-prototype-pollution.test.ts

@eddieran eddieran closed this by deleting the head repository Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security] Query type confusion and FormData auto-parse prototype pollution

1 participant