Elysia vulnerable to prototype pollution with multiple standalone schema validation
Description
Elysia is a Typescript framework for request validation, type inference, OpenAPI documentation and client-server communication. Versions 1.4.0 through 1.4.16 contain a prototype pollution vulnerability in mergeDeep after merging results of two standard schema validations with the same key. Due to the ordering of merging, there must be an any type that is set as a standalone guard, to allow for the __proto__ prop to be merged. When combined with GHSA-8vch-m3f4-q8jf this allows for a full RCE by an attacker. This issue is fixed in version 1.4.17. To workaround, remove the __proto__ key from body.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
elysianpm | >= 1.4.0, < 1.4.17 | 1.4.17 |
Affected products
2Patches
23af978663e43:wrench: fix: sanitize cookie key
2 files changed · +4 −4
src/compose.ts+3 −3 modified@@ -606,16 +606,16 @@ export const composeHandler = ({ if (cookieMeta.sign === true) _encodeCookie += 'for(const [key, cookie] of Object.entries(_setCookie)){' + - `c.set.cookie[key].value=await signCookie(cookie.value,\`${secret}\`)` + + `c.set.cookie[key].value=await signCookie(cookie.value,${!secret ? 'undefined' : overrideUnsafeQuote(secret)})` + '}' else { if (typeof cookieMeta.sign === 'string') cookieMeta.sign = [cookieMeta.sign] for (const name of cookieMeta.sign) _encodeCookie += - `if(_setCookie['${name}']?.value)` + - `c.set.cookie['${name}'].value=await signCookie(_setCookie['${name}'].value,\`${secret}\`)\n` + `if(_setCookie[${overrideUnsafeQuote(name)}]?.value)` + + `c.set.cookie[${overrideUnsafeQuote(name)}].value=await signCookie(_setCookie[${overrideUnsafeQuote(name)}].value,${!secret ? 'undefined' : overrideUnsafeQuote(secret)})\n` } _encodeCookie += '}\n'
test/core/elysia.test.ts+1 −1 modified@@ -420,7 +420,7 @@ describe('Edge Case', () => { }) }) - it('handle arbitary code execution from cookie', async () => { + it('handle arbitrary code execution from cookie', async () => { const app = new Elysia({ cookie: { secrets: `\` + console.log(c.q='pwn') + \``,
26935bf76ebc:wrench: fix: cookie injection, prototype pollution, and RCE
5 files changed · +118 −62
CHANGELOG.md+3 −0 modified@@ -5,6 +5,9 @@ Improvement: - [#1549](https://github.com/elysiajs/elysia/pull/1549) Set-Cookie headers not sent when errors are thrown - [#1579](https://github.com/elysiajs/elysia/pull/1579) HEAD to handle Promise value +Security: +- cookie injection, prototype pollution, and RCE + Bug fix: - [#1550](https://github.com/elysiajs/elysia/pull/1550) excess property checking
example/a.ts+19 −46 modified@@ -1,51 +1,24 @@ import { Elysia, t } from '../src' +import * as z from 'zod' +import { post, req } from '../test/utils' -const app = new Elysia().get( - '/', - () => ({ message: 'Hello Elysia' as const }), - { - response: { - 200: t.Object({ - message: t.Literal('Hello Elysia') +const app = new Elysia() + .guard({ + schema: 'standalone', + body: z.object({ + data: z.any() + }) + }) + .post('/', ({ body }) => ({ body, win: {}.foo }), { + body: z.object({ + data: z.object({ + messageId: z.string('pollute-me') }) - } - } -) - -type AppResponse = (typeof app)['~Routes']['get']['response'] - -// Should properly infer the 200 response type, not [x: string]: any -const _typeTest: AppResponse extends { - 200: { message: 'Hello Elysia' } -} - ? true - : false = true - -// Test with multiple status codes including 200 -const app2 = new Elysia().post( - '/test', - ({ status }) => { - if (Math.random() > 0.5) { - return status(200, { message: 'Hello Elysia' as const }) - } + }) + }) + .get('/cold-route', () => 'hello world') + .listen(3000) - return status(422, { error: 'Validation error' }) - }, - { - response: { - 200: t.Object({ - message: t.Literal('Hello Elysia') - }), - 422: t.Object({ - error: t.String() - }) - } - } +console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ) - -type App2Response = (typeof app2)['~Routes']['test']['post']['response'] - -type A = App2Response extends { - 200: { message: 'Hello Elysia' } - 422: { error: string } -} ? true : false
src/compose.ts+18 −12 modified@@ -67,6 +67,10 @@ import { tee } from './adapter/utils' const allocateIf = (value: string, condition: unknown) => condition ? value : '' +const overrideUnsafeQuote = (value: string) => + // '`' + value + '`' + '`' + value.replace(/`/g, '\\`').replace(/\${/g, '$\\{') + '`' + const defaultParsers = [ 'json', 'text', @@ -599,13 +603,13 @@ export const composeHandler = ({ if (cookieMeta.sign === true) _encodeCookie += 'for(const [key, cookie] of Object.entries(_setCookie)){' + - `c.set.cookie[key].value=await signCookie(cookie.value,'${secret}')` + + `c.set.cookie[key].value=await signCookie(cookie.value,\`${secret}\`)` + '}' else for (const name of cookieMeta.sign) _encodeCookie += `if(_setCookie['${name}']?.value)` + - `c.set.cookie['${name}'].value=await signCookie(_setCookie['${name}'].value,'${secret}')\n` + `c.set.cookie['${name}'].value=await signCookie(_setCookie['${name}'].value,\`${secret}\`)\n` _encodeCookie += '}\n' } @@ -643,12 +647,16 @@ export const composeHandler = ({ const get = (name: keyof CookieOptions, defaultValue?: unknown) => { // @ts-ignore const value = cookieMeta?.[name] ?? defaultValue + + if (value === undefined) return '' + if (!value) return typeof defaultValue === 'string' ? `${name}:"${defaultValue}",` : `${name}:${defaultValue},` - if (typeof value === 'string') return `${name}:'${value}',` + if (typeof value === 'string') + return `${name}:${overrideUnsafeQuote(value)},` if (value instanceof Date) return `${name}: new Date(${value.getTime()}),` @@ -659,12 +667,11 @@ export const composeHandler = ({ ? `{secrets:${ cookieMeta.secrets !== undefined ? typeof cookieMeta.secrets === 'string' - ? `'${cookieMeta.secrets}'` + ? overrideUnsafeQuote(cookieMeta.secrets) : '[' + - cookieMeta.secrets.reduce( - (a, b) => a + `'${b}',`, - '' - ) + + cookieMeta.secrets + .map(overrideUnsafeQuote) + .reduce((a, b) => a + `'${b}',`, '') + ']' : 'undefined' },` + @@ -673,10 +680,9 @@ export const composeHandler = ({ ? true : cookieMeta.sign !== undefined ? '[' + - cookieMeta.sign.reduce( - (a, b) => a + `'${b}',`, - '' - ) + + cookieMeta.sign + .map(overrideUnsafeQuote) + .reduce((a, b) => a + `'${b}',`, '') + ']' : 'undefined' },` +
src/utils.ts+5 −1 modified@@ -68,7 +68,11 @@ export const mergeDeep = < if (!isObject(target) || !isObject(source)) return target as A & B for (const [key, value] of Object.entries(source)) { - if (skipKeys?.includes(key)) continue + if ( + skipKeys?.includes(key) || + ['__proto__', 'constructor', 'prototype'].includes(key) + ) + continue if (mergeArray && Array.isArray(value)) { target[key as keyof typeof target] = Array.isArray(
test/core/elysia.test.ts+73 −3 modified@@ -2,6 +2,7 @@ import { Elysia, t } from '../../src' import { describe, expect, it } from 'bun:test' import { req } from '../utils' +import z from 'zod' describe('Edge Case', () => { it('handle state', async () => { @@ -244,11 +245,12 @@ describe('Edge Case', () => { expect(responses).toEqual(['AB', 'BA']) }) - it('handle complex union with json accelerate, exact mirror, and sanitize', async () => { + it('handle complex union with json exact mirror, and sanitize', async () => { const app = new Elysia({ sanitize: (v) => v && 'Elysia' }).get( '/', + // @ts-ignore () => ({ type: 'ok', data: [ @@ -399,7 +401,7 @@ describe('Edge Case', () => { expect(response.status).toBe(200) expect(response.headers.toJSON()).toEqual({ - 'content-length': '11', + 'content-length': '11' }) }) @@ -414,7 +416,75 @@ describe('Edge Case', () => { expect(response.status).toBe(200) expect(response.headers.toJSON()).toEqual({ - 'content-length': '11', + 'content-length': '11' }) }) + + it('handle arbitary code execution from cookie', async () => { + const app = new Elysia({ + cookie: { + secrets: `\` + console.log(c.q='pwn') + \``, + domain: process.env.COOKIE_DOMAIN || 'localhost' + } + }).get( + '/', + (c) => + // @ts-ignore + c.q ?? 'safe', + { + cookie: t.Cookie({ + foo: t.Optional(t.Any()) + }) + } + ) + + const response = await app + .handle(req('/?name=saltyaom')) + .then((x) => x.text()) + + expect(response).toBe('safe') + }) + + it('prototype pollution from input', () => { + const app = new Elysia() + .guard({ + schema: 'standalone', + body: z.object({ + data: z.any() + }) + }) + .post( + '/', + ({ body }) => ({ + body, + win: + // @ts-ignore + {}.foo + }), + { + body: z.object({ + data: z.object({ + messageId: z.string('pollute-me') + }) + }) + } + ) + + app.handle( + new Request('http://localhost:3000/', { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: `{ + "data": { + "messageId": "pollute-me", + "__proto__": { + "foo": "bar" + } + } + }` + }) + ).then((x) => x.json()) + }) })
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-hxj9-33pp-j2ccghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-66456ghsaADVISORY
- github.com/elysiajs/elysia/commit/26935bf76ebc43b4a43d48b173fc853de43bb51eghsax_refsource_MISCWEB
- github.com/elysiajs/elysia/commit/3af978663e437dccc6c1a2a3aff4b74e1574849eghsax_refsource_MISCWEB
- github.com/elysiajs/elysia/pull/1564ghsax_refsource_MISCWEB
- github.com/elysiajs/elysia/security/advisories/GHSA-8vch-m3f4-q8jfghsax_refsource_MISCWEB
- github.com/elysiajs/elysia/security/advisories/GHSA-hxj9-33pp-j2ccghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.