From 4a44247a649a40ef3f1db8261a0e780080f494ba Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:40:34 +0000 Subject: [PATCH 1/6] fix: Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) (#10123) --- .github/pull_request_template.md | 4 +- benchmark/performance.js | 33 ++++++++ spec/vulnerabilities.spec.js | 136 +++++++++++++++++++++++++++++++ src/Utils.js | 27 ++++-- 4 files changed, 188 insertions(+), 12 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 38051caa2c..4c7cc7c321 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,9 +4,7 @@ - Any contribution is under this [license](https://github.com/parse-community/parse-server/blob/alpha/LICENSE). ## Issue - - -Closes: FILL_THIS_OUT + ## Approach diff --git a/benchmark/performance.js b/benchmark/performance.js index 055558eb4f..b8d7bdff1a 100644 --- a/benchmark/performance.js +++ b/benchmark/performance.js @@ -791,6 +791,38 @@ async function benchmarkLiveQueryRegex(name) { } } +/** + * Benchmark: Object.save with nested data (denylist scanning) + * + * Measures create latency for objects with deeply nested structures containing + * multiple sibling objects at each level. This exercises the requestKeywordDenylist + * scanner (objectContainsKeyValue) which must traverse all keys and nested values. + */ +async function benchmarkObjectCreateNestedDenylist(name) { + let counter = 0; + + return measureOperation({ + name, + iterations: 1_000, + operation: async () => { + const TestObject = Parse.Object.extend('BenchmarkDenylist'); + const obj = new TestObject(); + const idx = counter++; + obj.set('nested', { + meta1: { info: { detail: `value-${idx}` } }, + meta2: { info: { detail: `value-${idx}` } }, + meta3: { info: { detail: `value-${idx}` } }, + tags: ['a', 'b', 'c'], + config: { + setting1: { enabled: true, params: { x: 1 } }, + setting2: { enabled: false, params: { y: 2 } }, + }, + }); + await obj.save(); + }, + }); +} + /** * Run all benchmarks */ @@ -824,6 +856,7 @@ async function runBenchmarks() { { name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested }, { name: 'Query.find (large result, GC pressure)', fn: benchmarkLargeResultMemory }, { name: 'Query.find (concurrent, GC pressure)', fn: benchmarkConcurrentQueryMemory }, + { name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist }, { name: 'Query $regex', fn: benchmarkQueryRegex }, { name: 'LiveQuery $regex', fn: benchmarkLiveQueryRegex }, ]; diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index bf88415877..1738a23972 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -161,6 +161,142 @@ describe('Vulnerabilities', () => { }); describe('Request denylist', () => { + describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => { + it('denies _bsontype:Code after a sibling nested object', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + obj: { + metadata: {}, + _bsontype: 'Code', + code: 'malicious', + }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('denies _bsontype:Code after a sibling nested array', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + obj: { + tags: ['safe'], + _bsontype: 'Code', + code: 'malicious', + }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('denies __proto__ after a sibling nested object', async () => { + // Cannot test via HTTP because deepcopy() strips __proto__ before the denylist + // check runs. Test objectContainsKeyValue directly with a JSON.parse'd object + // that preserves __proto__ as an own property. + const Utils = require('../lib/Utils'); + const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}'); + expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true); + }); + + it('denies constructor after a sibling nested object', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + obj: { + data: {}, + constructor: { prototype: { polluted: true } }, + }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"constructor"}.' + ); + }); + + it('denies _bsontype:Code nested inside a second sibling object', async () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/Bypass', + body: JSON.stringify({ + field1: { safe: true }, + field2: { _bsontype: 'Code', code: 'malicious' }, + }), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toBe( + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ); + }); + + it('handles circular references without infinite loop', () => { + const Utils = require('../lib/Utils'); + const obj = { name: 'test', nested: { value: 1 } }; + obj.nested.self = obj; + expect(Utils.objectContainsKeyValue(obj, 'nonexistent', undefined)).toBe(false); + }); + + it('denies _bsontype:Code in file metadata after a sibling nested object', async () => { + const str = 'Hello World!'; + const data = []; + for (let i = 0; i < str.length; i++) { + data.push(str.charCodeAt(i)); + } + const file = new Parse.File('hello.txt', data, 'text/plain'); + file.addMetadata('nested', { safe: true }); + file.addMetadata('_bsontype', 'Code'); + file.addMetadata('code', 'malicious'); + await expectAsync(file.save()).toBeRejectedWith( + new Parse.Error( + Parse.Error.INVALID_KEY_NAME, + 'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.' + ) + ); + }); + }); + it('denies BSON type code data in write request by default', async () => { const headers = { 'Content-Type': 'application/json', diff --git a/src/Utils.js b/src/Utils.js index 1e072725d7..11c5d14399 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -344,16 +344,25 @@ class Utils { const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b; const isKeyMatch = k => isMatch(k, key); const isValueMatch = v => isMatch(v, value); - for (const [k, v] of Object.entries(obj)) { - if (key !== undefined && value === undefined && isKeyMatch(k)) { - return true; - } else if (key === undefined && value !== undefined && isValueMatch(v)) { - return true; - } else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) { - return true; + const stack = [obj]; + const seen = new WeakSet(); + while (stack.length > 0) { + const current = stack.pop(); + if (seen.has(current)) { + continue; } - if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) { - return Utils.objectContainsKeyValue(v, key, value); + seen.add(current); + for (const [k, v] of Object.entries(current)) { + if (key !== undefined && value === undefined && isKeyMatch(k)) { + return true; + } else if (key === undefined && value !== undefined && isValueMatch(v)) { + return true; + } else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) { + return true; + } + if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) { + stack.push(v); + } } } return false; From d4020244e9ba9477fea6715f8e1a9f13dba1350a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 7 Mar 2026 16:41:26 +0000 Subject: [PATCH 2/6] chore(release): 9.5.1-alpha.1 [skip ci] ## [9.5.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1-alpha.1) (2026-03-07) ### Bug Fixes * Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index a3618331f7..f03231f879 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1-alpha.1) (2026-03-07) + + +### Bug Fixes + +* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10123](https://github.com/parse-community/parse-server/issues/10123)) ([4a44247](https://github.com/parse-community/parse-server/commit/4a44247a649a40ef3f1db8261a0e780080f494ba)) + # [9.5.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.0-alpha.13...9.5.0-alpha.14) (2026-03-07) diff --git a/package-lock.json b/package-lock.json index 2456973249..7b9979d7b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.0", + "version": "9.5.1-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.0", + "version": "9.5.1-alpha.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 577fb52a7b..d70ae2395e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.0", + "version": "9.5.1-alpha.1", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 560e6e77c7625da0655b2d01dc2d10632a80f591 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:38:13 +0000 Subject: [PATCH 3/6] fix: Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) (#10125) --- spec/vulnerabilities.spec.js | 85 ++++++++++++++++++++++++++++++++++++ src/triggers.js | 26 +++++++---- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 1738a23972..3ce1be2e3f 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -160,6 +160,91 @@ describe('Vulnerabilities', () => { }); }); + describe('(GHSA-5j86-7r7m-p8h6) Cloud function name prototype chain bypass', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects "constructor" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/constructor', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "toString" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/toString', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "valueOf" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/valueOf', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "hasOwnProperty" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/hasOwnProperty', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('rejects "__proto__.toString" as cloud function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/__proto__.toString', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(text.error).toContain('Invalid function'); + }); + + it('still executes a legitimately defined cloud function', async () => { + Parse.Cloud.define('legitimateFunction', () => 'hello'); + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction', + body: JSON.stringify({}), + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.text).result).toBe('hello'); + }); + }); + describe('Request denylist', () => { describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => { it('denies _bsontype:Code after a sibling nested object', async () => { diff --git a/src/triggers.js b/src/triggers.js index 0d1e0e05ac..2ba470ed90 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -20,18 +20,28 @@ export const Types = { const ConnectClassName = '@Connect'; +/** + * Creates a prototype-free object for use as a lookup store. + * This prevents prototype chain properties (e.g. `constructor`, `toString`) + * from being resolved as registered handlers when using bracket notation + * for lookups. Always use this instead of `{}` for handler stores. + */ +function createStore() { + return Object.create(null); +} + const baseStore = function () { const Validators = Object.keys(Types).reduce(function (base, key) { - base[key] = {}; + base[key] = createStore(); return base; - }, {}); - const Functions = {}; - const Jobs = {}; + }, createStore()); + const Functions = createStore(); + const Jobs = createStore(); const LiveQuery = []; const Triggers = Object.keys(Types).reduce(function (base, key) { - base[key] = {}; + base[key] = createStore(); return base; - }, {}); + }, createStore()); return Object.freeze({ Functions, @@ -90,7 +100,7 @@ function getStore(category, name, applicationId) { const invalidNameRegex = /['"`]/; if (invalidNameRegex.test(name)) { // Prevent a malicious user from injecting properties into the store - return {}; + return createStore(); } const path = name.split('.'); @@ -101,7 +111,7 @@ function getStore(category, name, applicationId) { for (const component of path) { store = store[component]; if (!store) { - return {}; + return createStore(); } } return store; From f8afefe4884d760ad2564e5645bf691efbfde08a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 7 Mar 2026 17:39:07 +0000 Subject: [PATCH 4/6] chore(release): 9.5.1-alpha.2 [skip ci] ## [9.5.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.1-alpha.1...9.5.1-alpha.2) (2026-03-07) ### Bug Fixes * Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591)) --- changelogs/CHANGELOG_alpha.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index f03231f879..b84fe858a7 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.1-alpha.1...9.5.1-alpha.2) (2026-03-07) + + +### Bug Fixes + +* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10125](https://github.com/parse-community/parse-server/issues/10125)) ([560e6e7](https://github.com/parse-community/parse-server/commit/560e6e77c7625da0655b2d01dc2d10632a80f591)) + ## [9.5.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.0...9.5.1-alpha.1) (2026-03-07) diff --git a/package-lock.json b/package-lock.json index 7b9979d7b6..bfa2b33538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.1-alpha.1", + "version": "9.5.1-alpha.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.1-alpha.1", + "version": "9.5.1-alpha.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index d70ae2395e..43347918b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.1-alpha.1", + "version": "9.5.1-alpha.2", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0e06b93d83fbdb46aad155e73208e577a53d94ea Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:42:11 +0000 Subject: [PATCH 5/6] ci: Performance benchmark job not failing on regression (#10126) --- .github/workflows/ci-performance.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-performance.yml b/.github/workflows/ci-performance.yml index f5de30ae49..e83941f7cb 100644 --- a/.github/workflows/ci-performance.yml +++ b/.github/workflows/ci-performance.yml @@ -245,6 +245,7 @@ jobs: console.log(''); if (hasRegression) { console.log('⚠️ **Performance regressions detected.** Please review the changes.'); + process.exitCode = 1; } else if (hasImprovement) { console.log('🚀 **Performance improvements detected!** Great work!'); } else { From 7871e012780ad5b7a7704915fc5098bba0b8e8c4 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sat, 7 Mar 2026 18:06:52 +0000 Subject: [PATCH 6/6] empty commit to trigger CI