fix: strip prototype pollution keys from FormData JSON parsing#1849
fix: strip prototype pollution keys from FormData JSON parsing#1849eddieran wants to merge 1 commit intoelysiajs:mainfrom
Conversation
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>
WalkthroughOh wow, so you finally decided to fix that embarrassing prototype pollution vulnerability in FormData parsing~? How cute ♡ These changes add Changes
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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🟠 MajorAra 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
stripDangerousKeyson the result before assigning it at line 116! If an attacker crafts a form with a field likenested[0]containing{"__proto__": {"bad": true}}, followed bynested[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_KEYSas a Set on line 40 anddangerousKeysas 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 trueThen use
dangerousKeysArrayinstripDangerousKeys, or better yet, just useDANGEROUS_KEYSeverywhere:-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:
- Dangerous keys as form field names - e.g., a field literally named
__proto__- Nested form field paths - e.g.,
user.profileoritems[0]syntax- JSON arrays - values starting with
[are also parsed (line 153 in dynamic-handle.ts)- 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
📒 Files selected for processing (3)
src/adapter/web-standard/index.tssrc/dynamic-handle.tstest/security/formdata-prototype-pollution.test.ts
Summary
Fixes #1848 (Finding 2: FormData auto-parse prototype pollution)
normalizeFormValueinsrc/dynamic-handle.tsand the compiled formData parser insrc/adapter/web-standard/index.tsauto-parse form field values starting with{or[as JSON viaJSON.parse. The parsed objects can carry__proto__,constructor, orprototypeas enumerable own properties, creating a prototype pollution risk if downstream code merges or spreads these objects.JSON.parse, recursively strip__proto__,constructor, andprototypekeys from the parsed object in both the dynamic and compiled code paths.Test plan
test/security/formdata-prototype-pollution.test.ts— 4 tests covering__proto__,constructor/prototype, nested objects, and valid JSON passthroughbun test test/type-system/formdata.test.ts(28/28)🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
Bug Fixes
Tests