diff --git a/src/adapter/web-standard/index.ts b/src/adapter/web-standard/index.ts index 43701d0d..891e09c1 100644 --- a/src/adapter/web-standard/index.ts +++ b/src/adapter/web-standard/index.ts @@ -58,6 +58,13 @@ export const WebStandardAdapter: ElysiaAdapter = { `const m=k.match(/^(.+)\\[(\\d+)\\]$/);` + `return m?{name:m[1],index:parseInt(m[2],10)}:null` + `}\n` + + `const stripDangerousKeys=(obj)=>{` + + `if(typeof obj!=='object'||obj===null)return obj;` + + `if(Array.isArray(obj)){for(let i=0;i1)parsed.files=files\n` + `finalValue=parsed\n` + diff --git a/src/dynamic-handle.ts b/src/dynamic-handle.ts index c4dcd519..f518cfff 100644 --- a/src/dynamic-handle.ts +++ b/src/dynamic-handle.ts @@ -123,6 +123,28 @@ const setNestedValue = (obj: Record, path: string, value: any) => { } } +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) + if (key in obj) delete (obj as Record)[key] + + for (const key of Object.keys(obj)) + (obj as Record)[key] = stripDangerousKeys( + (obj as Record)[key] + ) + + return obj +} + const normalizeFormValue = (value: unknown[]) => { if (value.length === 1) { const stringValue = value[0] @@ -132,7 +154,7 @@ const normalizeFormValue = (value: unknown[]) => { try { const parsed = JSON.parse(stringValue) if (parsed && typeof parsed === 'object') { - return parsed + return stripDangerousKeys(parsed) } } catch {} } @@ -160,6 +182,8 @@ const normalizeFormValue = (value: unknown[]) => { if (typeof parsed !== 'object' || parsed === null) return value + stripDangerousKeys(parsed) + if (!('file' in parsed) && files.length === 1) (parsed as Record).file = files[0] else if (!('files' in parsed) && files.length > 1) diff --git a/test/security/formdata-prototype-pollution.test.ts b/test/security/formdata-prototype-pollution.test.ts new file mode 100644 index 00000000..0ddc43de --- /dev/null +++ b/test/security/formdata-prototype-pollution.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'bun:test' +import { Elysia } from '../../src' + +describe('FormData prototype pollution prevention', () => { + const app = new Elysia().post('/submit', ({ body }) => { + return body + }) + + it('should strip __proto__ from parsed JSON in form values', async () => { + const form = new FormData() + form.append( + 'data', + JSON.stringify({ + __proto__: { polluted: true }, + safe: 'value' + }) + ) + + const response = await app.handle( + new Request('http://localhost/submit', { + method: 'POST', + body: form + }) + ) + + const result = (await response.json()) as Record + const data = result.data as Record + + expect(data.safe).toBe('value') + // __proto__ should not be an own enumerable property + expect(Object.keys(data)).not.toContain('__proto__') + + // Verify Object.prototype was not polluted + const clean: Record = {} + expect(clean).not.toHaveProperty('polluted') + }) + + it('should strip constructor and prototype keys from parsed JSON in form values', async () => { + const form = new FormData() + form.append( + 'data', + JSON.stringify({ + constructor: { prototype: { polluted: true } }, + prototype: { polluted: true }, + safe: 'value' + }) + ) + + const response = await app.handle( + new Request('http://localhost/submit', { + method: 'POST', + body: form + }) + ) + + const result = (await response.json()) as Record + const data = result.data as Record + + expect(data.safe).toBe('value') + expect(Object.keys(data)).not.toContain('constructor') + expect(Object.keys(data)).not.toContain('prototype') + }) + + it('should strip __proto__ from nested objects in parsed JSON form values', async () => { + const form = new FormData() + form.append( + 'data', + JSON.stringify({ + nested: { + __proto__: { polluted: true }, + value: 'ok' + }, + safe: 'value' + }) + ) + + const response = await app.handle( + new Request('http://localhost/submit', { + method: 'POST', + body: form + }) + ) + + const result = (await response.json()) as Record + const data = result.data as Record + const nested = data.nested as Record + + expect(nested.value).toBe('ok') + expect(Object.keys(nested)).not.toContain('__proto__') + + // Verify Object.prototype was not polluted + const clean: Record = {} + expect(clean).not.toHaveProperty('polluted') + }) + + it('should still parse valid JSON form values without dangerous keys', async () => { + const form = new FormData() + form.append( + 'data', + JSON.stringify({ + name: 'test', + count: 42, + tags: ['a', 'b'] + }) + ) + + const response = await app.handle( + new Request('http://localhost/submit', { + method: 'POST', + body: form + }) + ) + + const result = (await response.json()) as Record + const data = result.data as Record + + expect(data.name).toBe('test') + expect(data.count).toBe(42) + expect(data.tags).toEqual(['a', 'b']) + }) +})