Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/adapter/web-standard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;i<obj.length;i++)obj[i]=stripDangerousKeys(obj[i]);return obj}` +
`for(const dk of dangerousKeys)if(dk in obj)delete obj[dk];` +
`for(const k of Object.keys(obj))obj[k]=stripDangerousKeys(obj[k]);` +
`return obj` +
`}\n` +
`for(const key of form.keys()){` +
`if(c.body[key])continue\n` +
`const value=form.getAll(key)\n` +
Expand All @@ -67,7 +74,7 @@ export const WebStandardAdapter: ElysiaAdapter = {
`if(typeof sv==='string'&&(sv.charCodeAt(0)===123||sv.charCodeAt(0)===91)){\n` +
`try{\n` +
`const p=JSON.parse(sv)\n` +
`if(p&&typeof p==='object')finalValue=p\n` +
`if(p&&typeof p==='object')finalValue=stripDangerousKeys(p)\n` +
`}catch{}\n` +
`}\n` +
`if(finalValue===undefined)finalValue=sv\n` +
Expand All @@ -79,6 +86,7 @@ export const WebStandardAdapter: ElysiaAdapter = {
`try{\n` +
`const parsed=JSON.parse(stringValue)\n` +
`if(parsed&&typeof parsed==='object'&&!Array.isArray(parsed)){\n` +
`stripDangerousKeys(parsed)\n` +
`if(!('file' in parsed)&&files.length===1)parsed.file=files[0]\n` +
`else if(!('files' in parsed)&&files.length>1)parsed.files=files\n` +
`finalValue=parsed\n` +
Expand Down
26 changes: 25 additions & 1 deletion src/dynamic-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,28 @@ const setNestedValue = (obj: Record<string, any>, 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<string, unknown>)[key]

for (const key of Object.keys(obj))
(obj as Record<string, unknown>)[key] = stripDangerousKeys(
(obj as Record<string, unknown>)[key]
)

return obj
}

const normalizeFormValue = (value: unknown[]) => {
if (value.length === 1) {
const stringValue = value[0]
Expand All @@ -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 {}
}
Expand Down Expand Up @@ -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<string, unknown>).file = files[0]
else if (!('files' in parsed) && files.length > 1)
Expand Down
121 changes: 121 additions & 0 deletions test/security/formdata-prototype-pollution.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
const data = result.data as Record<string, unknown>

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<string, unknown> = {}
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<string, unknown>
const data = result.data as Record<string, unknown>

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<string, unknown>
const data = result.data as Record<string, unknown>
const nested = data.nested as Record<string, unknown>

expect(nested.value).toBe('ok')
expect(Object.keys(nested)).not.toContain('__proto__')

// Verify Object.prototype was not polluted
const clean: Record<string, unknown> = {}
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<string, unknown>
const data = result.data as Record<string, unknown>

expect(data.name).toBe('test')
expect(data.count).toBe(42)
expect(data.tags).toEqual(['a', 'b'])
})
})