From b2f23172e4983e4597226ef80ccc75d3054d31ad Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:16:38 +0000 Subject: [PATCH 001/156] fix: NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) (#10128) --- spec/MongoTransform.spec.js | 1 + spec/RegexVulnerabilities.spec.js | 143 ++++++++++++++++++++++++++++++ src/Routers/PagesRouter.js | 3 +- src/Routers/UsersRouter.js | 4 + 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 96a38f36dd..5e27b1757e 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -692,4 +692,5 @@ describe('relativeTimeToDate', () => { }); }); }); + }); diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js index aab0241ad8..3e85620a48 100644 --- a/spec/RegexVulnerabilities.spec.js +++ b/spec/RegexVulnerabilities.spec.js @@ -125,6 +125,149 @@ describe('Regex Vulnerabilities', () => { }); }); + describe('on password reset request via token (handleResetRequest)', () => { + beforeEach(async () => { + user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword'); + // Trigger a password reset to generate a _perishable_token + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + email: 'someemail@somedomain.com', + }), + }); + // Expire the token so the handleResetRequest token-lookup branch matches + await Parse.Server.database.update( + '_User', + { objectId: user.id }, + { + _perishable_token_expires_at: new Date(Date.now() - 10000), + } + ); + }); + + it('should not allow $ne operator to match user via token injection', async () => { + // Without the fix, {$ne: null} matches any user with a non-null expired token, + // causing a password reset email to be sent — a boolean oracle for token extraction. + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $ne: null }, + }), + }); + fail('should not succeed with $ne token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should not allow $regex operator to extract token via injection', async () => { + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $regex: '^.' }, + }), + }); + fail('should not succeed with $regex token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should not allow $exists operator for token injection', async () => { + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $exists: true }, + }), + }); + fail('should not succeed with $exists token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should not allow $gt operator for token injection', async () => { + try { + await request({ + url: `${serverURL}/requestPasswordReset`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + token: { $gt: '' }, + }), + }); + fail('should not succeed with $gt token'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + }); + + describe('on resend verification email', () => { + // The PagesRouter uses express.urlencoded({ extended: false }) which does not parse + // nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection. + // The toString() guard in resendVerificationEmail() is defense-in-depth in case the + // body parser configuration changes. These tests verify the guard works correctly + // by directly testing the PagesRouter method. + it('should sanitize non-string token to string via toString()', async () => { + const { PagesRouter } = require('../lib/Routers/PagesRouter'); + const router = new PagesRouter(); + const goToPage = spyOn(router, 'goToPage').and.returnValue(Promise.resolve()); + const resendSpy = jasmine.createSpy('resendVerificationEmail').and.returnValue(Promise.resolve()); + const req = { + config: { + userController: { resendVerificationEmail: resendSpy }, + }, + body: { + username: 'testuser', + token: { $regex: '^.' }, + }, + }; + await router.resendVerificationEmail(req); + // The token passed to userController.resendVerificationEmail should be a string + const passedToken = resendSpy.calls.first().args[2]; + expect(typeof passedToken).toEqual('string'); + expect(passedToken).toEqual('[object Object]'); + }); + + it('should pass through valid string token unchanged', async () => { + const { PagesRouter } = require('../lib/Routers/PagesRouter'); + const router = new PagesRouter(); + const goToPage = spyOn(router, 'goToPage').and.returnValue(Promise.resolve()); + const resendSpy = jasmine.createSpy('resendVerificationEmail').and.returnValue(Promise.resolve()); + const req = { + config: { + userController: { resendVerificationEmail: resendSpy }, + }, + body: { + username: 'testuser', + token: 'validtoken123', + }, + }; + await router.resendVerificationEmail(req); + const passedToken = resendSpy.calls.first().args[2]; + expect(typeof passedToken).toEqual('string'); + expect(passedToken).toEqual('validtoken123'); + }); + }); + describe('on password reset', () => { beforeEach(async () => { user = await Parse.User.logIn('someemail@somedomain.com', 'somepassword'); diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index bd2dcce29f..59c4abf631 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -108,7 +108,8 @@ export class PagesRouter extends PromiseRouter { resendVerificationEmail(req) { const config = req.config; const username = req.body?.username; - const token = req.body?.token; + const rawToken = req.body?.token; + const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; if (!config) { this.invalidRequest(); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 0cf8247121..edd1bdae4e 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -458,6 +458,10 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email'); } + if (token && typeof token !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_VALUE, 'token must be a string'); + } + let userResults = null; let userData = null; From 3ed96d37d96e45ecf922628d452093864efdd12a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 7 Mar 2026 19:17:59 +0000 Subject: [PATCH 002/156] chore(release): 9.5.2-alpha.1 [skip ci] ## [9.5.2-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.1...9.5.2-alpha.1) (2026-03-07) ### Bug Fixes * NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10128](https://github.com/parse-community/parse-server/issues/10128)) ([b2f2317](https://github.com/parse-community/parse-server/commit/b2f23172e4983e4597226ef80ccc75d3054d31ad)) --- 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 b84fe858a7..cffb28c7e2 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.1...9.5.2-alpha.1) (2026-03-07) + + +### Bug Fixes + +* NoSQL injection via token type in password reset and email verification endpoints ([GHSA-vgjh-hmwf-c588](https://github.com/parse-community/parse-server/security/advisories/GHSA-vgjh-hmwf-c588)) ([#10128](https://github.com/parse-community/parse-server/issues/10128)) ([b2f2317](https://github.com/parse-community/parse-server/commit/b2f23172e4983e4597226ef80ccc75d3054d31ad)) + ## [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) diff --git a/package-lock.json b/package-lock.json index 1392cc6cef..775d61e8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.1", + "version": "9.5.2-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.1", + "version": "9.5.2-alpha.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index a95a2279af..441bd5e42f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.1", + "version": "9.5.2-alpha.1", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0ae9c25bc13847d547871511749b58b575b96333 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:40:45 +0000 Subject: [PATCH 003/156] fix: Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) (#10130) --- resources/buildConfigDefinitions.js | 2 + spec/GraphQLQueryComplexity.spec.js | 181 ++++++++++ spec/ParseGraphQLServer.spec.js | 6 + spec/RequestComplexity.spec.js | 327 ++++++++++++++++++ spec/RestQuery.spec.js | 1 + spec/SecurityCheckGroups.spec.js | 9 + src/Config.js | 28 ++ src/GraphQL/ParseGraphQLServer.js | 3 +- src/GraphQL/helpers/queryComplexity.js | 99 ++++++ src/Options/Definitions.js | 39 +++ src/Options/docs.js | 10 + src/Options/index.js | 24 ++ src/RestQuery.js | 58 +++- .../CheckGroups/CheckGroupServerConfig.js | 17 + 14 files changed, 799 insertions(+), 5 deletions(-) create mode 100644 spec/GraphQLQueryComplexity.spec.js create mode 100644 spec/RequestComplexity.spec.js create mode 100644 src/GraphQL/helpers/queryComplexity.js diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index ae7246fd9b..4220215a10 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -22,6 +22,7 @@ const nestedOptionTypes = [ 'PagesOptions', 'PagesRoute', 'PasswordPolicyOptions', + 'RequestComplexityOptions', 'SecurityOptions', 'SchemaOptions', 'LogLevels', @@ -46,6 +47,7 @@ const nestedOptionEnvPrefix = { ParseServerOptions: 'PARSE_SERVER_', PasswordPolicyOptions: 'PARSE_SERVER_PASSWORD_POLICY_', RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_', + RequestComplexityOptions: 'PARSE_SERVER_REQUEST_COMPLEXITY_', SchemaOptions: 'PARSE_SERVER_SCHEMA_', SecurityOptions: 'PARSE_SERVER_SECURITY_', }; diff --git a/spec/GraphQLQueryComplexity.spec.js b/spec/GraphQLQueryComplexity.spec.js new file mode 100644 index 0000000000..976cc761f4 --- /dev/null +++ b/spec/GraphQLQueryComplexity.spec.js @@ -0,0 +1,181 @@ +'use strict'; + +const http = require('http'); +const express = require('express'); +const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); +require('./helper'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); + +describe('graphql query complexity', () => { + let httpServer; + let graphQLServer; + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQL(serverOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + graphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + }); + graphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: 13378 }, resolve)); + } + + async function graphqlRequest(query, requestHeaders = headers) { + const response = await fetch('http://localhost:13378/graphql', { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ query }), + }); + return response.json(); + } + + // Returns a query with depth 4: users(1) > edges(2) > node(3) > objectId(4) + function buildDeepQuery() { + return '{ users { edges { node { objectId } } } }'; + } + + function buildWideQuery(fieldCount) { + const fields = Array.from({ length: fieldCount }, (_, i) => `field${i}: objectId`).join('\n '); + return `{ users { edges { node { ${fields} } } } }`; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); + + describe('depth limit', () => { + it('should reject query exceeding depth limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /GraphQL query depth of \d+ exceeds maximum allowed depth of 3/ + ); + }); + + it('should allow query within depth limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 10 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + + it('should allow deep query with master key', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3 }, + }); + const result = await graphqlRequest(buildDeepQuery(), { + ...headers, + 'X-Parse-Master-Key': 'test', + }); + expect(result.errors).toBeUndefined(); + }); + + it('should allow unlimited depth when graphQLDepth is -1', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: -1 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + }); + + describe('fields limit', () => { + it('should reject query exceeding fields limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 5 }, + }); + const result = await graphqlRequest(buildWideQuery(10)); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(5\)/ + ); + }); + + it('should allow query within fields limit', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 200 }, + }); + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeUndefined(); + }); + + it('should allow wide query with master key', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 5 }, + }); + const result = await graphqlRequest(buildWideQuery(10), { + ...headers, + 'X-Parse-Master-Key': 'test', + }); + expect(result.errors).toBeUndefined(); + }); + + it('should count fragment fields at each spread location', async () => { + // With correct counting: 2 aliases (2) + 2×edges (2) + 2×node (2) + 2×objectId from fragment (2) = 8 + // With incorrect counting (fragment once): 2 + 2 + 2 + 1 = 7 + // Set limit to 7 so incorrect counting passes but correct counting rejects + await setupGraphQL({ + requestComplexity: { graphQLFields: 7 }, + }); + const result = await graphqlRequest(` + fragment UserFields on User { objectId } + { + a1: users { edges { node { ...UserFields } } } + a2: users { edges { node { ...UserFields } } } + } + `); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(7\)/ + ); + }); + + it('should count inline fragment fields toward depth and field limits', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: 3 }, + }); + // Inline fragment adds fields without increasing depth: + // users(1) > edges(2) > ... on UserConnection { edges(3) > node(4) } + const result = await graphqlRequest(`{ + users { + edges { + ... on UserEdge { + node { + objectId + } + } + } + } + }`); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /Number of GraphQL fields \(\d+\) exceeds maximum allowed \(3\)/ + ); + }); + + it('should allow unlimited fields when graphQLFields is -1', async () => { + await setupGraphQL({ + requestComplexity: { graphQLFields: -1 }, + }); + const result = await graphqlRequest(buildWideQuery(50)); + expect(result.errors).toBeUndefined(); + }); + }); +}); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 2fd8a95158..2e3319c910 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -9554,6 +9554,12 @@ describe('ParseGraphQLServer', () => { }); it_only_db('mongo')('should support deep nested creation', async () => { + parseServer = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + requestComplexity: { includeDepth: 10 }, + }); + await createGQLFromParseServer(parseServer); const team = new Parse.Object('Team'); team.set('name', 'imATeam1'); await team.save(); diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js new file mode 100644 index 0000000000..6ee159f548 --- /dev/null +++ b/spec/RequestComplexity.spec.js @@ -0,0 +1,327 @@ +'use strict'; + +const Config = require('../lib/Config'); +const auth = require('../lib/Auth'); +const rest = require('../lib/rest'); + +describe('request complexity', () => { + function buildNestedInQuery(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $inQuery: { className, where } } }; + } + return where; + } + + function buildNestedNotInQuery(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $notInQuery: { className, where } } }; + } + return where; + } + + function buildNestedSelect(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $select: { query: { className, where }, key: 'username' } } }; + } + return where; + } + + function buildNestedDontSelect(depth, className = '_User') { + let where = {}; + for (let i = 0; i < depth; i++) { + where = { username: { $dontSelect: { query: { className, where }, key: 'username' } } }; + } + return where; + } + + describe('config validation', () => { + it('should accept valid requestComplexity config', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { + includeDepth: 10, + includeCount: 100, + subqueryDepth: 5, + graphQLDepth: 15, + graphQLFields: 300, + }, + }) + ).toBeResolved(); + }); + + it('should accept -1 to disable a specific limit', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, + graphQLDepth: -1, + graphQLFields: -1, + }, + }) + ).toBeResolved(); + }); + + it('should reject value of 0', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { includeDepth: 0 }, + }) + ).toBeRejectedWith( + new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.') + ); + }); + + it('should reject non-integer values', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { includeDepth: 3.5 }, + }) + ).toBeRejectedWith( + new Error('requestComplexity.includeDepth must be a positive integer or -1 to disable.') + ); + }); + + it('should reject unknown properties', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: { unknownProp: 5 }, + }) + ).toBeRejectedWith( + new Error("requestComplexity contains unknown property 'unknownProp'.") + ); + }); + + it('should reject non-object values', async () => { + await expectAsync( + reconfigureServer({ + requestComplexity: 'invalid', + }) + ).toBeRejectedWith(new Error('requestComplexity must be an object.')); + }); + + it('should apply defaults for missing properties', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3 }, + }); + const config = Config.get('test'); + expect(config.requestComplexity.includeDepth).toBe(3); + expect(config.requestComplexity.includeCount).toBe(50); + expect(config.requestComplexity.subqueryDepth).toBe(5); + expect(config.requestComplexity.graphQLDepth).toBe(50); + expect(config.requestComplexity.graphQLFields).toBe(200); + }); + + it('should apply full defaults when not configured', async () => { + await reconfigureServer({}); + const config = Config.get('test'); + expect(config.requestComplexity).toEqual({ + includeDepth: 5, + includeCount: 50, + subqueryDepth: 5, + graphQLDepth: 50, + graphQLFields: 200, + }); + }); + }); + + describe('subquery depth', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 3 }, + }); + config = Config.get('test'); + }); + + it('should allow $inQuery within depth limit', async () => { + const where = buildNestedInQuery(3); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject $inQuery exceeding depth limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $notInQuery exceeding depth limit', async () => { + const where = buildNestedNotInQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $select exceeding depth limit', async () => { + const where = buildNestedSelect(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $dontSelect exceeding depth limit', async () => { + const where = buildNestedDontSelect(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow subqueries with master key even when exceeding limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.master(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow subqueries with maintenance key even when exceeding limit', async () => { + const where = buildNestedInQuery(4); + await expectAsync( + rest.find(config, auth.maintenance(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow unlimited subqueries when subqueryDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: -1 }, + }); + config = Config.get('test'); + const where = buildNestedInQuery(15); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + }); + + describe('include limits', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3, includeCount: 5 }, + }); + config = Config.get('test'); + }); + + it('should allow include within depth limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c' }) + ).toBeResolved(); + }); + + it('should reject include exceeding depth limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d' }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Include depth of 4 exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow include count within limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e' }) + ).toBeResolved(); + }); + + it('should reject include count exceeding limit', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a,b,c,d,e,f' }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Number of include fields \(\d+\) exceeds maximum allowed \(5\)/), + }) + ); + }); + + it('should allow includeAll when within count limit', async () => { + const schema = new Parse.Schema('IncludeTestClass'); + schema.addPointer('ptr1', '_User'); + schema.addPointer('ptr2', '_User'); + schema.addPointer('ptr3', '_User'); + await schema.save(); + + const obj = new Parse.Object('IncludeTestClass'); + await obj.save(); + + await expectAsync( + rest.find(config, auth.nobody(config), 'IncludeTestClass', {}, { includeAll: true }) + ).toBeResolved(); + }); + + it('should reject includeAll when exceeding count limit', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: 3, includeCount: 2 }, + }); + config = Config.get('test'); + + const schema = new Parse.Schema('IncludeTestClass2'); + schema.addPointer('ptr1', '_User'); + schema.addPointer('ptr2', '_User'); + schema.addPointer('ptr3', '_User'); + await schema.save(); + + const obj = new Parse.Object('IncludeTestClass2'); + await obj.save(); + + await expectAsync( + rest.find(config, auth.nobody(config), 'IncludeTestClass2', {}, { includeAll: true }) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Number of include fields .* exceeds maximum allowed/), + }) + ); + }); + + it('should allow includes with master key even when exceeding limits', async () => { + await expectAsync( + rest.find(config, auth.master(config), '_User', {}, { include: 'a.b.c.d' }) + ).toBeResolved(); + }); + + it('should allow unlimited depth when includeDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { includeDepth: -1 }, + }); + config = Config.get('test'); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: 'a.b.c.d.e.f.g' }) + ).toBeResolved(); + }); + + it('should allow unlimited count when includeCount is -1', async () => { + await reconfigureServer({ + requestComplexity: { includeCount: -1 }, + }); + config = Config.get('test'); + const includes = Array.from({ length: 100 }, (_, i) => `field${i}`).join(','); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', {}, { include: includes }) + ).toBeResolved(); + }); + }); +}); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index fb5370d759..9418f856fd 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -390,6 +390,7 @@ describe('rest query', () => { }); it('battle test parallel include with 100 nested includes', async () => { + await reconfigureServer({ requestComplexity: { includeCount: 200 } }); const RootObject = Parse.Object.extend('RootObject'); const Level1Object = Parse.Object.extend('Level1Object'); const Level2Object = Parse.Object.extend('Level2Object'); diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index c9a87ac8ca..696b7da640 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -48,6 +48,7 @@ describe('Security Check Groups', () => { expect(group.checks()[5].checkState()).toBe(CheckState.success); expect(group.checks()[6].checkState()).toBe(CheckState.success); expect(group.checks()[8].checkState()).toBe(CheckState.success); + expect(group.checks()[9].checkState()).toBe(CheckState.success); }); it('checks fail correctly', async () => { @@ -59,6 +60,13 @@ describe('Security Check Groups', () => { config.mountPlayground = true; config.readOnlyMasterKey = 'someReadOnlyMasterKey'; config.readOnlyMasterKeyIps = ['0.0.0.0/0']; + config.requestComplexity = { + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, + graphQLDepth: -1, + graphQLFields: -1, + }; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -70,6 +78,7 @@ describe('Security Check Groups', () => { expect(group.checks()[5].checkState()).toBe(CheckState.fail); expect(group.checks()[6].checkState()).toBe(CheckState.fail); expect(group.checks()[8].checkState()).toBe(CheckState.fail); + expect(group.checks()[9].checkState()).toBe(CheckState.fail); }); it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => { diff --git a/src/Config.js b/src/Config.js index a3a56ea0f7..59afa92c00 100644 --- a/src/Config.js +++ b/src/Config.js @@ -18,6 +18,7 @@ import { PagesOptions, ParseServerOptions, SchemaOptions, + RequestComplexityOptions, SecurityOptions, } from './Options/Definitions'; import ParseServer from './cloud-code/Parse.Server'; @@ -134,6 +135,7 @@ export class Config { databaseOptions, extendSessionOnUse, allowClientClassCreation, + requestComplexity, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -176,6 +178,7 @@ export class Config { this.validateDatabaseOptions(databaseOptions); this.validateCustomPages(customPages); this.validateAllowClientClassCreation(allowClientClassCreation); + this.validateRequestComplexity(requestComplexity); } static validateCustomPages(customPages) { @@ -625,6 +628,31 @@ export class Config { } } + static validateRequestComplexity(requestComplexity) { + if (requestComplexity == null) { + return; + } + if (typeof requestComplexity !== 'object' || Array.isArray(requestComplexity)) { + throw new Error('requestComplexity must be an object.'); + } + const validKeys = Object.keys(RequestComplexityOptions); + for (const key of Object.keys(requestComplexity)) { + if (!validKeys.includes(key)) { + throw new Error(`requestComplexity contains unknown property '${key}'.`); + } + } + for (const key of validKeys) { + if (requestComplexity[key] !== undefined) { + const value = requestComplexity[key]; + if (!Number.isInteger(value) || (value < 1 && value !== -1)) { + throw new Error(`requestComplexity.${key} must be a positive integer or -1 to disable.`); + } + } else { + requestComplexity[key] = RequestComplexityOptions[key].default; + } + } + } + static validateAllowHeaders(allowHeaders) { if (![null, undefined].includes(allowHeaders)) { if (Array.isArray(allowHeaders)) { diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index fa42e0b2eb..6c7c2ff179 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -11,6 +11,7 @@ import requiredParameter from '../requiredParameter'; import defaultLogger from '../logger'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; +import { createComplexityValidationPlugin } from './helpers/queryComplexity'; const hasTypeIntrospection = (query) => { @@ -155,7 +156,7 @@ class ParseGraphQLServer { // We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable // we delegate the introspection control to the IntrospectionControlPlugin introspection: true, - plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)], + plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)], schema, }); await apollo.start(); diff --git a/src/GraphQL/helpers/queryComplexity.js b/src/GraphQL/helpers/queryComplexity.js new file mode 100644 index 0000000000..0057e6438a --- /dev/null +++ b/src/GraphQL/helpers/queryComplexity.js @@ -0,0 +1,99 @@ +import { GraphQLError } from 'graphql'; +import logger from '../../logger'; + +function calculateQueryComplexity(operation, fragments) { + let maxDepth = 0; + let totalFields = 0; + + function visitSelectionSet(selectionSet, depth, visitedFragments) { + if (!selectionSet) { + return; + } + for (const selection of selectionSet.selections) { + if (selection.kind === 'Field') { + totalFields++; + const newDepth = depth + 1; + if (newDepth > maxDepth) { + maxDepth = newDepth; + } + if (selection.selectionSet) { + visitSelectionSet(selection.selectionSet, newDepth, visitedFragments); + } + } else if (selection.kind === 'InlineFragment') { + visitSelectionSet(selection.selectionSet, depth, visitedFragments); + } else if (selection.kind === 'FragmentSpread') { + const name = selection.name.value; + if (visitedFragments.has(name)) { + continue; + } + const fragment = fragments[name]; + if (fragment) { + const branchVisited = new Set(visitedFragments); + branchVisited.add(name); + visitSelectionSet(fragment.selectionSet, depth, branchVisited); + } + } + } + } + + visitSelectionSet(operation.selectionSet, 0, new Set()); + + return { depth: maxDepth, fields: totalFields }; +} + +function createComplexityValidationPlugin(getConfig) { + return { + requestDidStart: (requestContext) => ({ + didResolveOperation: async () => { + const auth = requestContext.contextValue?.auth; + if (auth?.isMaster || auth?.isMaintenance) { + return; + } + + const config = getConfig(); + if (!config) { + return; + } + + const { graphQLDepth, graphQLFields } = config; + if (graphQLDepth === -1 && graphQLFields === -1) { + return; + } + + const fragments = {}; + for (const definition of requestContext.document.definitions) { + if (definition.kind === 'FragmentDefinition') { + fragments[definition.name.value] = definition; + } + } + + const { depth, fields } = calculateQueryComplexity( + requestContext.operation, + fragments + ); + + if (graphQLDepth !== -1 && depth > graphQLDepth) { + const message = `GraphQL query depth of ${depth} exceeds maximum allowed depth of ${graphQLDepth}`; + logger.warn(message); + throw new GraphQLError(message, { + extensions: { + http: { status: 400 }, + }, + }); + } + + if (graphQLFields !== -1 && fields > graphQLFields) { + const message = `Number of GraphQL fields (${fields}) exceeds maximum allowed (${graphQLFields})`; + logger.warn(message); + throw new GraphQLError(message, { + extensions: { + http: { status: 400 }, + }, + }); + } + }, + }), + }; +} + +export { calculateQueryComplexity, createComplexityValidationPlugin }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 96b278df9f..f6f2e6450b 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -492,6 +492,13 @@ module.exports.ParseServerOptions = { action: parsers.arrayParser, default: ['0.0.0.0/0', '::0'], }, + requestComplexity: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY', + help: 'Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit.', + action: parsers.objectParser, + type: 'RequestComplexityOptions', + default: {}, + }, requestContextMiddleware: { env: 'PARSE_SERVER_REQUEST_CONTEXT_MIDDLEWARE', help: 'Options to customize the request context using inversion of control/dependency injection.', @@ -654,6 +661,38 @@ module.exports.RateLimitOptions = { default: 'ip', }, }; +module.exports.RequestComplexityOptions = { + graphQLDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH', + help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.', + action: parsers.numberParser('graphQLDepth'), + default: 50, + }, + graphQLFields: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS', + help: 'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.', + action: parsers.numberParser('graphQLFields'), + default: 200, + }, + includeCount: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT', + help: 'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.', + action: parsers.numberParser('includeCount'), + default: 50, + }, + includeDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH', + help: 'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.', + action: parsers.numberParser('includeDepth'), + default: 5, + }, + subqueryDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH', + help: 'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.', + action: parsers.numberParser('subqueryDepth'), + default: 5, + }, +}; module.exports.SecurityOptions = { checkGroups: { env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', diff --git a/src/Options/docs.js b/src/Options/docs.js index 1a77a8b76a..30d5722313 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -92,6 +92,7 @@ * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. + * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. * @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection. * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls @@ -126,6 +127,15 @@ * @property {String} zone The type of rate limit to apply. The following types are supported:Default is `ip`. */ +/** + * @interface RequestComplexityOptions + * @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`. + * @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`. + * @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`. + * @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`. + * @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. + */ + /** * @interface SecurityOptions * @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`. diff --git a/src/Options/index.js b/src/Options/index.js index 4988166df9..fc1d40d366 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -356,6 +356,10 @@ export interface ParseServerOptions { schema: ?SchemaOptions; /* Callback when server has closed */ serverCloseComplete: ?() => void; + /* Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY + :DEFAULT: {} */ + requestComplexity: ?RequestComplexityOptions; /* The security options to identify and report weak security settings. :DEFAULT: {} */ security: ?SecurityOptions; @@ -411,6 +415,26 @@ export interface RateLimitOptions { zone: ?string; } +export interface RequestComplexityOptions { + /* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`. + :DEFAULT: 5 */ + includeDepth: ?number; + /* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`. + :DEFAULT: 50 */ + includeCount: ?number; + /* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. + :DEFAULT: 5 */ + subqueryDepth: ?number; + /* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH + :DEFAULT: 50 */ + graphQLDepth: ?number; + /* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`. + :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS + :DEFAULT: 200 */ + graphQLFields: ?number; +} + export interface SecurityOptions { /* Is true if Parse Server should check for weak security settings. :DEFAULT: false */ diff --git a/src/RestQuery.js b/src/RestQuery.js index 76535d5edc..4c64801ee7 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -3,6 +3,7 @@ var SchemaController = require('./Controllers/SchemaController'); var Parse = require('parse/node').Parse; +var logger = require('./logger').default; const triggers = require('./triggers'); const { continueWhile } = require('parse/lib/node/promiseUtils'); const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL']; @@ -289,6 +290,9 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) { .then(() => { return this.handleIncludeAll(); }) + .then(() => { + return this.validateIncludeComplexity(); + }) .then(() => { return this.handleExcludeKeys(); }) @@ -359,6 +363,9 @@ _UnsafeRestQuery.prototype.buildRestWhere = function () { .then(() => { return this.validateClientClassCreation(); }) + .then(() => { + return this.checkSubqueryDepth(); + }) .then(() => { return this.replaceSelect(); }) @@ -451,6 +458,22 @@ function transformInQuery(inQueryObject, className, results) { } } +_UnsafeRestQuery.prototype.checkSubqueryDepth = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc || rc.subqueryDepth === -1) { + return; + } + const depth = this.context._subqueryDepth || 0; + if (depth > rc.subqueryDepth) { + const message = `Subquery nesting depth exceeds maximum allowed depth of ${rc.subqueryDepth}`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } +}; + // Replaces a $inQuery clause by running the subquery, if there is an // $inQuery clause. // The $inQuery clause turns into an $in with values that are just @@ -478,6 +501,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -485,7 +509,7 @@ _UnsafeRestQuery.prototype.replaceInQuery = async function () { className: inQueryValue.className, restWhere: inQueryValue.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { transformInQuery(inQueryObject, subquery.className, response.results); @@ -538,6 +562,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -545,7 +570,7 @@ _UnsafeRestQuery.prototype.replaceNotInQuery = async function () { className: notInQueryValue.className, restWhere: notInQueryValue.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { @@ -611,6 +636,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -618,7 +644,7 @@ _UnsafeRestQuery.prototype.replaceSelect = async function () { className: selectValue.query.className, restWhere: selectValue.query.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { @@ -674,6 +700,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () { additionalOptions.readPreference = this.restOptions.readPreference; } + const childContext = { ...this.context, _subqueryDepth: (this.context._subqueryDepth || 0) + 1 }; const subquery = await RestQuery({ method: RestQuery.Method.find, config: this.config, @@ -681,7 +708,7 @@ _UnsafeRestQuery.prototype.replaceDontSelect = async function () { className: dontSelectValue.query.className, restWhere: dontSelectValue.query.where, restOptions: additionalOptions, - context: this.context, + context: childContext, }); return subquery.execute().then(response => { @@ -860,6 +887,29 @@ _UnsafeRestQuery.prototype.handleIncludeAll = function () { }); }; +_UnsafeRestQuery.prototype.validateIncludeComplexity = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc) { + return; + } + if (rc.includeDepth !== -1 && this.include && this.include.length > 0) { + const maxDepth = Math.max(...this.include.map(path => path.length)); + if (maxDepth > rc.includeDepth) { + const message = `Include depth of ${maxDepth} exceeds maximum allowed depth of ${rc.includeDepth}`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } + } + if (rc.includeCount !== -1 && this.include && this.include.length > rc.includeCount) { + const message = `Number of include fields (${this.include.length}) exceeds maximum allowed (${rc.includeCount})`; + logger.warn(message); + throw new Parse.Error(Parse.Error.INVALID_QUERY, message); + } +}; + // Updates property `this.keys` to contain all keys but the ones unselected. _UnsafeRestQuery.prototype.handleExcludeKeys = function () { if (!this.excludeKeys) { diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index 5267174045..d8d8e647e6 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -134,6 +134,23 @@ class CheckGroupServerConfig extends CheckGroup { } }, }), + new Check({ + title: 'Request complexity limits enabled', + warning: + 'One or more request complexity limits are disabled, which may allow denial-of-service attacks through deeply nested or excessively broad queries.', + solution: + "Ensure all properties in 'requestComplexity' are set to positive integers. Set to '-1' only if you have other mitigations in place.", + check: () => { + const rc = config.requestComplexity; + if (!rc) { + throw 1; + } + const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.graphQLDepth, rc.graphQLFields]; + if (values.some(v => v === -1)) { + throw 1; + } + }, + }), new Check({ title: 'LiveQuery regex timeout enabled', warning: From a3e8743aa4383985593dbc66807a49e1bf39e9a6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 7 Mar 2026 23:41:40 +0000 Subject: [PATCH 004/156] chore(release): 9.5.2-alpha.2 [skip ci] ## [9.5.2-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.1...9.5.2-alpha.2) (2026-03-07) ### Bug Fixes * Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10130](https://github.com/parse-community/parse-server/issues/10130)) ([0ae9c25](https://github.com/parse-community/parse-server/commit/0ae9c25bc13847d547871511749b58b575b96333)) --- 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 cffb28c7e2..4d26d7e7c6 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.1...9.5.2-alpha.2) (2026-03-07) + + +### Bug Fixes + +* Denial-of-service via unbounded query complexity in REST and GraphQL API ([GHSA-cmj3-wx7h-ffvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-cmj3-wx7h-ffvg)) ([#10130](https://github.com/parse-community/parse-server/issues/10130)) ([0ae9c25](https://github.com/parse-community/parse-server/commit/0ae9c25bc13847d547871511749b58b575b96333)) + ## [9.5.2-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.1...9.5.2-alpha.1) (2026-03-07) diff --git a/package-lock.json b/package-lock.json index 775d61e8e1..fe5eec96f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.1", + "version": "9.5.2-alpha.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.1", + "version": "9.5.2-alpha.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 441bd5e42f..c915742c90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.1", + "version": "9.5.2-alpha.2", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0a86d5cb91fa203ee8542d9baa699b60c25a22a5 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Mar 2026 23:46:18 +0000 Subject: [PATCH 005/156] ci: Performance benchmark job not failing on regression (#10132) --- .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 e83941f7cb..d353fd99d4 100644 --- a/.github/workflows/ci-performance.yml +++ b/.github/workflows/ci-performance.yml @@ -173,6 +173,7 @@ jobs: - name: Compare benchmark results id: compare run: | + set -o pipefail node -e " const fs = require('fs'); From 98188d92c0b05ef498fa066588da1740de047bde Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:15:08 +0000 Subject: [PATCH 006/156] fix: Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) (#10133) --- spec/ParseLiveQuery.spec.js | 84 +++++++++++++++++++++++++++ spec/ParseLiveQueryServer.spec.js | 54 +++++------------ src/LiveQuery/ParseLiveQueryServer.ts | 59 +++++++++++-------- 3 files changed, 133 insertions(+), 64 deletions(-) diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 7ce757a17b..48bc1504c8 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -1308,4 +1308,88 @@ describe('ParseLiveQuery', function () { await new Promise(resolve => setTimeout(resolve, 100)); expect(createSpy).toHaveBeenCalledTimes(1); }); + + describe('class level permissions', () => { + async function setPermissionsOnClass(className, permissions, doPut) { + const method = doPut ? 'PUT' : 'POST'; + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + classLevelPermissions: permissions, + }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } + + it('delivers LiveQuery event to authenticated client when CLP allows find', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['SecureChat'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('admin'); + user.setPassword('password'); + await user.signUp(); + + await setPermissionsOnClass('SecureChat', { + create: { '*': true }, + find: { [user.id]: true }, + }); + + // Subscribe as the authorized user + const query = new Parse.Query('SecureChat'); + const subscription = await query.subscribe(user.getSessionToken()); + + const spy = jasmine.createSpy('create'); + subscription.on('create', spy); + + const obj = new Parse.Object('SecureChat'); + obj.set('secret', 'data'); + await obj.save(null, { useMasterKey: true }); + + await sleep(500); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('rejects LiveQuery subscription when CLP denies find at subscription time', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['SecureChat'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('admin'); + user.setPassword('password'); + await user.signUp(); + + await setPermissionsOnClass('SecureChat', { + create: { '*': true }, + find: { [user.id]: true }, + }); + + // Log out so subscription is unauthenticated + await Parse.User.logOut(); + + const query = new Parse.Query('SecureChat'); + await expectAsync(query.subscribe()).toBeRejected(); + }); + }); }); diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index dd306ac1be..6287fc7642 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -1563,11 +1563,8 @@ describe('ParseLiveQueryServer', function () { }); describe('class level permissions', () => { - it('matches CLP when find is closed', done => { + it('rejects CLP when find is closed', async () => { const parseLiveQueryServer = new ParseLiveQueryServer({}); - const acl = new Parse.ACL(); - acl.setReadAccess(testUserId, true); - // Mock sessionTokenCache will return false when sessionToken is undefined const client = { sessionToken: 'sessionToken', getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ @@ -1576,27 +1573,19 @@ describe('ParseLiveQueryServer', function () { }; const requestId = 0; - parseLiveQueryServer - ._matchesCLP( - { - find: {}, - }, + await expectAsync( + parseLiveQueryServer._matchesCLP( + { find: {} }, { className: 'Yolo' }, client, requestId, 'find' ) - .then(isMatched => { - expect(isMatched).toBe(false); - done(); - }); + ).toBeRejected(); }); - it('matches CLP when find is open', done => { + it('resolves CLP when find is open', async () => { const parseLiveQueryServer = new ParseLiveQueryServer({}); - const acl = new Parse.ACL(); - acl.setReadAccess(testUserId, true); - // Mock sessionTokenCache will return false when sessionToken is undefined const client = { sessionToken: 'sessionToken', getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ @@ -1605,27 +1594,19 @@ describe('ParseLiveQueryServer', function () { }; const requestId = 0; - parseLiveQueryServer - ._matchesCLP( - { - find: { '*': true }, - }, + await expectAsync( + parseLiveQueryServer._matchesCLP( + { find: { '*': true } }, { className: 'Yolo' }, client, requestId, 'find' ) - .then(isMatched => { - expect(isMatched).toBe(true); - done(); - }); + ).toBeResolved(); }); - it('matches CLP when find is restricted to userIds', done => { + it('rejects CLP when find is restricted to userIds', async () => { const parseLiveQueryServer = new ParseLiveQueryServer({}); - const acl = new Parse.ACL(); - acl.setReadAccess(testUserId, true); - // Mock sessionTokenCache will return false when sessionToken is undefined const client = { sessionToken: 'sessionToken', getSubscriptionInfo: jasmine.createSpy('getSubscriptionInfo').and.returnValue({ @@ -1634,20 +1615,15 @@ describe('ParseLiveQueryServer', function () { }; const requestId = 0; - parseLiveQueryServer - ._matchesCLP( - { - find: { userId: true }, - }, + await expectAsync( + parseLiveQueryServer._matchesCLP( + { find: { userId: true } }, { className: 'Yolo' }, client, requestId, 'find' ) - .then(isMatched => { - expect(isMatched).toBe(false); - done(); - }); + ).toBeRejected(); }); }); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index ee9eb6a209..d8badaa913 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -20,6 +20,7 @@ import { } from '../triggers'; import { getAuthForSessionToken, Auth } from '../Auth'; import { getCacheController, getDatabaseController } from '../Controllers'; +import Config from '../Config'; import { LRUCache as LRU } from 'lru-cache'; import UserRouter from '../Routers/UsersRouter'; import DatabaseController from '../Controllers/DatabaseController'; @@ -590,39 +591,20 @@ class ParseLiveQueryServer { requestId?: number, op?: string ): Promise { - // try to match on user first, less expensive than with roles const subscriptionInfo = client.getSubscriptionInfo(requestId); const aclGroup = ['*']; - let userId; if (typeof subscriptionInfo !== 'undefined') { const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); if (userId) { aclGroup.push(userId); } } - try { - await SchemaController.validatePermission( - classLevelPermissions, - object.className, - aclGroup, - op - ); - return true; - } catch (e) { - logger.verbose(`Failed matching CLP for ${object.id} ${userId} ${e}`); - return false; - } - // TODO: handle roles permissions - // Object.keys(classLevelPermissions).forEach((key) => { - // const perm = classLevelPermissions[key]; - // Object.keys(perm).forEach((key) => { - // if (key.indexOf('role')) - // }); - // }) - // // it's rejected here, check the roles - // var rolesQuery = new Parse.Query(Parse.Role); - // rolesQuery.equalTo("users", user); - // return rolesQuery.find({useMasterKey:true}); + await SchemaController.validatePermission( + classLevelPermissions, + object.className, + aclGroup, + op + ); } async _filterSensitiveData( @@ -907,6 +889,33 @@ class ParseLiveQueryServer { return; } } + // Check CLP for subscribe operation + const appConfig = Config.get(this.config.appId); + const schemaController = await appConfig.database.loadSchema(); + const classLevelPermissions = schemaController.getClassLevelPermissions(className); + const op = this._getCLPOperation(request.query); + const aclGroup = ['*']; + if (!authCalled) { + const auth = await this.getAuthFromClient( + client, + request.requestId, + request.sessionToken + ); + authCalled = true; + if (auth && auth.user) { + request.user = auth.user; + aclGroup.push(auth.user.id); + } + } else if (request.user) { + aclGroup.push(request.user.id); + } + await SchemaController.validatePermission( + classLevelPermissions, + className, + aclGroup, + op + ); + // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); // Add className to subscriptions if necessary From 0538835605c67cac177fcc7da0d06500c0a46800 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 01:16:06 +0000 Subject: [PATCH 007/156] chore(release): 9.5.2-alpha.3 [skip ci] ## [9.5.2-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.2...9.5.2-alpha.3) (2026-03-08) ### Bug Fixes * Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) ([#10133](https://github.com/parse-community/parse-server/issues/10133)) ([98188d9](https://github.com/parse-community/parse-server/commit/98188d92c0b05ef498fa066588da1740de047bde)) --- 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 4d26d7e7c6..53acaf0e45 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.2...9.5.2-alpha.3) (2026-03-08) + + +### Bug Fixes + +* Bypass of class-level permissions in LiveQuery ([GHSA-7ch5-98q2-7289](https://github.com/parse-community/parse-server/security/advisories/GHSA-7ch5-98q2-7289)) ([#10133](https://github.com/parse-community/parse-server/issues/10133)) ([98188d9](https://github.com/parse-community/parse-server/commit/98188d92c0b05ef498fa066588da1740de047bde)) + ## [9.5.2-alpha.2](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.1...9.5.2-alpha.2) (2026-03-07) diff --git a/package-lock.json b/package-lock.json index fe5eec96f7..8dfe314cf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.2", + "version": "9.5.2-alpha.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.2", + "version": "9.5.2-alpha.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index c915742c90..19ff8a19bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.2", + "version": "9.5.2-alpha.3", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 93b784d21a8be13c6db1e8f0baeb0feda1fe12be Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:04:59 +0000 Subject: [PATCH 008/156] fix: Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) (#10136) --- spec/ParseFile.spec.js | 57 ++++++++++++++++++++++++++++++++++++++ src/Options/Definitions.js | 4 +-- src/Options/docs.js | 2 +- src/Options/index.js | 4 +-- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 7eb7e39b17..5fa13ce9a9 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -1461,6 +1461,63 @@ describe('Parse.File testing', () => { } }); + it('default should block SVG files', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const svgContent = Buffer.from('').toString('base64'); + for (const extension of ['svg', 'SVG', 'Svg']) { + await expectAsync( + request({ + method: 'POST', + headers: headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension ${extension} is disabled.`) + ); + } + }); + + it('default should block SVG content type without file extension', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + const svgContent = Buffer.from('').toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/file', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension svg+xml is disabled.`) + ); + }); + it('works with a period in the file name', async () => { await reconfigureServer({ fileUpload: { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index f6f2e6450b..fe1d67fda3 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -1059,9 +1059,9 @@ module.exports.FileUploadOptions = { }, fileExtensions: { env: 'PARSE_SERVER_FILE_UPLOAD_FILE_EXTENSIONS', - help: "Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?![xXsS]?[hH][tT][mM][lL]?$)` which allows any file extension except those MIME types that are mapped to `text/html` and are rendered as website by a web browser.", + help: "Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser.", action: parsers.arrayParser, - default: ['^(?![xXsS]?[hH][tT][mM][lL]?$)'], + default: ['^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)'], }, }; /* The available log levels for Parse Server logging. Valid values are:
- `'error'` - Error level (highest priority)
- `'warn'` - Warning level
- `'info'` - Info level (default)
- `'verbose'` - Verbose level
- `'debug'` - Debug level
- `'silly'` - Silly level (lowest priority) */ diff --git a/src/Options/docs.js b/src/Options/docs.js index 30d5722313..2b66fdb04d 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -248,7 +248,7 @@ * @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users. * @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users. * @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication. - * @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?![xXsS]?[hH][tT][mM][lL]?$)` which allows any file extension except those MIME types that are mapped to `text/html` and are rendered as website by a web browser. + * @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. */ /** diff --git a/src/Options/index.js b/src/Options/index.js index fc1d40d366..c23092485d 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -648,8 +648,8 @@ export interface PasswordPolicyOptions { } export interface FileUploadOptions { - /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML files are especially problematic as they may be used by an attacker who uploads a HTML form to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?![xXsS]?[hH][tT][mM][lL]?$)` which allows any file extension except those MIME types that are mapped to `text/html` and are rendered as website by a web browser. - :DEFAULT: ["^(?![xXsS]?[hH][tT][mM][lL]?$)"] */ + /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. + :DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)"] */ fileExtensions: ?(string[]); /* Is true if file upload should be allowed for anonymous users. :DEFAULT: false */ From da94750ee1add86a23ed6bbe94e0a46a673b1ff6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 02:05:58 +0000 Subject: [PATCH 009/156] chore(release): 9.5.2-alpha.4 [skip ci] ## [9.5.2-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.3...9.5.2-alpha.4) (2026-03-08) ### Bug Fixes * Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) ([#10136](https://github.com/parse-community/parse-server/issues/10136)) ([93b784d](https://github.com/parse-community/parse-server/commit/93b784d21a8be13c6db1e8f0baeb0feda1fe12be)) --- 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 53acaf0e45..6f77f01584 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.3...9.5.2-alpha.4) (2026-03-08) + + +### Bug Fixes + +* Stored cross-site scripting (XSS) via SVG file upload ([GHSA-hcj7-6gxh-24ww](https://github.com/parse-community/parse-server/security/advisories/GHSA-hcj7-6gxh-24ww)) ([#10136](https://github.com/parse-community/parse-server/issues/10136)) ([93b784d](https://github.com/parse-community/parse-server/commit/93b784d21a8be13c6db1e8f0baeb0feda1fe12be)) + ## [9.5.2-alpha.3](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.2...9.5.2-alpha.3) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index 8dfe314cf4..99f5db3af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.3", + "version": "9.5.2-alpha.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.3", + "version": "9.5.2-alpha.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 19ff8a19bf..6be27a03c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.3", + "version": "9.5.2-alpha.4", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 78ef1a175d3b8da83d33fd5c69830b12d366212f Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 03:50:10 +0000 Subject: [PATCH 010/156] fix: Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) (#10137) --- spec/AuthenticationAdapters.spec.js | 394 +++++++++++++++++++++++----- src/Adapters/Auth/keycloak.js | 119 ++++++--- 2 files changed, 400 insertions(+), 113 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index bb59eefc8c..f7f3d8d45e 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -659,7 +659,8 @@ describe('google auth adapter', () => { describe('keycloak auth adapter', () => { const keycloak = require('../lib/Adapters/Auth/keycloak'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + const jwt = require('jsonwebtoken'); + const authUtils = require('../lib/Adapters/Auth/utils'); it('validateAuthData should fail without access token', async () => { const authData = { @@ -704,17 +705,12 @@ describe('keycloak auth adapter', () => { } }); - it('validateAuthData should fail connect error', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.reject({ - text: JSON.stringify({ error: 'hosting_error' }), - }); - }); + it('validateAuthData should fail without client-id', async () => { const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', }, }, }; @@ -727,86 +723,172 @@ describe('keycloak auth adapter', () => { await adapter.validateAuthData(authData, providerOptions); fail(); } catch (e) { - expect(e.message).toBe('Could not connect to the authentication server'); + expect(e.message).toBe('Keycloak auth is not configured. Missing client-id.'); } }); - it('validateAuthData should fail with error description', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.reject({ - text: JSON.stringify({ error_description: 'custom error message' }), - }); - }); + it('validateAuthData should fail with invalid JWT token', async () => { const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'not-a-jwt', }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { await adapter.validateAuthData(authData, providerOptions); fail(); } catch (e) { - expect(e.message).toBe('custom error message'); + expect(e.message).toBe('provided token does not decode as JWT'); } }); - it('validateAuthData should fail with invalid auth', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({}); - }); + it('validateAuthData should fail with wrong issuer', async () => { + const fakeClaim = { + iss: 'https://evil.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { await adapter.validateAuthData(authData, providerOptions); fail(); } catch (e) { - expect(e.message).toBe('Invalid authentication'); + expect(e.message).toBe( + 'access token not issued by correct provider - expected: https://auth.example.com/realms/my-realm | from: https://evil.example.com/realms/my-realm' + ); } }); - it('validateAuthData should fail with invalid groups', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ - data: { - sub: 'fakeid', - roles: ['role1'], - groups: ['unknown'], + it('validateAuthData should fail with wrong azp (audience)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'other-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, - }); - }); + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe( + 'access token is not authorized for this client - expected: parse-app | from: other-app' + ); + } + }); + + it('validateAuthData should fail with wrong sub', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'wrong-id', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('validateAuthData should fail with invalid roles (JWT validation)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, roles: ['role1'], groups: ['group1'], }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + roles: ['wrong-role'], + groups: ['group1'], + }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { await adapter.validateAuthData(authData, providerOptions); @@ -816,29 +898,35 @@ describe('keycloak auth adapter', () => { } }); - it('validateAuthData should fail with invalid roles', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ - data: { - sub: 'fakeid', - roles: 'unknown', - groups: ['group1'], - }, - }); - }); + it('validateAuthData should fail with invalid groups (JWT validation)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['role1'], + groups: ['group1'], + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', roles: ['role1'], - groups: ['group1'], + groups: ['wrong-group'], }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { @@ -849,39 +937,201 @@ describe('keycloak auth adapter', () => { } }); - it('validateAuthData should handle authentication', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ - data: { - sub: 'fakeid', - roles: ['role1'], - groups: ['group1'], - }, - }); - }); + it('validateAuthData should handle successful authentication', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['role1'], + groups: ['group1'], + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', roles: ['role1'], groups: ['group1'], }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); await adapter.validateAuthData(authData, providerOptions); - expect(httpsRequest.get).toHaveBeenCalledWith({ - host: 'http://example.com', - path: '/realms/new/protocol/openid-connect/userinfo', - headers: { - Authorization: 'Bearer sometoken', + expect(jwt.verify).toHaveBeenCalled(); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('validateAuthData should handle successful authentication without roles and groups', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + await adapter.validateAuthData(authData, providerOptions); + expect(jwt.verify).toHaveBeenCalled(); + }); + + it('validateAuthData should use hardcoded RS256 algorithm, not JWT header alg', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + await adapter.validateAuthData(authData, providerOptions); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('validateAuthData should verify a real signed JWT end-to-end', async () => { + const crypto = require('crypto'); + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const token = jwt.sign( + { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'user123', + roles: ['admin'], + groups: ['staff'], + }, + privateKey, + { algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' } + ); + + // Only mock the JWKS key fetch — jwt.verify runs for real + spyOn(authUtils, 'getSigningKey').and.resolveTo({ + kid: 'test-key-1', + publicKey: publicKey, }); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'user123', + access_token: token, + roles: ['admin'], + groups: ['staff'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + const result = await adapter.validateAuthData(authData, providerOptions); + expect(result.sub).toBe('user123'); + expect(result.azp).toBe('parse-app'); + expect(result.iss).toBe('https://auth.example.com/realms/my-realm'); + }); + + it('validateAuthData should reject a JWT signed with a different key', async () => { + const crypto = require('crypto'); + const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const { publicKey: differentPublicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const token = jwt.sign( + { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'user123', + }, + privateKey, + { algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' } + ); + + // Return a different public key — signature verification should fail + spyOn(authUtils, 'getSigningKey').and.resolveTo({ + kid: 'test-key-1', + publicKey: differentPublicKey, + }); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'user123', + access_token: token, + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('invalid signature'); + } }); }); diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js index 457faeeaed..f3b4db37f9 100644 --- a/src/Adapters/Auth/keycloak.js +++ b/src/Adapters/Auth/keycloak.js @@ -67,7 +67,9 @@ */ const { Parse } = require('parse/node'); -const httpsRequest = require('./httpsRequest'); +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const authUtils = require('./utils'); const arraysEqual = (_arr1, _arr2) => { if (!Array.isArray(_arr1) || !Array.isArray(_arr2) || _arr1.length !== _arr2.length) { return false; } @@ -82,61 +84,96 @@ const arraysEqual = (_arr1, _arr2) => { return true; }; -const handleAuth = async ({ access_token, id, roles, groups } = {}, { config } = {}) => { +const getKeycloakKeyByKeyId = async (keyId, jwksUri, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri, + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; + +const verifyAccessToken = async ( + { access_token, id, roles, groups } = {}, + { config, cacheMaxEntries, cacheMaxAge } = {} +) => { if (!(access_token && id)) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing access token and/or User id'); } if (!config || !(config['auth-server-url'] && config['realm'])) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Missing keycloak configuration'); } + if (!config['client-id']) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Keycloak auth is not configured. Missing client-id.' + ); + } + + const expectedIssuer = `${config['auth-server-url']}/realms/${config['realm']}`; + const jwksUri = `${config['auth-server-url']}/realms/${config['realm']}/protocol/openid-connect/certs`; + + const { kid: keyId } = authUtils.getHeaderFromToken(access_token); + const ONE_HOUR_IN_MS = 3600000; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const keycloakKey = await getKeycloakKeyByKeyId(keyId, jwksUri, cacheMaxEntries, cacheMaxAge); + const signingKey = keycloakKey.publicKey || keycloakKey.rsaPublicKey; + + let jwtClaims; try { - const response = await httpsRequest.get({ - host: config['auth-server-url'], - path: `/realms/${config['realm']}/protocol/openid-connect/userinfo`, - headers: { - Authorization: 'Bearer ' + access_token, - }, + jwtClaims = jwt.verify(access_token, signingKey, { + algorithms: ['RS256'], }); - if ( - response && - response.data && - response.data.sub == id && - arraysEqual(response.data.roles, roles) && - arraysEqual(response.data.groups, groups) - ) { - return; - } + } catch (exception) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${exception.message}`); + } + + if (jwtClaims.iss !== expectedIssuer) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `access token not issued by correct provider - expected: ${expectedIssuer} | from: ${jwtClaims.iss}` + ); + } + + if (jwtClaims.azp !== config['client-id']) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `access token is not authorized for this client - expected: ${config['client-id']} | from: ${jwtClaims.azp}` + ); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.'); + } + + const rolesMatch = jwtClaims.roles === roles || arraysEqual(jwtClaims.roles, roles); + const groupsMatch = jwtClaims.groups === groups || arraysEqual(jwtClaims.groups, groups); + + if (!rolesMatch || !groupsMatch) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid authentication'); - } catch (e) { - if (e instanceof Parse.Error) { - throw e; - } - const error = JSON.parse(e.text); - if (error.error_description) { - throw new Parse.Error(Parse.Error.HOSTING_ERROR, error.error_description); - } else { - throw new Parse.Error( - Parse.Error.HOSTING_ERROR, - 'Could not connect to the authentication server' - ); - } } + + return jwtClaims; }; -/* - @param {Object} authData: the client provided authData - @param {string} authData.access_token: the access_token retrieved from client authentication in Keycloak - @param {string} authData.id: the id retrieved from client authentication in Keycloak - @param {Array} authData.roles: the roles retrieved from client authentication in Keycloak - @param {Array} authData.groups: the groups retrieved from client authentication in Keycloak - @param {Object} options: additional options - @param {Object} options.config: the config object passed during Parse Server instantiation -*/ function validateAuthData(authData, options = {}) { - return handleAuth(authData, options); + return verifyAccessToken(authData, options); } -// Returns a promise that fulfills if this app id is valid. function validateAppId() { return Promise.resolve(); } From e9b020b233d811186072447e5e255580e54a71f9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 03:51:00 +0000 Subject: [PATCH 011/156] chore(release): 9.5.2-alpha.5 [skip ci] ## [9.5.2-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.4...9.5.2-alpha.5) (2026-03-08) ### Bug Fixes * Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) ([#10137](https://github.com/parse-community/parse-server/issues/10137)) ([78ef1a1](https://github.com/parse-community/parse-server/commit/78ef1a175d3b8da83d33fd5c69830b12d366212f)) --- 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 6f77f01584..778be064d0 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.4...9.5.2-alpha.5) (2026-03-08) + + +### Bug Fixes + +* Missing audience validation in Keycloak authentication adapter ([GHSA-48mh-j4p5-7j9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-48mh-j4p5-7j9v)) ([#10137](https://github.com/parse-community/parse-server/issues/10137)) ([78ef1a1](https://github.com/parse-community/parse-server/commit/78ef1a175d3b8da83d33fd5c69830b12d366212f)) + ## [9.5.2-alpha.4](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.3...9.5.2-alpha.4) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index 99f5db3af6..c4c0af00a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.4", + "version": "9.5.2-alpha.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.4", + "version": "9.5.2-alpha.5", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 6be27a03c6..13f92cd2f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.4", + "version": "9.5.2-alpha.5", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From be1d65dac5d2718491e38727f96f205e43463e4c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:25:22 +0000 Subject: [PATCH 012/156] fix: Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) (#10140) --- spec/ProtectedFields.spec.js | 137 +++++++++++++++++++++++++++++++++++ src/RestQuery.js | 27 +++++-- 2 files changed, 156 insertions(+), 8 deletions(-) diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js index 8195985dcb..ba5eb96e2f 100644 --- a/spec/ProtectedFields.spec.js +++ b/spec/ProtectedFields.spec.js @@ -1700,4 +1700,141 @@ describe('ProtectedFields', function () { done(); }); }); + + describe('query on protected fields via logical operators', function () { + let user; + let otherUser; + const testEmail = 'victim@example.com'; + const otherEmail = 'other@example.com'; + + beforeEach(async function () { + await reconfigureServer({ + protectedFields: { + _User: { '*': ['email'] }, + }, + }); + user = new Parse.User(); + user.setUsername('victim' + Date.now()); + user.setPassword('password'); + user.setEmail(testEmail); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user.setACL(acl); + await user.save(null, { useMasterKey: true }); + + otherUser = new Parse.User(); + otherUser.setUsername('attacker' + Date.now()); + otherUser.setPassword('password'); + otherUser.setEmail(otherEmail); + const acl2 = new Parse.ACL(); + acl2.setPublicReadAccess(true); + otherUser.setACL(acl2); + await otherUser.save(null, { useMasterKey: true }); + await Parse.User.logIn(otherUser.getUsername(), 'password'); + }); + + it('should deny query on protected field via $or', async function () { + const q1 = new Parse.Query(Parse.User); + q1.equalTo('email', testEmail); + const query = Parse.Query.or(q1); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via $and', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $and: [{ email: testEmail }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via $nor', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $nor: [{ email: testEmail }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via nested $or inside $and', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $and: [{ $or: [{ email: testEmail }] }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field via $or with $regex', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $or: [{ email: { $regex: '^victim' } }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should allow $or query on non-protected fields', async function () { + const q1 = new Parse.Query(Parse.User); + q1.equalTo('username', user.getUsername()); + const query = Parse.Query.or(q1); + const results = await query.find(); + expect(results.length).toBe(1); + expect(results[0].id).toBe(user.id); + }); + + it('should allow master key to query on protected fields via $or', async function () { + const q1 = new Parse.Query(Parse.User); + q1.equalTo('email', testEmail); + const query = Parse.Query.or(q1); + const results = await query.find({ useMasterKey: true }); + expect(results.length).toBe(1); + expect(results[0].id).toBe(user.id); + }); + + it('should deny query on protected field with falsy value', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { email: null } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should deny query on protected field with falsy value via $or', async function () { + const query = new Parse.Query(Parse.User); + query.withJSON({ where: { $or: [{ email: null }] } }); + await expectAsync(query.find()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('should not throw TypeError in denyProtectedFields for null element in $or', async function () { + const Config = require('../lib/Config'); + const authModule = require('../lib/Auth'); + const RestQuery = require('../lib/RestQuery'); + const config = Config.get(Parse.applicationId); + const restQuery = await RestQuery({ + method: RestQuery.Method.find, + config, + auth: authModule.nobody(config), + className: '_User', + restWhere: { $or: [null, { username: 'test' }] }, + }); + await expectAsync(restQuery.denyProtectedFields()).toBeResolved(); + }); + }); }); diff --git a/src/RestQuery.js b/src/RestQuery.js index 4c64801ee7..c335b1e633 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -847,15 +847,26 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () { this.auth, this.findOptions ) || []; - for (const key of protectedFields) { - if (this.restWhere[key]) { - throw createSanitizedError( - Parse.Error.OPERATION_FORBIDDEN, - `This user is not allowed to query ${key} on class ${this.className}`, - this.config - ); + const checkWhere = (where) => { + if (typeof where !== 'object' || where === null) { + return; } - } + for (const key of protectedFields) { + if (key in where) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `This user is not allowed to query ${key} on class ${this.className}`, + this.config + ); + } + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + where[op].forEach(subQuery => checkWhere(subQuery)); + } + } + }; + checkWhere(this.restWhere); }; // Augments this.response with all pointers on an object From 189f15e6919acfad370da7b36cf4fc82f2c1ffdc Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 15:26:15 +0000 Subject: [PATCH 013/156] chore(release): 9.5.2-alpha.6 [skip ci] ## [9.5.2-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.5...9.5.2-alpha.6) (2026-03-08) ### Bug Fixes * Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) ([#10140](https://github.com/parse-community/parse-server/issues/10140)) ([be1d65d](https://github.com/parse-community/parse-server/commit/be1d65dac5d2718491e38727f96f205e43463e4c)) --- 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 778be064d0..09e3b403a6 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.5...9.5.2-alpha.6) (2026-03-08) + + +### Bug Fixes + +* Protected fields bypass via logical query operators ([GHSA-72hp-qff8-4pvv](https://github.com/parse-community/parse-server/security/advisories/GHSA-72hp-qff8-4pvv)) ([#10140](https://github.com/parse-community/parse-server/issues/10140)) ([be1d65d](https://github.com/parse-community/parse-server/commit/be1d65dac5d2718491e38727f96f205e43463e4c)) + ## [9.5.2-alpha.5](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.4...9.5.2-alpha.5) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index c4c0af00a5..1e67d7c839 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.5", + "version": "9.5.2-alpha.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.5", + "version": "9.5.2-alpha.6", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 13f92cd2f6..1f2b11d3c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.5", + "version": "9.5.2-alpha.6", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 22faa08a7b89b15c3c96da2af9387bd44cbec088 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:26:41 +0000 Subject: [PATCH 014/156] fix: Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) (#10141) --- spec/helper.js | 3 ++ spec/rest.spec.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++ src/SharedRest.js | 9 ++++ 3 files changed, 123 insertions(+) diff --git a/spec/helper.js b/spec/helper.js index 28fb4afa80..d6df39e851 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -258,6 +258,9 @@ global.afterEachFn = async () => { if (!className.startsWith('_')) { return true; } + if (className.startsWith('_Join:')) { + return true; + } return [ '_User', '_Installation', diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 684a3633fb..25a092751b 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -953,6 +953,117 @@ describe('rest update', () => { }); }); +describe('_Join table security', () => { + let config; + + beforeEach(() => { + config = Config.get('test'); + }); + + it('cannot create object in _Join table without masterKey', () => { + expect(() => + rest.create(config, auth.nobody(config), '_Join:users:_Role', { + relatedId: 'someUserId', + owningId: 'someRoleId', + }) + ).toThrowError(/Permission denied/); + }); + + it('cannot find objects in _Join table without masterKey', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_Join:users:_Role', {}) + ).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('cannot update object in _Join table without masterKey', () => { + expect(() => + rest.update(config, auth.nobody(config), '_Join:users:_Role', { relatedId: 'someUserId' }, { owningId: 'newRoleId' }) + ).toThrowError(/Permission denied/); + }); + + it('cannot delete object in _Join table without masterKey', () => { + expect(() => + rest.del(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId') + ).toThrowError(/Permission denied/); + }); + + it('cannot get object in _Join table without masterKey', async () => { + await expectAsync( + rest.get(config, auth.nobody(config), '_Join:users:_Role', 'someObjectId') + ).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.OPERATION_FORBIDDEN, + }) + ); + }); + + it('can find objects in _Join table with masterKey', async () => { + await expectAsync( + rest.find(config, auth.master(config), '_Join:users:_Role', {}) + ).toBeResolved(); + }); + + it('can find objects in _Join table with maintenance key', async () => { + await expectAsync( + rest.find(config, auth.maintenance(config), '_Join:users:_Role', {}) + ).toBeResolved(); + }); + + it('legitimate relation operations still work', async () => { + const role = new Parse.Role('admin', new Parse.ACL()); + const user = await Parse.User.signUp('testuser', 'password123'); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + const result = await rest.find(config, auth.master(config), '_Join:users:_Role', {}); + expect(result.results.length).toBe(1); + }); + + it('blocks _Join table access for any relation, not just _Role', () => { + expect(() => + rest.create(config, auth.nobody(config), '_Join:viewers:ConfidentialDoc', { + relatedId: 'someUserId', + owningId: 'someDocId', + }) + ).toThrowError(/Permission denied/); + }); + + it('cannot escalate role via direct _Join table write', async () => { + const role = new Parse.Role('superadmin', new Parse.ACL()); + await role.save(null, { useMasterKey: true }); + const user = await Parse.User.signUp('attacker', 'password123'); + const sessionToken = user.getSessionToken(); + const userAuth = await auth.getAuthForSessionToken({ + config, + sessionToken, + }); + expect(() => + rest.create(config, userAuth, '_Join:users:_Role', { + relatedId: user.id, + owningId: role.id, + }) + ).toThrowError(/Permission denied/); + }); + + it('cannot write to _Join table with read-only masterKey', () => { + expect(() => + rest.create(config, auth.readOnly(config), '_Join:users:_Role', { + relatedId: 'someUserId', + owningId: 'someRoleId', + }) + ).toThrowError(/Permission denied/); + }); + + it('can read _Join table with read-only masterKey', async () => { + await expectAsync( + rest.find(config, auth.readOnly(config), '_Join:users:_Role', {}) + ).toBeResolved(); + }); +}); + describe('read-only masterKey', () => { let loggerErrorSpy; let logger; diff --git a/src/SharedRest.js b/src/SharedRest.js index 3dc396d30c..341babaf3e 100644 --- a/src/SharedRest.js +++ b/src/SharedRest.js @@ -33,6 +33,15 @@ function enforceRoleSecurity(method, className, auth, config) { ); } + // _Join tables are internal and must only be modified through relation operations + if (className.startsWith('_Join:') && !auth.isMaster && !auth.isMaintenance) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `Clients aren't allowed to perform the ${method} operation on the ${className} collection.`, + config + ); + } + // readOnly masterKey is not allowed if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) { throw createSanitizedError( From c2ca77a5819921fcd4f66f5c32c509886c134a92 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 16:27:31 +0000 Subject: [PATCH 015/156] chore(release): 9.5.2-alpha.7 [skip ci] ## [9.5.2-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.6...9.5.2-alpha.7) (2026-03-08) ### Bug Fixes * Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) ([#10141](https://github.com/parse-community/parse-server/issues/10141)) ([22faa08](https://github.com/parse-community/parse-server/commit/22faa08a7b89b15c3c96da2af9387bd44cbec088)) --- 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 09e3b403a6..7c7532242e 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.6...9.5.2-alpha.7) (2026-03-08) + + +### Bug Fixes + +* Parse Server role escalation and CLP bypass via direct `_Join table write ([GHSA-5f92-jrq3-28rc](https://github.com/parse-community/parse-server/security/advisories/GHSA-5f92-jrq3-28rc)) ([#10141](https://github.com/parse-community/parse-server/issues/10141)) ([22faa08](https://github.com/parse-community/parse-server/commit/22faa08a7b89b15c3c96da2af9387bd44cbec088)) + ## [9.5.2-alpha.6](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.5...9.5.2-alpha.6) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index 1e67d7c839..5197c9fc3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.6", + "version": "9.5.2-alpha.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.6", + "version": "9.5.2-alpha.7", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 1f2b11d3c9..c825405e57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.6", + "version": "9.5.2-alpha.7", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 70b7b070e1135949dd80ecf382f34db0bfdbb71e Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:22:36 +0000 Subject: [PATCH 016/156] fix: Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) (#10143) --- spec/RestQuery.spec.js | 101 +++++++++++++++++++++++++++++++++++++++++ src/RestQuery.js | 29 ++++++++++++ 2 files changed, 130 insertions(+) diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 9418f856fd..ccf898852b 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -614,3 +614,104 @@ describe('RestQuery.each', () => { ]); }); }); + +describe('redirectClassNameForKey security', () => { + let config; + + beforeEach(() => { + config = Config.get('test'); + }); + + it('should scope _Session results to the current user when redirected via redirectClassNameForKey', async () => { + // Create two users with sessions (without logging out, to preserve sessions) + const user1 = await Parse.User.signUp('user1', 'password1'); + const sessionToken1 = user1.getSessionToken(); + + // Sign up user2 via REST to avoid logging out user1 + await request({ + method: 'POST', + url: Parse.serverURL + '/users', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { username: 'user2', password: 'password2' }, + }); + + // Create a public class with a relation field pointing to _Session + // (using masterKey to create the object and relation schema) + const obj = new Parse.Object('PublicData'); + const relation = obj.relation('pivot'); + // Add a fake pointer to _Session to establish the relation schema + relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' })); + await obj.save(null, { useMasterKey: true }); + + // Authenticated user queries with redirectClassNameForKey + const userAuth = await auth.getAuthForSessionToken({ + config, + sessionToken: sessionToken1, + }); + const result = await rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'pivot' }); + + // Should only see user1's own session, not user2's + expect(result.results.length).toBe(1); + expect(result.results[0].user.objectId).toBe(user1.id); + }); + + it('should reject unauthenticated access to _Session via redirectClassNameForKey', async () => { + // Create a user so a session exists + await Parse.User.signUp('victim', 'password123'); + await Parse.User.logOut(); + + // Create a public class with a relation to _Session + const obj = new Parse.Object('PublicData'); + const relation = obj.relation('pivot'); + relation.add(Parse.Object.fromJSON({ className: '_Session', objectId: 'fakeId' })); + await obj.save(null, { useMasterKey: true }); + + // Unauthenticated query with redirectClassNameForKey + await expectAsync( + rest.find(config, auth.nobody(config), 'PublicData', {}, { redirectClassNameForKey: 'pivot' }) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.INVALID_SESSION_TOKEN }) + ); + }); + + it('should block redirectClassNameForKey to master-only classes', async () => { + // Create a public class with a relation to _JobStatus (master-only) + const obj = new Parse.Object('PublicData'); + const relation = obj.relation('jobPivot'); + relation.add(Parse.Object.fromJSON({ className: '_JobStatus', objectId: 'fakeId' })); + await obj.save(null, { useMasterKey: true }); + + // Create a user for authenticated access + const user = await Parse.User.signUp('attacker', 'password123'); + const sessionToken = user.getSessionToken(); + const userAuth = await auth.getAuthForSessionToken({ config, sessionToken }); + + // Authenticated query should be blocked + await expectAsync( + rest.find(config, userAuth, 'PublicData', {}, { redirectClassNameForKey: 'jobPivot' }) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it('should allow redirectClassNameForKey between regular classes', async () => { + // Create target class objects + const wheel1 = new Parse.Object('Wheel'); + await wheel1.save(); + + // Create source class with relation to Wheel + const car = new Parse.Object('Car'); + const relation = car.relation('wheels'); + relation.add(wheel1); + await car.save(); + + // Query with redirectClassNameForKey should work normally + const result = await rest.find(config, auth.nobody(config), 'Car', {}, { redirectClassNameForKey: 'wheels' }); + expect(result.results.length).toBe(1); + expect(result.results[0].objectId).toBe(wheel1.id); + }); +}); diff --git a/src/RestQuery.js b/src/RestQuery.js index c335b1e633..f80c8f570c 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -414,6 +414,35 @@ _UnsafeRestQuery.prototype.redirectClassNameForKey = function () { .then(newClassName => { this.className = newClassName; this.redirectClassName = newClassName; + + // Re-apply security checks for the redirected class name, since the + // checks in the constructor and in rest.find ran against the original + // class name before the redirect. + if (!this.auth.isMaster) { + enforceRoleSecurity('find', this.className, this.auth, this.config); + + if (this.className === '_Session') { + if (!this.auth.user) { + throw createSanitizedError( + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token', + this.config + ); + } + this.restWhere = { + $and: [ + this.restWhere, + { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id, + }, + }, + ], + }; + } + } }); }; From 6576a19aeed16d2b64d47a2c7a3c02dc11442d5e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 17:23:34 +0000 Subject: [PATCH 017/156] chore(release): 9.5.2-alpha.8 [skip ci] ## [9.5.2-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.7...9.5.2-alpha.8) (2026-03-08) ### Bug Fixes * Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) ([#10143](https://github.com/parse-community/parse-server/issues/10143)) ([70b7b07](https://github.com/parse-community/parse-server/commit/70b7b070e1135949dd80ecf382f34db0bfdbb71e)) --- 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 7c7532242e..48c89a033d 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.7...9.5.2-alpha.8) (2026-03-08) + + +### Bug Fixes + +* Parse Server session token exfiltration via `redirectClassNameForKey` query parameter ([GHSA-6r2j-cxgf-495f](https://github.com/parse-community/parse-server/security/advisories/GHSA-6r2j-cxgf-495f)) ([#10143](https://github.com/parse-community/parse-server/issues/10143)) ([70b7b07](https://github.com/parse-community/parse-server/commit/70b7b070e1135949dd80ecf382f34db0bfdbb71e)) + ## [9.5.2-alpha.7](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.6...9.5.2-alpha.7) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index 5197c9fc3c..21a85b6896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.7", + "version": "9.5.2-alpha.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.7", + "version": "9.5.2-alpha.8", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index c825405e57..5906c91f3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.7", + "version": "9.5.2-alpha.8", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 9cfd06e0d055ba96f965a0684995807adfe32b75 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:27:06 +0000 Subject: [PATCH 018/156] fix: Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) (#10145) --- spec/Adapters/Auth/oauth2.spec.js | 107 ++++++++++++++++++++++++++++++ src/Adapters/Auth/oauth2.js | 4 +- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/spec/Adapters/Auth/oauth2.spec.js b/spec/Adapters/Auth/oauth2.spec.js index 4dff1219ee..7ed9f3e047 100644 --- a/spec/Adapters/Auth/oauth2.spec.js +++ b/spec/Adapters/Auth/oauth2.spec.js @@ -128,6 +128,62 @@ describe('OAuth2Adapter', () => { adapter.validateAuthData(authData, null, validOptions) ).toBeRejectedWithError('OAuth2 access token is invalid for this user.'); }); + + it('should default useridField to sub and reject mismatched user ID', async () => { + const adapterNoUseridField = new OAuth2Adapter.constructor(); + adapterNoUseridField.validateOptions({ + tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect', + }); + + const authData = { id: 'victim-user-id', access_token: 'attackerToken' }; + const mockResponse = { + active: true, + sub: 'attacker-user-id', + }; + + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapterNoUseridField.validateAuthData(authData, null, {}) + ).toBeRejectedWithError('OAuth2 access token is invalid for this user.'); + }); + + it('should default useridField to sub and accept matching user ID', async () => { + const adapterNoUseridField = new OAuth2Adapter.constructor(); + adapterNoUseridField.validateOptions({ + tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect', + }); + + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { + active: true, + sub: 'user-id', + }; + + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapterNoUseridField.validateAuthData(authData, null, {}) + ).toBeResolvedTo({}); + }); }); describe('requestTokenInfo', () => { @@ -281,6 +337,57 @@ describe('OAuth2Adapter', () => { ); }); + it('should reject account takeover when useridField is omitted and attacker uses their own token with victim ID', async () => { + await reconfigureServer({ + auth: { + mockOauth: { + tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect', + authorizationHeader: 'Bearer validAuthToken', + oauth2: true, + }, + }, + }); + + // Victim signs up with their own valid token + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'victim-sub-id', + }), + }, + }, + ]); + + const victimAuthData = { access_token: 'victimToken', id: 'victim-sub-id' }; + const victim = await Parse.User.logInWith('mockOauth', { authData: victimAuthData }); + expect(victim.id).toBeDefined(); + + // Attacker tries to log in with their own valid token but claims victim's ID + mockFetch([ + { + url: 'https://provider.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'attacker-sub-id', + }), + }, + }, + ]); + + const attackerAuthData = { access_token: 'attackerToken', id: 'victim-sub-id' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData: attackerAuthData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.') + ); + }); + it('should handle error when token introspection endpoint is missing', async () => { await reconfigureServer({ auth: { diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index 1498f8bf4e..e9e7d97379 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -5,7 +5,7 @@ * @param {Object} options - The adapter configuration options. * @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required. * @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required. - * @param {string} [options.useridField] - The field in the introspection response that contains the user ID. Optional. + * @param {string} [options.useridField='sub'] - The field in the introspection response that contains the user ID. Defaults to `sub` per RFC 7662. * @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional. * @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined. * @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional. @@ -66,7 +66,7 @@ class OAuth2Adapter extends AuthAdapter { } this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl; - this.useridField = options.useridField; + this.useridField = options.useridField || 'sub'; this.appidField = options.appidField; this.appIds = options.appIds; this.authorizationHeader = options.authorizationHeader; From e236ddfe897be1e4eec134f60a99a2abcccbe8c2 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 18:27:57 +0000 Subject: [PATCH 019/156] chore(release): 9.5.2-alpha.9 [skip ci] ## [9.5.2-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.8...9.5.2-alpha.9) (2026-03-08) ### Bug Fixes * Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10145](https://github.com/parse-community/parse-server/issues/10145)) ([9cfd06e](https://github.com/parse-community/parse-server/commit/9cfd06e0d055ba96f965a0684995807adfe32b75)) --- 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 48c89a033d..54898f2e38 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.8...9.5.2-alpha.9) (2026-03-08) + + +### Bug Fixes + +* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10145](https://github.com/parse-community/parse-server/issues/10145)) ([9cfd06e](https://github.com/parse-community/parse-server/commit/9cfd06e0d055ba96f965a0684995807adfe32b75)) + ## [9.5.2-alpha.8](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.7...9.5.2-alpha.8) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index 21a85b6896..c6edbc6f93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.8", + "version": "9.5.2-alpha.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.8", + "version": "9.5.2-alpha.9", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 5906c91f3c..00b1a41937 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.8", + "version": "9.5.2-alpha.9", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 2766f4f7a2ce3afde4e1628907cdc556b6d6355c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:30:45 +0000 Subject: [PATCH 020/156] fix: Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) (#10147) --- spec/RateLimit.spec.js | 221 +++++++++++++++++++++++++++++++++++++ src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- src/batch.js | 43 ++++++++ src/middlewares.js | 5 + 6 files changed, 272 insertions(+), 3 deletions(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 429b7aaed2..f482f7a700 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -1,4 +1,12 @@ const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default; +const request = require('../lib/request'); + +const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', +}; + describe('rate limit', () => { it('can limit cloud functions', async () => { Parse.Cloud.define('test', () => 'Abc'); @@ -487,6 +495,219 @@ describe('rate limit', () => { }) ).toBeRejectedWith(`Invalid rate limit option "path"`); }); + describe('batch', () => { + it('should reject batch request when sub-requests exceed rate limit for a path', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }).catch(e => e); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow batch request when sub-requests are within rate limit', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 5, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + + it('should reject batch when sub-requests for one rate-limited path exceed limit among mixed paths', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + await Parse.User.signUp('testuser', 'password'); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + { method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'wrong' } }, + ], + }), + }).catch(e => e); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many login requests', + }); + }); + + it('should not count sub-requests whose method does not match requestMethods', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'GET', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // 3 POST sub-requests should NOT be counted against a GET-only rate limit + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + + it('should skip batch rate limit check for master key requests when includeMasterKey is false', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // Master key requests should bypass rate limit (includeMasterKey defaults to false) + const masterHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }; + const response = await request({ + method: 'POST', + headers: masterHeaders, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + + it('should use configured errorResponseMessage when rejecting batch', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Custom rate limit message', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }).catch(e => e); + expect(response.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Custom rate limit message', + }); + }); + + it('should not reject batch when sub-requests target non-rate-limited paths', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/login', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many login requests', + includeInternalRequests: true, + }, + ], + }); + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }); + expect(response.data.length).toBe(3); + expect(response.data[0].success).toBeDefined(); + }); + }); + describe_only(() => { return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; })('with RedisCache', function () { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index fe1d67fda3..fd0d0a68ca 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -637,7 +637,7 @@ module.exports.RateLimitOptions = { }, requestCount: { env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_COUNT', - help: 'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied.', + help: 'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.', action: parsers.numberParser('requestCount'), }, requestMethods: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 2b66fdb04d..95e1cb49f3 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -120,7 +120,7 @@ * @property {Boolean} includeInternalRequests Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks. * @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. * @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests. - * @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. + * @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting. * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax. * @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. diff --git a/src/Options/index.js b/src/Options/index.js index c23092485d..50fe177adf 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -387,7 +387,7 @@ export interface RateLimitOptions { requestPath: string; /* The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. */ requestTimeWindow: ?number; - /* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. */ + /* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting. */ requestCount: ?number; /* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`. :DEFAULT: Too many requests. */ diff --git a/src/batch.js b/src/batch.js index ca1bea621a..b5acfee139 100644 --- a/src/batch.js +++ b/src/batch.js @@ -83,6 +83,49 @@ function handleBatch(router, req) { req.config.publicServerURL ); + // Check if batch sub-requests would exceed any configured rate limits. + // Count how many sub-requests target each rate-limited path and reject + // the entire batch if any path's count exceeds its requestCount. + const rateLimits = req.config.rateLimits || []; + for (const limit of rateLimits) { + // Skip rate limit if master key is used and includeMasterKey is not set + if (req.auth?.isMaster && !limit.includeMasterKey) { + continue; + } + // Skip rate limit for internal requests if includeInternalRequests is not set + if (req.config.ip === '127.0.0.1' && !limit.includeInternalRequests) { + continue; + } + const pathExp = limit.path.regexp || limit.path; + let matchCount = 0; + for (const restRequest of req.body.requests) { + // Check if sub-request method matches the rate limit's requestMethods filter + if (limit.requestMethods) { + const method = restRequest.method?.toUpperCase(); + if (Array.isArray(limit.requestMethods)) { + if (!limit.requestMethods.includes(method)) { + continue; + } + } else { + const regExp = new RegExp(limit.requestMethods); + if (!regExp.test(method)) { + continue; + } + } + } + const routablePath = makeRoutablePath(restRequest.path); + if (pathExp.test(routablePath)) { + matchCount++; + } + } + if (matchCount > limit.requestCount) { + throw new Parse.Error( + Parse.Error.CONNECTION_FAILED, + limit.errorResponseMessage || 'Batch request exceeds rate limit for endpoint' + ); + } + } + const batch = transactionRetries => { let initialPromise = Promise.resolve(); if (req.body?.transaction === true) { diff --git a/src/middlewares.js b/src/middlewares.js index 06ed72e7ae..9c5eb68242 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -584,6 +584,11 @@ export const addRateLimit = (route, config, cloud) => { } config.rateLimits.push({ path: pathToRegexp(route.requestPath), + requestCount: route.requestCount, + requestMethods: route.requestMethods, + includeMasterKey: route.includeMasterKey, + includeInternalRequests: route.includeInternalRequests, + errorResponseMessage: route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default, handler: rateLimit({ windowMs: route.requestTimeWindow, max: route.requestCount, From 6d8449667c538b20784292fd0dd00bf743edc2f0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 8 Mar 2026 20:32:10 +0000 Subject: [PATCH 021/156] chore(release): 9.5.2-alpha.10 [skip ci] ## [9.5.2-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.9...9.5.2-alpha.10) (2026-03-08) ### Bug Fixes * Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10147](https://github.com/parse-community/parse-server/issues/10147)) ([2766f4f](https://github.com/parse-community/parse-server/commit/2766f4f7a2ce3afde4e1628907cdc556b6d6355c)) --- 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 54898f2e38..6c02a3ec54 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.9...9.5.2-alpha.10) (2026-03-08) + + +### Bug Fixes + +* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10147](https://github.com/parse-community/parse-server/issues/10147)) ([2766f4f](https://github.com/parse-community/parse-server/commit/2766f4f7a2ce3afde4e1628907cdc556b6d6355c)) + ## [9.5.2-alpha.9](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.8...9.5.2-alpha.9) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index c6edbc6f93..a985c4e4d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.9", + "version": "9.5.2-alpha.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.9", + "version": "9.5.2-alpha.10", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 00b1a41937..2900938def 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.9", + "version": "9.5.2-alpha.10", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 853bfe1bd3b104aefbcf87cf0cac391c9772ab9d Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 02:05:27 +0000 Subject: [PATCH 022/156] fix: Concurrent signup with same authentication creates duplicate users (#10149) --- spec/AuthDataUniqueIndex.spec.js | 210 ++++++++++++++++++ spec/DatabaseController.spec.js | 10 + spec/ParseUser.spec.js | 63 +++++- .../Storage/Mongo/MongoStorageAdapter.js | 51 ++++- .../Postgres/PostgresStorageAdapter.js | 62 +++++- src/Controllers/DatabaseController.js | 24 ++ src/Options/Definitions.js | 8 +- src/Options/docs.js | 3 +- src/Options/index.js | 5 +- src/RestWrite.js | 33 ++- src/defaults.js | 1 + 11 files changed, 450 insertions(+), 20 deletions(-) create mode 100644 spec/AuthDataUniqueIndex.spec.js diff --git a/spec/AuthDataUniqueIndex.spec.js b/spec/AuthDataUniqueIndex.spec.js new file mode 100644 index 0000000000..d975a42209 --- /dev/null +++ b/spec/AuthDataUniqueIndex.spec.js @@ -0,0 +1,210 @@ +'use strict'; + +const request = require('../lib/request'); +const Config = require('../lib/Config'); + +describe('AuthData Unique Index', () => { + const fakeAuthProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + beforeEach(async () => { + await reconfigureServer({ auth: { fakeAuthProvider } }); + }); + + it('should prevent concurrent signups with the same authData from creating duplicate users', async () => { + const authData = { fakeAuthProvider: { id: 'duplicate-test-id', token: 'token1' } }; + + // Fire multiple concurrent signup requests with the same authData + const concurrentRequests = Array.from({ length: 5 }, () => + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/users', + body: { authData }, + }).then( + response => ({ success: true, data: response.data }), + error => ({ success: false, error: error.data || error.message }) + ) + ); + + const results = await Promise.all(concurrentRequests); + const successes = results.filter(r => r.success); + const failures = results.filter(r => !r.success); + + // All should either succeed (returning the same user) or fail with "this auth is already used" + // The key invariant: only ONE unique objectId should exist + const uniqueObjectIds = new Set(successes.map(r => r.data.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.fakeAuthProvider.id', 'duplicate-test-id'); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); + + it('should prevent concurrent signups via batch endpoint with same authData', async () => { + const authData = { fakeAuthProvider: { id: 'batch-race-test-id', token: 'token1' } }; + + const response = await request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/batch', + body: { + requests: Array.from({ length: 3 }, () => ({ + method: 'POST', + path: '/1/users', + body: { authData }, + })), + }, + }); + + const results = response.data; + const successes = results.filter(r => r.success); + const failures = results.filter(r => r.error); + + // All successes should reference the same user + const uniqueObjectIds = new Set(successes.map(r => r.success.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.fakeAuthProvider.id', 'batch-race-test-id'); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); + + it('should allow sequential signups with different authData IDs', async () => { + const user1 = await Parse.User.logInWith('fakeAuthProvider', { + authData: { id: 'user-id-1', token: 'token1' }, + }); + const user2 = await Parse.User.logInWith('fakeAuthProvider', { + authData: { id: 'user-id-2', token: 'token2' }, + }); + + expect(user1.id).toBeDefined(); + expect(user2.id).toBeDefined(); + expect(user1.id).not.toBe(user2.id); + }); + + it('should still allow login with authData after successful signup', async () => { + const authPayload = { authData: { id: 'login-test-id', token: 'token1' } }; + + // Signup + const user1 = await Parse.User.logInWith('fakeAuthProvider', authPayload); + expect(user1.id).toBeDefined(); + + // Login again with same authData — should return same user + const user2 = await Parse.User.logInWith('fakeAuthProvider', authPayload); + expect(user2.id).toBe(user1.id); + }); + + it('should skip startup index creation when createIndexAuthDataUniqueness is false', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + const spy = spyOn(adapter, 'ensureAuthDataUniqueness').and.callThrough(); + + // Temporarily set the option to false + const originalOptions = config.database.options.databaseOptions; + config.database.options.databaseOptions = { createIndexAuthDataUniqueness: false }; + + await config.database.performInitialization(); + expect(spy).not.toHaveBeenCalled(); + + // Restore original options + config.database.options.databaseOptions = originalOptions; + }); + + it('should handle calling ensureAuthDataUniqueness multiple times (idempotent)', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + + // Both calls should succeed (index creation is idempotent) + await adapter.ensureAuthDataUniqueness('fakeAuthProvider'); + await adapter.ensureAuthDataUniqueness('fakeAuthProvider'); + }); + + it('should log warning when index creation fails due to existing duplicates', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + + // Spy on the adapter to simulate a duplicate value error + spyOn(adapter, 'ensureAuthDataUniqueness').and.callFake(() => { + return Promise.reject( + new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'duplicates exist') + ); + }); + + const logSpy = spyOn(require('../lib/logger').logger, 'warn'); + + // Re-run performInitialization — should warn but not throw + await config.database.performInitialization(); + expect(logSpy).toHaveBeenCalledWith( + jasmine.stringContaining('Unable to ensure uniqueness for auth data provider'), + jasmine.anything() + ); + }); + + it('should prevent concurrent signups with same anonymous authData', async () => { + const anonymousId = 'anon-race-test-id'; + const authData = { anonymous: { id: anonymousId } }; + + const concurrentRequests = Array.from({ length: 5 }, () => + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/users', + body: { authData }, + }).then( + response => ({ success: true, data: response.data }), + error => ({ success: false, error: error.data || error.message }) + ) + ); + + const results = await Promise.all(concurrentRequests); + const successes = results.filter(r => r.success); + const failures = results.filter(r => !r.success); + + // All successes should reference the same user + const uniqueObjectIds = new Set(successes.map(r => r.data.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.anonymous.id', anonymousId); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); +}); diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index b1ccc0d586..ac83c11dde 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -415,6 +415,11 @@ describe('DatabaseController', function () { email_1: { email: 1 }, _email_verify_token: { _email_verify_token: 1 }, _perishable_token: { _perishable_token: 1 }, + _auth_data_custom_id: { '_auth_data_custom.id': 1 }, + _auth_data_facebook_id: { '_auth_data_facebook.id': 1 }, + _auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 }, + _auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 }, + _auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 }, }); } ); @@ -441,6 +446,11 @@ describe('DatabaseController', function () { email_1: { email: 1 }, _email_verify_token: { _email_verify_token: 1 }, _perishable_token: { _perishable_token: 1 }, + _auth_data_custom_id: { '_auth_data_custom.id': 1 }, + _auth_data_facebook_id: { '_auth_data_facebook.id': 1 }, + _auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 }, + _auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 }, + _auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 }, }); } ); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 48ae1d2a9c..66fce2b129 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -390,7 +390,7 @@ describe('Parse.User testing', () => { expect(newUser).not.toBeUndefined(); }); - it('should be let masterKey lock user out with authData', async () => { + it_only_db('mongo')('should reject duplicate authData when masterKey locks user out (mongo)', async () => { const response = await request({ method: 'POST', url: 'http://localhost:8378/1/classes/_User', @@ -406,15 +406,13 @@ describe('Parse.User testing', () => { }); const body = response.data; const objectId = body.objectId; - const sessionToken = body.sessionToken; - expect(sessionToken).toBeDefined(); + expect(body.sessionToken).toBeDefined(); expect(objectId).toBeDefined(); const user = new Parse.User(); user.id = objectId; const ACL = new Parse.ACL(); user.setACL(ACL); await user.save(null, { useMasterKey: true }); - // update the user const options = { method: 'POST', url: `http://localhost:8378/1/classes/_User/`, @@ -430,8 +428,61 @@ describe('Parse.User testing', () => { }, }, }; - const res = await request(options); - expect(res.data.objectId).not.toEqual(objectId); + try { + await request(options); + fail('should have thrown'); + } catch (err) { + expect(err.data.code).toBe(208); + expect(err.data.error).toBe('this auth is already used'); + } + }); + + it_only_db('postgres')('should reject duplicate authData when masterKey locks user out (postgres)', async () => { + await reconfigureServer(); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'value', + authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } }, + }, + }); + const body = response.data; + const objectId = body.objectId; + expect(body.sessionToken).toBeDefined(); + expect(objectId).toBeDefined(); + const user = new Parse.User(); + user.id = objectId; + const ACL = new Parse.ACL(); + user.setACL(ACL); + await user.save(null, { useMasterKey: true }); + const options = { + method: 'POST', + url: `http://localhost:8378/1/classes/_User/`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'otherValue', + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }; + try { + await request(options); + fail('should have thrown'); + } catch (err) { + expect(err.data.code).toBe(208); + expect(err.data.error).toBe('this auth is already used'); + } }); it('user login with files', done => { diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 9d8ef47941..054c714278 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -582,6 +582,13 @@ export class MongoStorageAdapter implements StorageAdapter { if (matches && Array.isArray(matches)) { err.userInfo = { duplicated_field: matches[1] }; } + // Check for authData unique index violations + if (!err.userInfo) { + const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/); + if (authDataMatch) { + err.userInfo = { duplicated_field: authDataMatch[1] }; + } + } } throw err; } @@ -658,10 +665,26 @@ export class MongoStorageAdapter implements StorageAdapter { .catch(error => { if (error.code === 11000) { logger.error('Duplicate key error:', error.message); - throw new Parse.Error( + const err = new Parse.Error( Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided' ); + err.underlyingError = error; + if (error.message) { + const matches = error.message.match( + /index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/ + ); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + if (!err.userInfo) { + const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/); + if (authDataMatch) { + err.userInfo = { duplicated_field: authDataMatch[1] }; + } + } + } + throw err; } throw error; }) @@ -818,6 +841,32 @@ export class MongoStorageAdapter implements StorageAdapter { .catch(err => this.handleError(err)); } + // Creates a unique sparse index on _auth_data_.id to prevent + // race conditions during concurrent signups with the same authData. + ensureAuthDataUniqueness(provider: string) { + return this._adaptiveCollection('_User') + .then(collection => + collection._mongoCollection.createIndex( + { [`_auth_data_${provider}.id`]: 1 }, + { unique: true, sparse: true, background: true, name: `_auth_data_${provider}_id` } + ) + ) + .catch(error => { + if (error.code === 11000) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); + } + // Ignore "index already exists with same name" or "index already exists with different options" + if (error.code === 85 || error.code === 86) { + return; + } + throw error; + }) + .catch(err => this.handleError(err)); + } + // Used in tests _rawFind(className: string, query: QueryType) { return this._adaptiveCollection(className) diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index e988c9cc19..2c9ff4bfff 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1479,9 +1479,15 @@ export class PostgresStorageAdapter implements StorageAdapter { ); err.underlyingError = error; if (error.constraint) { - const matches = error.constraint.match(/unique_([a-zA-Z]+)/); - if (matches && Array.isArray(matches)) { - err.userInfo = { duplicated_field: matches[1] }; + // Check for authData unique index violations first + const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/); + if (authDataMatch) { + err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` }; + } else { + const matches = error.constraint.match(/unique_([a-zA-Z]+)/); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } } } error = err; @@ -1809,7 +1815,30 @@ export class PostgresStorageAdapter implements StorageAdapter { const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`; - const promise = (transactionalSession ? transactionalSession.t : this._client).any(qs, values); + const promise = (transactionalSession ? transactionalSession.t : this._client) + .any(qs, values) + .catch(error => { + if (error.code === PostgresUniqueIndexViolationError) { + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + err.underlyingError = error; + if (error.constraint) { + const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/); + if (authDataMatch) { + err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` }; + } else { + const matches = error.constraint.match(/unique_([a-zA-Z]+)/); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + } + } + throw err; + } + throw error; + }); if (transactionalSession) { transactionalSession.batch.push(promise); } @@ -2052,6 +2081,31 @@ export class PostgresStorageAdapter implements StorageAdapter { }); } + // Creates a unique index on authData->->>'id' to prevent + // race conditions during concurrent signups with the same authData. + async ensureAuthDataUniqueness(provider: string) { + const indexName = `_User_unique_authData_${provider}_id`; + const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $1:name ON "_User" (("authData"->$2::text->>'id')) WHERE "authData"->$2::text->>'id' IS NOT NULL`; + await this._client.none(qs, [indexName, provider]).catch(error => { + if ( + error.code === PostgresDuplicateRelationError && + error.message.includes(indexName) + ) { + // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(indexName) + ) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); + } else { + throw error; + } + }); + } + // Executes a count. async count( className: string, diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index d83fdd2d84..3784c8755f 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1850,6 +1850,30 @@ class DatabaseController { throw error; }); } + // Create unique indexes for authData providers to prevent race conditions + // during concurrent signups with the same authData + if ( + databaseOptions.createIndexAuthDataUniqueness !== false && + typeof this.adapter.ensureAuthDataUniqueness === 'function' + ) { + const authProviders = Object.keys(this.options.auth || {}); + if (this.options.enableAnonymousUsers !== false) { + if (!authProviders.includes('anonymous')) { + authProviders.push('anonymous'); + } + } + await Promise.all( + authProviders.map(provider => + this.adapter.ensureAuthDataUniqueness(provider).catch(error => { + logger.warn( + `Unable to ensure uniqueness for auth data provider "${provider}": `, + error + ); + }) + ) + ); + } + await this.adapter.updateSchemaWithIndexes(); } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index fd0d0a68ca..4cf036895f 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -102,7 +102,7 @@ module.exports.ParseServerOptions = { }, auth: { env: 'PARSE_SERVER_AUTH_PROVIDERS', - help: 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', + help: "Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules.", action: parsers.objectParser, }, cacheAdapter: { @@ -1169,6 +1169,12 @@ module.exports.DatabaseOptions = { help: 'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.', action: parsers.numberParser('connectTimeoutMS'), }, + createIndexAuthDataUniqueness: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_AUTH_DATA_UNIQUENESS', + help: 'Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, createIndexRoleName: { env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_ROLE_NAME', help: 'Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 95e1cb49f3..e7414890b8 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -21,7 +21,7 @@ * @property {Adapter} analyticsAdapter Adapter module for the analytics * @property {String} appId Your Parse Application ID * @property {String} appName Sets the app name - * @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication + * @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules. * @property {Adapter} cacheAdapter Adapter module for the cache * @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000 * @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) @@ -281,6 +281,7 @@ * @property {DatabaseOptionsClientMetadata} clientMetadata Custom metadata to append to database client connections for identifying Parse Server instances in database logs. If set, this metadata will be visible in database logs during connection handshakes. This can help with debugging and monitoring in deployments with multiple database clients. Set `name` to identify your application (e.g., 'MyApp') and `version` to your application's version. Leave undefined (default) to disable this feature and avoid the additional data transfer overhead. * @property {Union} compressors The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance. * @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout. + * @property {Boolean} createIndexAuthDataUniqueness Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server. * @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. * @property {Boolean} createIndexUserEmail Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. * @property {Boolean} createIndexUserEmailCaseInsensitive Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. diff --git a/src/Options/index.js b/src/Options/index.js index 50fe177adf..3dd4ad9351 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -183,7 +183,7 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID :DEFAULT: false */ allowCustomObjectId: ?boolean; - /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication + /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules. :ENV: PARSE_SERVER_AUTH_PROVIDERS */ auth: ?{ [string]: AuthAdapter }; /* Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`. @@ -806,6 +806,9 @@ export interface DatabaseOptions { /* Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. :DEFAULT: true */ createIndexUserUsernameCaseInsensitive: ?boolean; + /* Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexAuthDataUniqueness: ?boolean; /* Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. :DEFAULT: true */ createIndexRoleName: ?boolean; diff --git a/src/RestWrite.js b/src/RestWrite.js index b8d0e670eb..05dc3f7fd6 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -515,6 +515,16 @@ RestWrite.prototype.getUserId = function () { }; // Developers are allowed to change authData via before save trigger +RestWrite.prototype._throwIfAuthDataDuplicate = function (error) { + if ( + this.className === '_User' && + error?.code === Parse.Error.DUPLICATE_VALUE && + error.userInfo?.duplicated_field?.startsWith('_auth_data_') + ) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } +}; + // we need after before save to ensure that the developer // is not currently duplicating auth data ID RestWrite.prototype.ensureUniqueAuthDataId = async function () { @@ -652,12 +662,17 @@ RestWrite.prototype.handleAuthData = async function (authData) { // uses the `doNotSave` option. Just update the authData part // Then we're good for the user, early exit of sorts if (Object.keys(this.data.authData).length) { - await this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: this.data.authData }, - {} - ); + try { + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); + } catch (error) { + this._throwIfAuthDataDuplicate(error); + throw error; + } } } } @@ -1579,6 +1594,10 @@ RestWrite.prototype.runDatabaseOperation = function () { false, this.validSchemaController ) + .catch(error => { + this._throwIfAuthDataDuplicate(error); + throw error; + }) .then(response => { response.updatedAt = this.updatedAt; this._updateResponseWithData(response, this.data); @@ -1613,6 +1632,8 @@ RestWrite.prototype.runDatabaseOperation = function () { throw error; } + this._throwIfAuthDataDuplicate(error); + // Quick check, if we were able to infer the duplicated field name if (error && error.userInfo && error.userInfo.duplicated_field === 'username') { throw new Parse.Error( diff --git a/src/defaults.js b/src/defaults.js index ce4bd09766..b7d05f1550 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -48,6 +48,7 @@ export const ParseServerDatabaseOptions = [ 'allowPublicExplain', 'batchSize', 'clientMetadata', + 'createIndexAuthDataUniqueness', 'createIndexRoleName', 'createIndexUserEmail', 'createIndexUserEmailCaseInsensitive', From 4625aefc3890e20c82591744fd7bc489f08feea7 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 02:06:19 +0000 Subject: [PATCH 023/156] chore(release): 9.5.2-alpha.11 [skip ci] ## [9.5.2-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.10...9.5.2-alpha.11) (2026-03-09) ### Bug Fixes * Concurrent signup with same authentication creates duplicate users ([#10149](https://github.com/parse-community/parse-server/issues/10149)) ([853bfe1](https://github.com/parse-community/parse-server/commit/853bfe1bd3b104aefbcf87cf0cac391c9772ab9d)) --- 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 6c02a3ec54..64e1d53cac 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.10...9.5.2-alpha.11) (2026-03-09) + + +### Bug Fixes + +* Concurrent signup with same authentication creates duplicate users ([#10149](https://github.com/parse-community/parse-server/issues/10149)) ([853bfe1](https://github.com/parse-community/parse-server/commit/853bfe1bd3b104aefbcf87cf0cac391c9772ab9d)) + ## [9.5.2-alpha.10](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.9...9.5.2-alpha.10) (2026-03-08) diff --git a/package-lock.json b/package-lock.json index a985c4e4d0..63e278a043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.10", + "version": "9.5.2-alpha.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.10", + "version": "9.5.2-alpha.11", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 2900938def..ae3ab93f51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.10", + "version": "9.5.2-alpha.11", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 1de4e43ca2c894f1c0c1ca5611f5b491e8d24d40 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 03:41:12 +0000 Subject: [PATCH 024/156] fix: Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) (#10151) --- spec/AudienceRouter.spec.js | 23 +++--- spec/PushController.spec.js | 2 +- spec/rest.spec.js | 147 ++++++++++++++++++++++++++++++++++++ src/SharedRest.js | 2 + 4 files changed, 162 insertions(+), 12 deletions(-) diff --git a/spec/AudienceRouter.spec.js b/spec/AudienceRouter.spec.js index f6d6af9393..96b8f2459f 100644 --- a/spec/AudienceRouter.spec.js +++ b/spec/AudienceRouter.spec.js @@ -36,9 +36,9 @@ describe('AudiencesRouter', () => { const router = new AudiencesRouter(); rest - .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .create(config, auth.master(config), '_Audience', androidAudienceRequest) .then(() => { - return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); }) .then(() => { return router.handleFind(request); @@ -78,9 +78,9 @@ describe('AudiencesRouter', () => { const router = new AudiencesRouter(); rest - .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .create(config, auth.master(config), '_Audience', androidAudienceRequest) .then(() => { - return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); }) .then(() => { return router.handleFind(request); @@ -119,9 +119,9 @@ describe('AudiencesRouter', () => { Config.get('test'); const router = new AudiencesRouter(); rest - .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .create(config, auth.master(config), '_Audience', androidAudienceRequest) .then(() => { - return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); }) .then(() => { return router.handleFind(request); @@ -159,8 +159,8 @@ describe('AudiencesRouter', () => { const router = new AudiencesRouter(); rest - .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) - .then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest)) + .create(config, auth.master(config), '_Audience', androidAudienceRequest) + .then(() => rest.create(config, auth.master(config), '_Audience', iosAudienceRequest)) .then(() => router.handleFind(request)) .then(res => { const response = res.response; @@ -197,9 +197,9 @@ describe('AudiencesRouter', () => { const router = new AudiencesRouter(); rest - .create(config, auth.nobody(config), '_Audience', androidAudienceRequest) + .create(config, auth.master(config), '_Audience', androidAudienceRequest) .then(() => { - return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest); + return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest); }) .then(() => { return router.handleFind(request); @@ -421,6 +421,7 @@ describe('AudiencesRouter', () => { await reconfigureServer({ appId: 'test', restAPIKey: 'test', + masterKey: 'test', publicServerURL: 'http://localhost:8378/1', }); try { @@ -430,7 +431,7 @@ describe('AudiencesRouter', () => { body: { lorem: 'ipsum', _method: 'POST' }, headers: { 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', + 'X-Parse-Master-Key': 'test', 'Content-Type': 'application/json', }, }); diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index b914ceac84..aed0e0ab89 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -1074,7 +1074,7 @@ describe('PushController', () => { const audience = new Parse.Object('_Audience'); audience.set('name', 'testAudience'); audience.set('query', JSON.stringify(where)); - await Parse.Object.saveAll(audience); + await audience.save(null, { useMasterKey: true }); await query.find({ useMasterKey: true }).then(parseResults); const body = { diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 25a092751b..a2afa5387c 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -812,6 +812,153 @@ describe('rest create', () => { expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the get operation on the _GlobalConfig collection.")); }); + it('should require master key for all volatile classes', () => { + // This test guards against drift between volatileClasses (SchemaController.js) + // and classesWithMasterOnlyAccess (SharedRest.js). If a new volatile class is + // added, it must also be added to classesWithMasterOnlyAccess and this test. + const volatileClasses = [ + '_JobStatus', + '_PushStatus', + '_Hooks', + '_GlobalConfig', + '_GraphQLConfig', + '_JobSchedule', + '_Audience', + '_Idempotency', + ]; + for (const className of volatileClasses) { + expect(() => + rest.create(config, auth.nobody(config), className, {}) + ).toThrowMatching( + e => e.code === Parse.Error.OPERATION_FORBIDDEN, + `Expected ${className} to require master key` + ); + } + }); + + it('cannot find objects in _GraphQLConfig without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + await expectAsync( + rest.find(config, auth.nobody(config), '_GraphQLConfig', {}) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it('cannot update object in _GraphQLConfig without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + expect(() => + rest.update(config, auth.nobody(config), '_GraphQLConfig', '1', { + config: { enabledForClasses: [] }, + }) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('cannot delete object in _GraphQLConfig without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + expect(() => + rest.del(config, auth.nobody(config), '_GraphQLConfig', '1') + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('can perform operations on _GraphQLConfig with masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + const found = await rest.find(config, auth.master(config), '_GraphQLConfig', {}); + expect(found.results.length).toBeGreaterThan(0); + await rest.del(config, auth.master(config), '_GraphQLConfig', '1'); + const afterDelete = await rest.find(config, auth.master(config), '_GraphQLConfig', {}); + expect(afterDelete.results.length).toBe(0); + }); + + it('cannot create object in _Audience without masterKey', () => { + expect(() => + rest.create(config, auth.nobody(config), '_Audience', { + name: 'test', + query: '{}', + }) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('cannot find objects in _Audience without masterKey', async () => { + await expectAsync( + rest.find(config, auth.nobody(config), '_Audience', {}) + ).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }) + ); + }); + + it('cannot update object in _Audience without masterKey', async () => { + const obj = await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + expect(() => + rest.update(config, auth.nobody(config), '_Audience', obj.response.objectId, { + name: 'updated', + }) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('cannot delete object in _Audience without masterKey', async () => { + const obj = await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + expect(() => + rest.del(config, auth.nobody(config), '_Audience', obj.response.objectId) + ).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN); + }); + + it('can perform CRUD on _Audience with masterKey', async () => { + const obj = await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + expect(obj.response.objectId).toBeDefined(); + const found = await rest.find(config, auth.master(config), '_Audience', {}); + expect(found.results.length).toBeGreaterThan(0); + await rest.del(config, auth.master(config), '_Audience', obj.response.objectId); + const afterDelete = await rest.find(config, auth.master(config), '_Audience', {}); + expect(afterDelete.results.length).toBe(0); + }); + + it('cannot access _GraphQLConfig via class route without masterKey', async () => { + await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] }); + try { + await request({ + url: 'http://localhost:8378/1/classes/_GraphQLConfig', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('cannot access _Audience via class route without masterKey', async () => { + await rest.create(config, auth.master(config), '_Audience', { + name: 'test', + query: '{}', + }); + try { + await request({ + url: 'http://localhost:8378/1/classes/_Audience', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + fail('should have thrown'); + } catch (e) { + expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + } + }); + it('locks down session', done => { let currentUser; Parse.User.signUp('foo', 'bar') diff --git a/src/SharedRest.js b/src/SharedRest.js index 341babaf3e..e36a9703ea 100644 --- a/src/SharedRest.js +++ b/src/SharedRest.js @@ -3,7 +3,9 @@ const classesWithMasterOnlyAccess = [ '_PushStatus', '_Hooks', '_GlobalConfig', + '_GraphQLConfig', '_JobSchedule', + '_Audience', '_Idempotency', ]; const { createSanitizedError } = require('./Error'); From e2687b0519015b2fd46c40b901e2281e9458edf2 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 03:42:11 +0000 Subject: [PATCH 025/156] chore(release): 9.5.2-alpha.12 [skip ci] ## [9.5.2-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.11...9.5.2-alpha.12) (2026-03-09) ### Bug Fixes * Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10151](https://github.com/parse-community/parse-server/issues/10151)) ([1de4e43](https://github.com/parse-community/parse-server/commit/1de4e43ca2c894f1c0c1ca5611f5b491e8d24d40)) --- 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 64e1d53cac..ec5016b970 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.11...9.5.2-alpha.12) (2026-03-09) + + +### Bug Fixes + +* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10151](https://github.com/parse-community/parse-server/issues/10151)) ([1de4e43](https://github.com/parse-community/parse-server/commit/1de4e43ca2c894f1c0c1ca5611f5b491e8d24d40)) + ## [9.5.2-alpha.11](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.10...9.5.2-alpha.11) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index 63e278a043..34de0a9351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.11", + "version": "9.5.2-alpha.12", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.11", + "version": "9.5.2-alpha.12", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index ae3ab93f51..f3c144e8c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.11", + "version": "9.5.2-alpha.12", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 5bbca7b862840909bb130920c33794abebbc15d4 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:06:32 +0000 Subject: [PATCH 026/156] fix: LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) (#10154) --- spec/LdapAuth.spec.js | 172 ++++++++++++++++++++++++++++++++++++++ src/Adapters/Auth/ldap.js | 45 +++++++++- 2 files changed, 214 insertions(+), 3 deletions(-) diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js index ea30f59f0c..b577defcd9 100644 --- a/spec/LdapAuth.spec.js +++ b/spec/LdapAuth.spec.js @@ -4,6 +4,178 @@ const fs = require('fs'); const port = 12345; const sslport = 12346; +describe('LDAP Injection Prevention', () => { + describe('escapeDN', () => { + it('should escape comma', () => { + expect(ldap.escapeDN('admin,ou=evil')).toBe('admin\\,ou\\=evil'); + }); + + it('should escape equals sign', () => { + expect(ldap.escapeDN('admin=evil')).toBe('admin\\=evil'); + }); + + it('should escape plus sign', () => { + expect(ldap.escapeDN('admin+evil')).toBe('admin\\+evil'); + }); + + it('should escape less-than and greater-than signs', () => { + expect(ldap.escapeDN('admin')).toBe('admin\\'); + }); + + it('should escape hash at start', () => { + expect(ldap.escapeDN('#admin')).toBe('\\#admin'); + }); + + it('should escape semicolon', () => { + expect(ldap.escapeDN('admin;evil')).toBe('admin\\;evil'); + }); + + it('should escape double quote', () => { + expect(ldap.escapeDN('admin"evil')).toBe('admin\\"evil'); + }); + + it('should escape backslash', () => { + expect(ldap.escapeDN('admin\\evil')).toBe('admin\\\\evil'); + }); + + it('should escape leading space', () => { + expect(ldap.escapeDN(' admin')).toBe('\\ admin'); + }); + + it('should escape trailing space', () => { + expect(ldap.escapeDN('admin ')).toBe('admin\\ '); + }); + + it('should escape multiple special characters', () => { + expect(ldap.escapeDN('admin,ou=evil+cn=x')).toBe('admin\\,ou\\=evil\\+cn\\=x'); + }); + + it('should not modify safe values', () => { + expect(ldap.escapeDN('testuser')).toBe('testuser'); + expect(ldap.escapeDN('john.doe')).toBe('john.doe'); + expect(ldap.escapeDN('user123')).toBe('user123'); + }); + }); + + describe('escapeFilter', () => { + it('should escape asterisk', () => { + expect(ldap.escapeFilter('*')).toBe('\\2a'); + }); + + it('should escape open parenthesis', () => { + expect(ldap.escapeFilter('test(')).toBe('test\\28'); + }); + + it('should escape close parenthesis', () => { + expect(ldap.escapeFilter('test)')).toBe('test\\29'); + }); + + it('should escape backslash', () => { + expect(ldap.escapeFilter('test\\')).toBe('test\\5c'); + }); + + it('should escape null byte', () => { + expect(ldap.escapeFilter('test\x00')).toBe('test\\00'); + }); + + it('should escape multiple special characters', () => { + expect(ldap.escapeFilter('*()\\')).toBe('\\2a\\28\\29\\5c'); + }); + + it('should not modify safe values', () => { + expect(ldap.escapeFilter('testuser')).toBe('testuser'); + expect(ldap.escapeFilter('john.doe')).toBe('john.doe'); + expect(ldap.escapeFilter('user123')).toBe('user123'); + }); + + it('should escape filter injection attempt with wildcard', () => { + expect(ldap.escapeFilter('x)(|(objectClass=*)')).toBe('x\\29\\28|\\28objectClass=\\2a\\29'); + }); + }); + + describe('authData validation', () => { + it('should reject missing authData.id', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ password: 'secret' }, options); + fail('Should have rejected missing id'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + + it('should reject non-string authData.id', async done => { + const server = await mockLdapServer(port, 'uid=testuser, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + try { + await ldap.validateAuthData({ id: 123, password: 'secret' }, options); + fail('Should have rejected non-string id'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + }); + + describe('DN injection prevention', () => { + it('should prevent DN injection via comma in authData.id', async done => { + // Mock server accepts the DN that would result from an unescaped injection + const server = await mockLdapServer(port, 'uid=admin,ou=admins,o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + // Attacker tries to inject additional DN components via comma + // Without escaping: DN = uid=admin,ou=admins, o=example (3 RDNs) → matches mock + // With escaping: DN = uid=admin\,ou=admins, o=example (2 RDNs) → doesn't match + try { + await ldap.validateAuthData({ id: 'admin,ou=admins', password: 'secret' }, options); + fail('Should have rejected DN injection attempt'); + } catch (err) { + expect(err.message).toBe('LDAP: Wrong username or password'); + } + server.close(done); + }); + }); + + describe('Filter injection prevention', () => { + it('should prevent LDAP filter injection via wildcard in authData.id', async done => { + // Mock server accepts uid=*, o=example (the attacker's bind DN) + // The * is not special in DNs so it binds fine regardless of escaping + const server = await mockLdapServer(port, 'uid=*, o=example'); + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + // Attacker uses * as ID to match any group member via wildcard + // Group has member uid=testuser, not uid=* + // Without escaping: filter uses SubstringFilter, matches testuser → passes + // With escaping: filter uses EqualityFilter with literal \2a, no match → fails + try { + await ldap.validateAuthData({ id: '*', password: 'secret' }, options); + fail('Should have rejected filter injection attempt'); + } catch (err) { + expect(err.message).toBe('LDAP: User not in group'); + } + server.close(done); + }); + }); +}); + describe('Ldap Auth', () => { it('Should fail with missing options', done => { ldap diff --git a/src/Adapters/Auth/ldap.js b/src/Adapters/Auth/ldap.js index 5f6a88a7b5..7312aea67b 100644 --- a/src/Adapters/Auth/ldap.js +++ b/src/Adapters/Auth/ldap.js @@ -76,6 +76,37 @@ const ldapjs = require('ldapjs'); const Parse = require('parse/node').Parse; +// Escape LDAP DN special characters per RFC 4514 +// https://datatracker.ietf.org/doc/html/rfc4514#section-2.4 +function escapeDN(value) { + let escaped = value + .replace(/\\/g, '\\\\') + .replace(/,/g, '\\,') + .replace(/=/g, '\\=') + .replace(/\+/g, '\\+') + .replace(//g, '\\>') + .replace(/#/g, '\\#') + .replace(/;/g, '\\;') + .replace(/"/g, '\\"'); + if (escaped.startsWith(' ')) { + escaped = '\\ ' + escaped.slice(1); + } + if (escaped.endsWith(' ')) { + escaped = escaped.slice(0, -1) + '\\ '; + } + return escaped; +} + +// Escape LDAP filter special characters per RFC 4515 +// https://datatracker.ietf.org/doc/html/rfc4515#section-3 +function escapeFilter(value) { + // eslint-disable-next-line no-control-regex + return value.replace(/[\\*()\x00]/g, ch => + '\\' + ch.charCodeAt(0).toString(16).padStart(2, '0') + ); +} + function validateAuthData(authData, options) { if (!optionsAreValid(options)) { return new Promise((_, reject) => { @@ -86,11 +117,17 @@ function validateAuthData(authData, options) { ? { url: options.url, tlsOptions: options.tlsOptions } : { url: options.url }; + if (typeof authData.id !== 'string') { + return Promise.reject( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAP: Wrong username or password') + ); + } const client = ldapjs.createClient(clientOptions); + const escapedId = escapeDN(authData.id); const userCn = typeof options.dn === 'string' - ? options.dn.replace('{{id}}', authData.id) - : `uid=${authData.id},${options.suffix}`; + ? options.dn.replace('{{id}}', escapedId) + : `uid=${escapedId},${options.suffix}`; return new Promise((resolve, reject) => { client.bind(userCn, authData.password, ldapError => { @@ -140,7 +177,7 @@ function optionsAreValid(options) { } function searchForGroup(client, options, id, resolve, reject) { - const filter = options.groupFilter.replace(/{{id}}/gi, id); + const filter = options.groupFilter.replace(/{{id}}/gi, escapeFilter(id)); const opts = { scope: 'sub', filter: filter, @@ -184,4 +221,6 @@ function validateAppId() { module.exports = { validateAppId, validateAuthData, + escapeDN, + escapeFilter, }; From 00c6c7c7d1ac59ea4fa4321df0ae02b3f622f1f0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 14:07:27 +0000 Subject: [PATCH 027/156] chore(release): 9.5.2-alpha.13 [skip ci] ## [9.5.2-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.12...9.5.2-alpha.13) (2026-03-09) ### Bug Fixes * LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10154](https://github.com/parse-community/parse-server/issues/10154)) ([5bbca7b](https://github.com/parse-community/parse-server/commit/5bbca7b862840909bb130920c33794abebbc15d4)) --- 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 ec5016b970..49aec4abbe 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.12...9.5.2-alpha.13) (2026-03-09) + + +### Bug Fixes + +* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10154](https://github.com/parse-community/parse-server/issues/10154)) ([5bbca7b](https://github.com/parse-community/parse-server/commit/5bbca7b862840909bb130920c33794abebbc15d4)) + ## [9.5.2-alpha.12](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.11...9.5.2-alpha.12) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index 34de0a9351..4c6cf8d226 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.12", + "version": "9.5.2-alpha.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.12", + "version": "9.5.2-alpha.13", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index f3c144e8c7..115ca6b052 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.12", + "version": "9.5.2-alpha.13", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 416cfbcd73f0da398e577a188c7976716a3c27ab Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:03:17 +0000 Subject: [PATCH 028/156] fix: LiveQuery `regexTimeout` default value not applied (#10156) --- spec/QueryTools.spec.js | 10 ++++++++++ src/Config.js | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index 17ad7488f3..8819aadd66 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -557,6 +557,16 @@ describe('matchesQuery', function () { expect(matchesQuery(player, q)).toBe(true); }); + it('applies default regexTimeout when liveQuery is configured without explicit regexTimeout', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['Player'] }, + }); + // Verify the default value is applied by checking the config + const Config = require('../lib/Config'); + const config = Config.get('test'); + expect(config.liveQuery.regexTimeout).toBe(100); + }); + it('matches $nearSphere queries', function () { let q = new Parse.Query('Checkin'); q.near('location', new Parse.GeoPoint(20, 20)); diff --git a/src/Config.js b/src/Config.js index 59afa92c00..920f72d1e6 100644 --- a/src/Config.js +++ b/src/Config.js @@ -14,6 +14,7 @@ import { DatabaseOptions, FileUploadOptions, IdempotencyOptions, + LiveQueryOptions, LogLevels, PagesOptions, ParseServerOptions, @@ -136,6 +137,7 @@ export class Config { extendSessionOnUse, allowClientClassCreation, requestComplexity, + liveQuery, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -179,6 +181,7 @@ export class Config { this.validateCustomPages(customPages); this.validateAllowClientClassCreation(allowClientClassCreation); this.validateRequestComplexity(requestComplexity); + this.validateLiveQueryOptions(liveQuery); } static validateCustomPages(customPages) { @@ -706,6 +709,17 @@ export class Config { } } + static validateLiveQueryOptions(liveQuery) { + if (liveQuery == undefined) { + return; + } + if (liveQuery.regexTimeout === undefined) { + liveQuery.regexTimeout = LiveQueryOptions.regexTimeout.default; + } else if (typeof liveQuery.regexTimeout !== 'number') { + throw `liveQuery.regexTimeout must be a number`; + } + } + static validateRateLimit(rateLimit) { if (!rateLimit) { return; From 510b898098b700a98da543c5274ac748a795014d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 18:04:24 +0000 Subject: [PATCH 029/156] chore(release): 9.5.2-alpha.14 [skip ci] ## [9.5.2-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.13...9.5.2-alpha.14) (2026-03-09) ### Bug Fixes * LiveQuery `regexTimeout` default value not applied ([#10156](https://github.com/parse-community/parse-server/issues/10156)) ([416cfbc](https://github.com/parse-community/parse-server/commit/416cfbcd73f0da398e577a188c7976716a3c27ab)) --- 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 49aec4abbe..3ddee3754a 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +## [9.5.2-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.13...9.5.2-alpha.14) (2026-03-09) + + +### Bug Fixes + +* LiveQuery `regexTimeout` default value not applied ([#10156](https://github.com/parse-community/parse-server/issues/10156)) ([416cfbc](https://github.com/parse-community/parse-server/commit/416cfbcd73f0da398e577a188c7976716a3c27ab)) + ## [9.5.2-alpha.13](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.12...9.5.2-alpha.13) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index 4c6cf8d226..75706d6367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.13", + "version": "9.5.2-alpha.14", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.13", + "version": "9.5.2-alpha.14", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 115ca6b052..6cef29d346 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.13", + "version": "9.5.2-alpha.14", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 28d11a33bcdb0f89604e2289018a6f4729d4ba67 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:12:29 +0000 Subject: [PATCH 030/156] feat: Add `X-Content-Type-Options: nosniff` header and customizable response headers for files via `Parse.Cloud.afterFind(Parse.File)` (#10158) --- spec/CloudCode.spec.js | 33 +++++++++++++++++++++++++++++++++ spec/vulnerabilities.spec.js | 29 +++++++++++++++++++++++++++++ src/Routers/FilesRouter.js | 12 +++++++++++- src/cloud-code/Parse.Cloud.js | 2 ++ src/triggers.js | 3 +++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 544c535bc5..86085a3faf 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -4453,6 +4453,39 @@ describe('Parse.File hooks', () => { expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`); }); + it('can set custom response headers in afterFind', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, req => { + req.responseHeaders['X-Custom-Header'] = 'custom-value'; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-custom-header']).toBe('custom-value'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('can override default response headers in afterFind', async () => { + const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + Parse.Cloud.afterFind(Parse.File, req => { + delete req.responseHeaders['X-Content-Type-Options']; + }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-content-type-options']).toBeUndefined(); + }); + it('beforeFind blocks metadata endpoint', async () => { const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain'); await file.save({ useMasterKey: true }); diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 3ce1be2e3f..4dfc0bd338 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -859,3 +859,32 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () client.close(); }); }); + +describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { + it('sets X-Content-Type-Options: nosniff on file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('sets X-Content-Type-Options: nosniff on streaming file GET response', async () => { + const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); + await file.save({ useMasterKey: true }); + const response = await request({ + url: file.url(), + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Range': 'bytes=0-2', + }, + }); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); +}); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 721e8eade6..112f38f7c3 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -188,7 +188,12 @@ export class FilesRouter { contentType = mime.getType(filename); } + const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' }; + if (isFileStreamable(req, filesController)) { + for (const [key, value] of Object.entries(defaultResponseHeaders)) { + res.set(key, value); + } filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); @@ -208,7 +213,7 @@ export class FilesRouter { file = new Parse.File(filename, { base64: data.toString('base64') }, contentType); const afterFind = await triggers.maybeRunFileTrigger( triggers.Types.afterFind, - { file, forceDownload: false }, + { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } }, config, req.auth ); @@ -224,6 +229,11 @@ export class FilesRouter { if (afterFind.forceDownload) { res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`); } + if (afterFind.responseHeaders) { + for (const [key, value] of Object.entries(afterFind.responseHeaders)) { + res.set(key, value); + } + } res.end(data); } catch (e) { const err = triggers.resolveError(e, { diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 88aedf080f..7f2aded387 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -757,6 +757,8 @@ module.exports = ParseCloud; * @property {String} triggerName The name of the trigger (`beforeSave`, `afterSave`) * @property {Object} log The current logger inside Parse Server. * @property {Object} config The Parse Server config. + * @property {Boolean} forceDownload (afterFind only) If set to `true`, the file response will include a `Content-Disposition: attachment` header, prompting the browser to download the file instead of displaying it inline. + * @property {Object} responseHeaders (afterFind only) The headers that will be set on the file response. By default contains `{ 'X-Content-Type-Options': 'nosniff' }`. Modify this object to add, change, or remove response headers. */ /** diff --git a/src/triggers.js b/src/triggers.js index 2ba470ed90..a2daec42cf 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1076,6 +1076,9 @@ export async function maybeRunFileTrigger(triggerType, fileObject, config, auth) if (request.forceDownload) { fileObject.forceDownload = true; } + if (request.responseHeaders) { + fileObject.responseHeaders = request.responseHeaders; + } logTriggerSuccessBeforeHook( triggerType, 'Parse.File', From df80c8908b7af1ca4d41fe5cd2dd01dbebd22719 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 19:13:27 +0000 Subject: [PATCH 031/156] chore(release): 9.6.0-alpha.1 [skip ci] # [9.6.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.14...9.6.0-alpha.1) (2026-03-09) ### Features * Add `X-Content-Type-Options: nosniff` header and customizable response headers for files via `Parse.Cloud.afterFind(Parse.File)` ([#10158](https://github.com/parse-community/parse-server/issues/10158)) ([28d11a3](https://github.com/parse-community/parse-server/commit/28d11a33bcdb0f89604e2289018a6f4729d4ba67)) --- 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 3ddee3754a..a814dd972c 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.14...9.6.0-alpha.1) (2026-03-09) + + +### Features + +* Add `X-Content-Type-Options: nosniff` header and customizable response headers for files via `Parse.Cloud.afterFind(Parse.File)` ([#10158](https://github.com/parse-community/parse-server/issues/10158)) ([28d11a3](https://github.com/parse-community/parse-server/commit/28d11a33bcdb0f89604e2289018a6f4729d4ba67)) + ## [9.5.2-alpha.14](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.13...9.5.2-alpha.14) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index 75706d6367..cdad97a61e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.5.2-alpha.14", + "version": "9.6.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.5.2-alpha.14", + "version": "9.6.0-alpha.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 6cef29d346..47c8424a68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.5.2-alpha.14", + "version": "9.6.0-alpha.1", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From ea538a4ba320f5ead4e784de5de815edf765a9f5 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:27:56 +0000 Subject: [PATCH 032/156] fix: SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) (#10159) --- spec/vulnerabilities.spec.js | 161 ++++++++++++++++++ .../Postgres/PostgresStorageAdapter.js | 4 +- 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 4dfc0bd338..70b34e357f 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -860,6 +860,167 @@ describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () }); }); +describe('(GHSA-qpr4-jrj4-6f27) SQL Injection via sort dot-notation field name', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it_only_db('postgres')('does not execute injected SQL via sort order dot-notation', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + // This payload would execute a stacked query if single quotes are not escaped + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); + + // Verify the data was not modified by injected SQL + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injected SQL via sort order with pg_sleep', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; SELECT pg_sleep(3)--", + }, + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); + + it_only_db('postgres')('does not execute injection via dollar-sign quoting bypass', async () => { + // PostgreSQL supports $$string$$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $$hacked$$ WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via tagged dollar quoting bypass', async () => { + // PostgreSQL supports $tag$string$tag$ as alternative to 'string' + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $t$hacked$t$ WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via CHR() concatenation bypass', async () => { + // CHR(104)||CHR(97)||... builds 'hacked' without quotes + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = CHR(104)||CHR(97)||CHR(99)||CHR(107) WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it_only_db('postgres')('does not execute injection via backslash escape bypass', async () => { + // Backslash before quote could interact with '' escaping in some configurations + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + obj.set('name', 'original'); + await obj.save(); + + await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: "data.x\\' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--", + }, + }).catch(() => {}); + + const verify = await new Parse.Query('InjectionTest').get(obj.id); + expect(verify.get('name')).toBe('original'); + }); + + it('allows valid dot-notation sort on object field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { key: 'value' }); + await obj.save(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.key', + }, + }); + expect(response.status).toBe(200); + }); + + it('allows valid dot-notation with special characters in sub-field', async () => { + const obj = new Parse.Object('InjectionTest'); + obj.set('data', { 'my-field': 'value' }); + await obj.save(); + + const response = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/InjectionTest', + headers, + qs: { + order: 'data.my-field', + }, + }); + expect(response.status).toBe(200); + }); +}); + describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { it('sets X-Content-Type-Options: nosniff on file GET response', async () => { const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 2c9ff4bfff..6730e18e78 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -211,12 +211,12 @@ const handleDotFields = object => { const transformDotFieldToComponents = fieldName => { return fieldName.split('.').map((cmpt, index) => { if (index === 0) { - return `"${cmpt}"`; + return `"${cmpt.replace(/"/g, '""')}"`; } if (isArrayIndex(cmpt)) { return Number(cmpt); } else { - return `'${cmpt}'`; + return `'${cmpt.replace(/'/g, "''")}'`; } }); }; From a6c092652fc6c533f8c8df18bab1d476b6d3c067 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 20:28:51 +0000 Subject: [PATCH 033/156] chore(release): 9.6.0-alpha.2 [skip ci] # [9.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.1...9.6.0-alpha.2) (2026-03-09) ### Bug Fixes * SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10159](https://github.com/parse-community/parse-server/issues/10159)) ([ea538a4](https://github.com/parse-community/parse-server/commit/ea538a4ba320f5ead4e784de5de815edf765a9f5)) --- 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 a814dd972c..75c78a7498 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.1...9.6.0-alpha.2) (2026-03-09) + + +### Bug Fixes + +* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10159](https://github.com/parse-community/parse-server/issues/10159)) ([ea538a4](https://github.com/parse-community/parse-server/commit/ea538a4ba320f5ead4e784de5de815edf765a9f5)) + # [9.6.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.5.2-alpha.14...9.6.0-alpha.1) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index cdad97a61e..a881a57656 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.1", + "version": "9.6.0-alpha.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.1", + "version": "9.6.0-alpha.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 47c8424a68..bdcb61642d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.1", + "version": "9.6.0-alpha.2", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 8f822826a48169528a66626118bbaead3064b055 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:30:28 +0000 Subject: [PATCH 034/156] fix: SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) (#10161) --- spec/vulnerabilities.spec.js | 88 +++++++++++++++++++ .../Postgres/PostgresStorageAdapter.js | 16 ++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 70b34e357f..3b1cbe81ae 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1049,3 +1049,91 @@ describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { expect(response.headers['x-content-type-options']).toBe('nosniff'); }); }); + +describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects non-number Increment amount on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '1' }, + }), + }).catch(e => e); + + expect(response.status).toBe(400); + const text = JSON.parse(response.text); + expect(text.code).toBe(Parse.Error.INVALID_JSON); + }); + + it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + }); + + it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { + __op: 'Increment', + amount: '0+(SELECT ascii(substr(current_database(),1,1)))', + }, + }), + }).catch(() => {}); + + // Verify counter was not modified by injected SQL + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it('allows valid numeric Increment on nested object field', async () => { + const obj = new Parse.Object('IncrTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 3 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('IncrTest').get(obj.id); + expect(verify.get('stats').counter).toBe(8); + }); +}); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 6730e18e78..e9757fceb0 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1735,13 +1735,19 @@ export class PostgresStorageAdapter implements StorageAdapter { .map(k => k.split('.')[1]); let incrementPatterns = ''; + const incrementValues = []; if (keysToIncrement.length > 0) { incrementPatterns = ' || ' + keysToIncrement .map(c => { const amount = fieldValue[c].amount; - return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + ${amount}, '}')::jsonb`; + if (typeof amount !== 'number') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number'); + } + incrementValues.push(amount); + const amountIndex = index + incrementValues.length; + return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + $${amountIndex}, '}')::jsonb`; }) .join(' || '); // Strip the keys @@ -1764,7 +1770,7 @@ export class PostgresStorageAdapter implements StorageAdapter { .map(k => k.split('.')[1]); const deletePatterns = keysToDelete.reduce((p: string, c: string, i: number) => { - return p + ` - '$${index + 1 + i}:value'`; + return p + ` - '$${index + 1 + incrementValues.length + i}:value'`; }, ''); // Override Object let updateObject = "'{}'::jsonb"; @@ -1774,11 +1780,11 @@ export class PostgresStorageAdapter implements StorageAdapter { updateObject = `COALESCE($${index}:name, '{}'::jsonb)`; } updatePatterns.push( - `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + keysToDelete.length + `$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + incrementValues.length + keysToDelete.length }::jsonb )` ); - values.push(fieldName, ...keysToDelete, JSON.stringify(fieldValue)); - index += 2 + keysToDelete.length; + values.push(fieldName, ...incrementValues, ...keysToDelete, JSON.stringify(fieldValue)); + index += 2 + incrementValues.length + keysToDelete.length; } else if ( Array.isArray(fieldValue) && schema.fields[fieldName] && From 10547a64fa5c9782a55ab88ae6d7956edec746c0 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 21:31:26 +0000 Subject: [PATCH 035/156] chore(release): 9.6.0-alpha.3 [skip ci] # [9.6.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.2...9.6.0-alpha.3) (2026-03-09) ### Bug Fixes * SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10161](https://github.com/parse-community/parse-server/issues/10161)) ([8f82282](https://github.com/parse-community/parse-server/commit/8f822826a48169528a66626118bbaead3064b055)) --- 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 75c78a7498..379cab32bb 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.2...9.6.0-alpha.3) (2026-03-09) + + +### Bug Fixes + +* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10161](https://github.com/parse-community/parse-server/issues/10161)) ([8f82282](https://github.com/parse-community/parse-server/commit/8f822826a48169528a66626118bbaead3064b055)) + # [9.6.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.1...9.6.0-alpha.2) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index a881a57656..0d3205b495 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.2", + "version": "9.6.0-alpha.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.2", + "version": "9.6.0-alpha.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index bdcb61642d..072d75896e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.2", + "version": "9.6.0-alpha.3", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 03287cf83bc05ee08bb29885d38a86e722cc3bf9 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:50:23 +0000 Subject: [PATCH 036/156] fix: Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) (#10162) --- spec/vulnerabilities.spec.js | 215 +++++++++++++++++++++++++++++++++++ src/Options/Definitions.js | 6 +- src/Options/docs.js | 2 +- src/Options/index.js | 4 +- 4 files changed, 222 insertions(+), 5 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 3b1cbe81ae..f1218eb760 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1021,6 +1021,221 @@ describe('(GHSA-qpr4-jrj4-6f27) SQL Injection via sort dot-notation field name', }); }); +describe('(GHSA-v5hf-f4c3-m5rv) Stored XSS via .svgz, .xht, .xml, .xsl, .xslt file upload', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + it('blocks .svgz file upload by default', async () => { + const svgContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['svgz', 'SVGZ', 'Svgz']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'image/svg+xml', + base64: svgContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xht file upload by default', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xht', 'XHT', 'Xht']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xhtml+xml', + base64: xhtContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xml file upload by default', async () => { + const xmlContent = Buffer.from( + 'test' + ).toString('base64'); + for (const extension of ['xml', 'XML', 'Xml']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xmlContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xsl file upload by default', async () => { + const xslContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xsl', 'XSL', 'Xsl']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xml', + base64: xslContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + it('blocks .xslt file upload by default', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + for (const extension of ['xslt', 'XSLT', 'Xslt']) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + + // Headers are intentionally omitted below so that the middleware parses _ContentType + // from the JSON body and sets it as the content-type header. When X-Parse-Application-Id + // is sent as a header, the middleware skips body parsing and _ContentType is ignored. + it('blocks extensionless upload with application/xhtml+xml content type', async () => { + const xhtContent = Buffer.from( + '' + ).toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xhtml+xml', + base64: xhtContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xhtml+xml is disabled.' + ) + ); + }); + + it('blocks extensionless upload with application/xslt+xml content type', async () => { + const xsltContent = Buffer.from( + '' + ).toString('base64'); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'application/xslt+xml', + base64: xsltContent, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + 'File upload of extension xslt+xml is disabled.' + ) + ); + }); + + it('still allows common file types', async () => { + for (const type of ['txt', 'png', 'jpg', 'gif', 'pdf', 'doc']) { + const file = new Parse.File(`file.${type}`, { base64: 'ParseA==' }); + await file.save(); + } + }); +}); + describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { it('sets X-Content-Type-Options: nosniff on file GET response', async () => { const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 4cf036895f..1359e1b27f 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -1059,9 +1059,11 @@ module.exports.FileUploadOptions = { }, fileExtensions: { env: 'PARSE_SERVER_FILE_UPLOAD_FILE_EXTENSIONS', - help: "Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser.", + help: "Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser.", action: parsers.arrayParser, - default: ['^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)'], + default: [ + '^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)', + ], }, }; /* The available log levels for Parse Server logging. Valid values are:
- `'error'` - Error level (highest priority)
- `'warn'` - Warning level
- `'info'` - Info level (default)
- `'verbose'` - Verbose level
- `'debug'` - Debug level
- `'silly'` - Silly level (lowest priority) */ diff --git a/src/Options/docs.js b/src/Options/docs.js index e7414890b8..54a49f5fb8 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -248,7 +248,7 @@ * @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users. * @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users. * @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication. - * @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. + * @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. */ /** diff --git a/src/Options/index.js b/src/Options/index.js index 3dd4ad9351..b8e239e37f 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -648,8 +648,8 @@ export interface PasswordPolicyOptions { } export interface FileUploadOptions { - /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML and SVG files are especially problematic as they may be used by an attacker who uploads a HTML form or SVG image to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. - :DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?|[sS][vV][gG](\\+[xX][mM][lL])?)$)"] */ + /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. + :DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)"] */ fileExtensions: ?(string[]); /* Is true if file upload should be allowed for anonymous users. :DEFAULT: false */ From d306b02849ddfe02f47516388acfdb5877d6de78 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 9 Mar 2026 23:51:40 +0000 Subject: [PATCH 037/156] chore(release): 9.6.0-alpha.4 [skip ci] # [9.6.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.3...9.6.0-alpha.4) (2026-03-09) ### Bug Fixes * Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) ([#10162](https://github.com/parse-community/parse-server/issues/10162)) ([03287cf](https://github.com/parse-community/parse-server/commit/03287cf83bc05ee08bb29885d38a86e722cc3bf9)) --- 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 379cab32bb..8b1ff6bec9 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.3...9.6.0-alpha.4) (2026-03-09) + + +### Bug Fixes + +* Stored XSS via file upload of HTML-renderable file types ([GHSA-v5hf-f4c3-m5rv](https://github.com/parse-community/parse-server/security/advisories/GHSA-v5hf-f4c3-m5rv)) ([#10162](https://github.com/parse-community/parse-server/issues/10162)) ([03287cf](https://github.com/parse-community/parse-server/commit/03287cf83bc05ee08bb29885d38a86e722cc3bf9)) + # [9.6.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.2...9.6.0-alpha.3) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index 0d3205b495..b7c3d3b225 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.3", + "version": "9.6.0-alpha.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.3", + "version": "9.6.0-alpha.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 072d75896e..e1610af176 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.3", + "version": "9.6.0-alpha.4", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 169d69257dda670daf0b20a967d0598a90510c82 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:59:42 +0000 Subject: [PATCH 038/156] fix: SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) (#10165) --- spec/vulnerabilities.spec.js | 102 ++++++++++++++++++ .../Postgres/PostgresStorageAdapter.js | 7 +- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index f1218eb760..9ad576fa35 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1352,3 +1352,105 @@ describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Obj expect(verify.get('stats').counter).toBe(8); }); }); + +describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in Increment operation', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it_only_db('postgres')('does not execute injected SQL via single quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + "stats.x' || (SELECT pg_sleep(3))::text || '": { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // If injection succeeded, query would take >= 3 seconds + expect(elapsed).toBeLessThan(3000); + // The escaped payload becomes a harmless literal key; original data is untouched + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('does not execute injected SQL via double quote in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x" || (SELECT pg_sleep(3))::text || "': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + // Double quotes break JSON structure inside the CONCAT, producing invalid JSONB. + // This causes a database error, NOT SQL injection. If injection succeeded, + // the query would take >= 3 seconds due to pg_sleep. + expect(elapsed).toBeLessThan(3000); + // Invalid JSONB cast fails the UPDATE, so the row is not modified + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats')).toEqual({ counter: 0 }); + }); + + it_only_db('postgres')('does not execute injected SQL via double quote crafted as valid JSONB in sub-key name', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 0 }); + await obj.save(); + + // This payload uses double quotes to craft a sub-key that produces valid JSONB + // (e.g. '{"x":0,"evil":1}') instead of breaking JSON structure. Even so, both + // interpolation sites are inside single-quoted SQL strings, so double quotes + // cannot escape the SQL context — no arbitrary SQL execution is possible. + const start = Date.now(); + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.x":0,"pg_sleep(3)': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(3000); + // Double quotes craft valid JSONB with extra keys, but no SQL injection occurs; + // original counter is untouched + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(0); + }); + + it_only_db('postgres')('allows valid Increment on nested object field with normal sub-key', async () => { + const obj = new Parse.Object('SubKeyTest'); + obj.set('stats', { counter: 5 }); + await obj.save(); + + const response = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'stats.counter': { __op: 'Increment', amount: 2 }, + }), + }); + + expect(response.status).toBe(200); + const verify = await new Parse.Query('SubKeyTest').get(obj.id); + expect(verify.get('stats').counter).toBe(7); + }); +}); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index e9757fceb0..97933cfed2 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -208,6 +208,8 @@ const handleDotFields = object => { return object; }; +const escapeSqlString = value => value.replace(/'/g, "''"); + const transformDotFieldToComponents = fieldName => { return fieldName.split('.').map((cmpt, index) => { if (index === 0) { @@ -216,7 +218,7 @@ const transformDotFieldToComponents = fieldName => { if (isArrayIndex(cmpt)) { return Number(cmpt); } else { - return `'${cmpt.replace(/'/g, "''")}'`; + return `'${escapeSqlString(cmpt)}'`; } }); }; @@ -1747,7 +1749,8 @@ export class PostgresStorageAdapter implements StorageAdapter { } incrementValues.push(amount); const amountIndex = index + incrementValues.length; - return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + $${amountIndex}, '}')::jsonb`; + const safeName = escapeSqlString(c); + return `CONCAT('{"${safeName}":', COALESCE($${index}:name->>'${safeName}','0')::int + $${amountIndex}, '}')::jsonb`; }) .join(' || '); // Strip the keys From 27add6d03bf595ac34ed5b57110629044c25892d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 10 Mar 2026 01:01:29 +0000 Subject: [PATCH 039/156] chore(release): 9.6.0-alpha.5 [skip ci] # [9.6.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.4...9.6.0-alpha.5) (2026-03-10) ### Bug Fixes * SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) ([#10165](https://github.com/parse-community/parse-server/issues/10165)) ([169d692](https://github.com/parse-community/parse-server/commit/169d69257dda670daf0b20a967d0598a90510c82)) --- 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 8b1ff6bec9..b49cd5871f 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.4...9.6.0-alpha.5) (2026-03-10) + + +### Bug Fixes + +* SQL Injection via dot-notation sub-key name in `Increment` operation on PostgreSQL ([GHSA-gqpp-xgvh-9h7h](https://github.com/parse-community/parse-server/security/advisories/GHSA-gqpp-xgvh-9h7h)) ([#10165](https://github.com/parse-community/parse-server/issues/10165)) ([169d692](https://github.com/parse-community/parse-server/commit/169d69257dda670daf0b20a967d0598a90510c82)) + # [9.6.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.3...9.6.0-alpha.4) (2026-03-09) diff --git a/package-lock.json b/package-lock.json index b7c3d3b225..5a3f99dd8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.4", + "version": "9.6.0-alpha.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.4", + "version": "9.6.0-alpha.5", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e1610af176..4bb6f6d8e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.4", + "version": "9.6.0-alpha.5", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 8f54c5437b4f3e184956cfbb8dd46840a4357344 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:04:25 +0000 Subject: [PATCH 040/156] fix: Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) (#10167) --- spec/vulnerabilities.spec.js | 203 +++++++++++++++++++++++++++++++++++ src/RestQuery.js | 21 +++- 2 files changed, 221 insertions(+), 3 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 9ad576fa35..ba3befbe23 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1454,3 +1454,206 @@ describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in I expect(verify.get('stats').counter).toBe(7); }); }); + +describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-notation on object-type fields', () => { + let obj; + + beforeEach(async () => { + const schema = new Parse.Schema('SecretClass'); + schema.addObject('secretObj'); + schema.addString('publicField'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + }); + await schema.save(); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + it('should deny query with dot-notation on protected field in where clause', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $or', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $or: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { 'secretObj.apiKey': 'other' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $and', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with dot-notation on protected field in $nor', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + $nor: [{ 'secretObj.apiKey': 'WRONG' }], + }), + }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny query with deeply nested dot-notation on protected field', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ 'secretObj.nested.deep.key': 'value' }) }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny sort on protected field directly', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'secretObj' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should deny descending sort on protected field via dot-notation', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: '-secretObj.score' }, + }).catch(e => e); + expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.data.error).toBe('Permission denied'); + }); + + it('should still allow queries on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ publicField: 'visible' }) }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].publicField).toBe('visible'); + expect(response.data.results[0].secretObj).toBeUndefined(); + }); + + it('should still allow sort on non-protected fields', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { order: 'publicField' }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still allow master key to query protected fields with dot-notation', async () => { + const response = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + qs: { where: JSON.stringify({ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }) }, + }); + expect(response.data.results.length).toBe(1); + }); + + it('should still block direct query on protected field (existing behavior)', async () => { + const res = await request({ + method: 'GET', + url: `${Parse.serverURL}/classes/SecretClass`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify({ secretObj: { apiKey: 'SENSITIVE_KEY_123' } }) }, + }).catch(e => e); + expect(res.status).toBe(400); + }); +}); diff --git a/src/RestQuery.js b/src/RestQuery.js index f80c8f570c..d1a2696421 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -880,11 +880,12 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () { if (typeof where !== 'object' || where === null) { return; } - for (const key of protectedFields) { - if (key in where) { + for (const whereKey of Object.keys(where)) { + const rootField = whereKey.split('.')[0]; + if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) { throw createSanitizedError( Parse.Error.OPERATION_FORBIDDEN, - `This user is not allowed to query ${key} on class ${this.className}`, + `This user is not allowed to query ${whereKey} on class ${this.className}`, this.config ); } @@ -896,6 +897,20 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () { } }; checkWhere(this.restWhere); + + // Check sort keys against protected fields + if (this.findOptions.sort) { + for (const sortKey of Object.keys(this.findOptions.sort)) { + const rootField = sortKey.split('.')[0]; + if (protectedFields.includes(sortKey) || protectedFields.includes(rootField)) { + throw createSanitizedError( + Parse.Error.OPERATION_FORBIDDEN, + `This user is not allowed to sort by ${sortKey} on class ${this.className}`, + this.config + ); + } + } + } }; // Augments this.response with all pointers on an object From 6fdb720d14ad6be4c6fb6067bd665421f3645bf1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 10 Mar 2026 02:05:20 +0000 Subject: [PATCH 041/156] chore(release): 9.6.0-alpha.6 [skip ci] # [9.6.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.5...9.6.0-alpha.6) (2026-03-10) ### Bug Fixes * Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) ([#10167](https://github.com/parse-community/parse-server/issues/10167)) ([8f54c54](https://github.com/parse-community/parse-server/commit/8f54c5437b4f3e184956cfbb8dd46840a4357344)) --- 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 b49cd5871f..25aff0d683 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.5...9.6.0-alpha.6) (2026-03-10) + + +### Bug Fixes + +* Protected fields bypass via dot-notation in query and sort ([GHSA-r2m8-pxm9-9c4g](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2m8-pxm9-9c4g)) ([#10167](https://github.com/parse-community/parse-server/issues/10167)) ([8f54c54](https://github.com/parse-community/parse-server/commit/8f54c5437b4f3e184956cfbb8dd46840a4357344)) + # [9.6.0-alpha.5](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.4...9.6.0-alpha.5) (2026-03-10) diff --git a/package-lock.json b/package-lock.json index 5a3f99dd8e..7da973755d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.5", + "version": "9.6.0-alpha.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.5", + "version": "9.6.0-alpha.6", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 4bb6f6d8e4..0961c8fe90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.5", + "version": "9.6.0-alpha.6", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 620844d00a3c629e8c6d210af1904c640b18a546 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:54:46 +0000 Subject: [PATCH 042/156] test: Verify prototype pollution via application ID is blocked by middleware (#10169) --- spec/vulnerabilities.spec.js | 47 ++++++++++++++++++++++++++++++++++++ src/triggers.js | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index ba3befbe23..c38ad99fdf 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -245,6 +245,53 @@ describe('Vulnerabilities', () => { }); }); + describe('(GHSA-3v4q-4q9g-x83q) Prototype pollution via application ID in trigger store', () => { + const prototypeProperties = ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']; + + for (const prop of prototypeProperties) { + it(`rejects "${prop}" as application ID in cloud function call`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'POST', + url: 'http://localhost:8378/1/functions/testFunction', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it(`rejects "${prop}" as application ID with arbitrary API key in cloud function call`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'ANY_KEY', + }, + method: 'POST', + url: 'http://localhost:8378/1/functions/testFunction', + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(403); + }); + + it(`rejects "${prop}" as application ID in class query`, async () => { + const response = await request({ + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': prop, + 'X-Parse-REST-API-Key': 'rest', + }, + method: 'GET', + url: 'http://localhost:8378/1/classes/TestClass', + }).catch(e => e); + expect(response.status).toBe(403); + }); + } + }); + 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 a2daec42cf..b35b5f9804 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -87,7 +87,7 @@ function validateClassNameForTriggers(className, type) { return className; } -const _triggerStore = {}; +const _triggerStore = Object.create(null); const Category = { Functions: 'Functions', From 18abdd960baf97cf5dce5cd46ca6b0b874218d94 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Mar 2026 04:22:17 +0000 Subject: [PATCH 043/156] fix: MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) (#10170) --- spec/AuthenticationAdapters.spec.js | 54 +++++++++++++++++++++++++++++ src/Adapters/Auth/mfa.js | 11 ++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index f7f3d8d45e..cd47130903 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -1913,6 +1913,60 @@ describe('OTP TOTP auth adatper', () => { ); }); + it('consumes recovery code after use', async () => { + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + // First login with recovery code should succeed + await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }); + // Second login with same recovery code should fail (code consumed) + await expectAsync( + request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }).catch(e => { + throw e.data; + }) + ).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' }); + }); + it('future logins reject incorrect TOTP token', async () => { const user = await Parse.User.signUp('username', 'password'); const OTPAuth = require('otpauth'); diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index df2fa73d02..9af8601c47 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -39,6 +39,8 @@ * - Requires a secret key for setup. * - Validates the user's OTP against a time-based one-time password (TOTP) generated using the secret key. * - Supports configurable digits, period, and algorithm for TOTP generation. + * - Generates two single-use recovery codes during enrollment. Each recovery code can be used once + * in place of a TOTP token and is consumed after use. * * ## MFA Payload * The adapter requires the following `authData` fields: @@ -157,8 +159,13 @@ class MFAAdapter extends AuthAdapter { if (!secret) { return saveResponse; } - if (recovery[0] === token || recovery[1] === token) { - return saveResponse; + const recoveryIndex = recovery?.indexOf(token) ?? -1; + if (recoveryIndex >= 0) { + const updatedRecovery = [...recovery]; + updatedRecovery.splice(recoveryIndex, 1); + return { + save: { ...auth.mfa, recovery: updatedRecovery }, + }; } const totp = new TOTP({ algorithm: this.algorithm, From f0f3bbb9a5685218a237f6896395a7aebcd1c4e8 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 10 Mar 2026 04:23:10 +0000 Subject: [PATCH 044/156] chore(release): 9.6.0-alpha.7 [skip ci] # [9.6.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.6...9.6.0-alpha.7) (2026-03-10) ### Bug Fixes * MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) ([#10170](https://github.com/parse-community/parse-server/issues/10170)) ([18abdd9](https://github.com/parse-community/parse-server/commit/18abdd960baf97cf5dce5cd46ca6b0b874218d94)) --- 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 25aff0d683..40efd79560 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.6...9.6.0-alpha.7) (2026-03-10) + + +### Bug Fixes + +* MFA recovery codes not consumed after use ([GHSA-4hf6-3x24-c9m8](https://github.com/parse-community/parse-server/security/advisories/GHSA-4hf6-3x24-c9m8)) ([#10170](https://github.com/parse-community/parse-server/issues/10170)) ([18abdd9](https://github.com/parse-community/parse-server/commit/18abdd960baf97cf5dce5cd46ca6b0b874218d94)) + # [9.6.0-alpha.6](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.5...9.6.0-alpha.6) (2026-03-10) diff --git a/package-lock.json b/package-lock.json index 7da973755d..1f3a0d689d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.6", + "version": "9.6.0-alpha.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.6", + "version": "9.6.0-alpha.7", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 0961c8fe90..70725ab2ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.6", + "version": "9.6.0-alpha.7", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 936abd4905e501838e8d46503da66ce9fe6a4f9d Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:58:58 +0000 Subject: [PATCH 045/156] fix: User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) (#10172) --- spec/EmailVerificationToken.spec.js | 2 + spec/SecurityCheckGroups.spec.js | 8 + spec/vulnerabilities.spec.js | 201 ++++++++++++++++++ src/Config.js | 9 +- src/Options/Definitions.js | 6 + src/Options/docs.js | 1 + src/Options/index.js | 7 + src/Routers/UsersRouter.js | 8 + .../CheckGroups/CheckGroupServerConfig.js | 24 +++ types/Options/index.d.ts | 1 + 10 files changed, 266 insertions(+), 1 deletion(-) diff --git a/spec/EmailVerificationToken.spec.js b/spec/EmailVerificationToken.spec.js index 82114760af..11a901f399 100644 --- a/spec/EmailVerificationToken.spec.js +++ b/spec/EmailVerificationToken.spec.js @@ -1085,6 +1085,7 @@ describe('Email Verification Token Expiration:', () => { emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds publicServerURL: 'http://localhost:8378/1', + emailVerifySuccessOnInvalidEmail: false, }); user.setUsername('no_new_verification_token_once_verified'); user.setPassword('expiringToken'); @@ -1131,6 +1132,7 @@ describe('Email Verification Token Expiration:', () => { emailAdapter: emailAdapter, emailVerifyTokenValidityDuration: 5, // 5 seconds publicServerURL: 'http://localhost:8378/1', + emailVerifySuccessOnInvalidEmail: false, }); const response = await request({ url: 'http://localhost:8378/1/verificationEmailRequest', diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index 696b7da640..354fed8b0e 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -49,6 +49,8 @@ describe('Security Check Groups', () => { expect(group.checks()[6].checkState()).toBe(CheckState.success); expect(group.checks()[8].checkState()).toBe(CheckState.success); expect(group.checks()[9].checkState()).toBe(CheckState.success); + expect(group.checks()[10].checkState()).toBe(CheckState.success); + expect(group.checks()[11].checkState()).toBe(CheckState.success); }); it('checks fail correctly', async () => { @@ -67,6 +69,10 @@ describe('Security Check Groups', () => { graphQLDepth: -1, graphQLFields: -1, }; + config.passwordPolicy = { + resetPasswordSuccessOnInvalidEmail: false, + }; + config.emailVerifySuccessOnInvalidEmail = false; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -79,6 +85,8 @@ describe('Security Check Groups', () => { expect(group.checks()[6].checkState()).toBe(CheckState.fail); expect(group.checks()[8].checkState()).toBe(CheckState.fail); expect(group.checks()[9].checkState()).toBe(CheckState.fail); + expect(group.checks()[10].checkState()).toBe(CheckState.fail); + expect(group.checks()[11].checkState()).toBe(CheckState.fail); }); it_only_db('mongo')('checks succeed correctly (MongoDB specific)', async () => { diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index c38ad99fdf..cd780974b6 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1,4 +1,5 @@ const request = require('../lib/request'); +const Config = require('../lib/Config'); describe('Vulnerabilities', () => { describe('(GHSA-8xq9-g7ch-35hg) Custom object ID allows to acquire role privilege', () => { @@ -1704,3 +1705,203 @@ describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-not expect(res.status).toBe(400); }); }); + +describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { + let sendVerificationEmail; + + async function createTestUsers() { + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('password123'); + user.set('email', 'unverified@example.com'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('verifieduser'); + user2.setPassword('password123'); + user2.set('email', 'verified@example.com'); + await user2.signUp(); + const config = Config.get(Parse.applicationId); + await config.database.update( + '_User', + { username: 'verifieduser' }, + { emailVerified: true } + ); + } + + describe('default (emailVerifySuccessOnInvalidEmail: true)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + it('returns success for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + }); + + it('returns success for unverified email', async () => { + sendVerificationEmail.calls.reset(); + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(response.status).toBe(200); + expect(response.data).toEqual({}); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + + it('does not send verification email for non-existent email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + + it('does not send verification email for already verified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + expect(sendVerificationEmail).not.toHaveBeenCalled(); + }); + }); + + describe('opt-out (emailVerifySuccessOnInvalidEmail: false)', () => { + beforeEach(async () => { + sendVerificationEmail = jasmine.createSpy('sendVerificationEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }); + await createTestUsers(); + }); + + it('returns error for non-existent email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'nonexistent@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.EMAIL_NOT_FOUND); + }); + + it('returns error for already verified email', async () => { + const response = await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'verified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.OTHER_CAUSE); + expect(response.data.error).toBe('Email verified@example.com is already verified.'); + }); + + it('sends verification email for unverified email', async () => { + sendVerificationEmail.calls.reset(); + await request({ + url: 'http://localhost:8378/1/verificationEmailRequest', + method: 'POST', + body: { email: 'unverified@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }); + await jasmine.timeout(); + expect(sendVerificationEmail).toHaveBeenCalledTimes(1); + }); + }); + + it('rejects invalid emailVerifySuccessOnInvalidEmail values', async () => { + const invalidValues = [[], {}, 0, 1, '', 'string']; + for (const value of invalidValues) { + await expectAsync( + reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: value, + emailAdapter: { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + }) + ).toBeRejectedWith('emailVerifySuccessOnInvalidEmail must be a boolean value'); + } + }); +}); diff --git a/src/Config.js b/src/Config.js index 920f72d1e6..c164594457 100644 --- a/src/Config.js +++ b/src/Config.js @@ -200,6 +200,7 @@ export class Config { _publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }) { const emailAdapter = userController.adapter; if (verifyUserEmails) { @@ -209,6 +210,7 @@ export class Config { publicServerURL: publicServerURL || _publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }); } } @@ -457,11 +459,12 @@ export class Config { } if ( - passwordPolicy.resetPasswordSuccessOnInvalidEmail && + passwordPolicy.resetPasswordSuccessOnInvalidEmail !== undefined && typeof passwordPolicy.resetPasswordSuccessOnInvalidEmail !== 'boolean' ) { throw 'resetPasswordSuccessOnInvalidEmail must be a boolean value'; } + } } @@ -504,6 +507,7 @@ export class Config { publicServerURL, emailVerifyTokenValidityDuration, emailVerifyTokenReuseIfValid, + emailVerifySuccessOnInvalidEmail, }) { if (!emailAdapter) { throw 'An emailAdapter is required for e-mail verification and password resets.'; @@ -525,6 +529,9 @@ export class Config { if (emailVerifyTokenReuseIfValid && !emailVerifyTokenValidityDuration) { throw 'You cannot use emailVerifyTokenReuseIfValid without emailVerifyTokenValidityDuration'; } + if (emailVerifySuccessOnInvalidEmail !== undefined && typeof emailVerifySuccessOnInvalidEmail !== 'boolean') { + throw 'emailVerifySuccessOnInvalidEmail must be a boolean value'; + } } static validateFileUploadOptions(fileUpload) { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 1359e1b27f..672325c072 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -197,6 +197,12 @@ module.exports.ParseServerOptions = { help: 'Adapter module for email sending', action: parsers.moduleOrObjectParser, }, + emailVerifySuccessOnInvalidEmail: { + env: 'PARSE_SERVER_EMAIL_VERIFY_SUCCESS_ON_INVALID_EMAIL', + help: 'Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.

Default is `true`.
Requires option `verifyUserEmails: true`.', + action: parsers.booleanParser, + default: true, + }, emailVerifyTokenReuseIfValid: { env: 'PARSE_SERVER_EMAIL_VERIFY_TOKEN_REUSE_IF_VALID', help: 'Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.
Requires option `verifyUserEmails: true`.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 54a49f5fb8..e076f591aa 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -39,6 +39,7 @@ * @property {Boolean} directAccess Set to `true` if Parse requests within the same Node.js environment as Parse Server should be routed to Parse Server directly instead of via the HTTP interface. Default is `false`.

If set to `false` then Parse requests within the same Node.js environment as Parse Server are executed as HTTP requests sent to Parse Server via the `serverURL`. For example, a `Parse.Query` in Cloud Code is calling Parse Server via a HTTP request. The server is essentially making a HTTP request to itself, unnecessarily using network resources such as network ports.

⚠️ In environments where multiple Parse Server instances run behind a load balancer and Parse requests within the current Node.js environment should be routed via the load balancer and distributed as HTTP requests among all instances via the `serverURL`, this should be set to `false`. * @property {String} dotNetKey Key for Unity and .Net SDK * @property {Adapter} emailAdapter Adapter module for email sending + * @property {Boolean} emailVerifySuccessOnInvalidEmail Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases.

Default is `true`.
Requires option `verifyUserEmails: true`. * @property {Boolean} emailVerifyTokenReuseIfValid Set to `true` if a email verification token should be reused in case another token is requested but there is a token that is still valid, i.e. has not expired. This avoids the often observed issue that a user requests multiple emails and does not know which link contains a valid token because each newly generated token would invalidate the previous token.

Default is `false`.
Requires option `verifyUserEmails: true`. * @property {Number} emailVerifyTokenValidityDuration Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours).

Default is `undefined`.
Requires option `verifyUserEmails: true`. * @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true diff --git a/src/Options/index.js b/src/Options/index.js index b8e239e37f..2d0c91f33a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -235,6 +235,13 @@ export interface ParseServerOptions { Requires option `verifyUserEmails: true`. :DEFAULT: false */ emailVerifyTokenReuseIfValid: ?boolean; + /* Set to `true` if a request to verify the email should return a success response even if the provided email address does not belong to a verifiable account, for example because it is unknown or already verified, or `false` if the request should return an error response in those cases. +

+ Default is `true`. +
+ Requires option `verifyUserEmails: true`. + :DEFAULT: true */ + emailVerifySuccessOnInvalidEmail: ?boolean; /* Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.

Default is `true`. diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index edd1bdae4e..2ca2da2641 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -547,8 +547,13 @@ export class UsersRouter extends ClassesRouter { ); } + const verifyEmailSuccessOnInvalidEmail = req.config.emailVerifySuccessOnInvalidEmail ?? true; + const results = await req.config.database.find('_User', { email: email }, {}, Auth.maintenance(req.config)); if (!results.length || results.length < 1) { + if (verifyEmailSuccessOnInvalidEmail) { + return { response: {} }; + } throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `No user found with email ${email}`); } const user = results[0]; @@ -557,6 +562,9 @@ export class UsersRouter extends ClassesRouter { delete user.password; if (user.emailVerified) { + if (verifyEmailSuccessOnInvalidEmail) { + return { response: {} }; + } throw new Parse.Error(Parse.Error.OTHER_CAUSE, `Email ${email} is already verified.`); } diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index d8d8e647e6..36b327dc91 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -151,6 +151,30 @@ class CheckGroupServerConfig extends CheckGroup { } }, }), + new Check({ + title: 'Password reset endpoint user enumeration mitigated', + warning: + 'The password reset endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.', + solution: + "Change Parse Server configuration to 'passwordPolicy.resetPasswordSuccessOnInvalidEmail: true'.", + check: () => { + if (config.passwordPolicy?.resetPasswordSuccessOnInvalidEmail === false) { + throw 1; + } + }, + }), + new Check({ + title: 'Email verification endpoint user enumeration mitigated', + warning: + 'The email verification endpoint returns distinct error responses for invalid email addresses, which allows attackers to enumerate registered users.', + solution: + "Change Parse Server configuration to 'emailVerifySuccessOnInvalidEmail: true'.", + check: () => { + if (config.emailVerifySuccessOnInvalidEmail === false) { + throw 1; + } + }, + }), new Check({ title: 'LiveQuery regex timeout enabled', warning: diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index 49e58cc1df..6a8b1494ac 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -95,6 +95,7 @@ export interface ParseServerOptions { preventSignupWithUnverifiedEmail?: boolean; emailVerifyTokenValidityDuration?: number; emailVerifyTokenReuseIfValid?: boolean; + emailVerifySuccessOnInvalidEmail?: boolean; sendUserEmailVerification?: boolean | ((params: SendEmailVerificationRequest) => boolean | Promise); accountLockout?: AccountLockoutOptions; passwordPolicy?: PasswordPolicyOptions; From 3da580a20ec54ffd556ecbe8eb1913205cdf7c26 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 10 Mar 2026 14:00:00 +0000 Subject: [PATCH 046/156] chore(release): 9.6.0-alpha.8 [skip ci] # [9.6.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.7...9.6.0-alpha.8) (2026-03-10) ### Bug Fixes * User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) ([#10172](https://github.com/parse-community/parse-server/issues/10172)) ([936abd4](https://github.com/parse-community/parse-server/commit/936abd4905e501838e8d46503da66ce9fe6a4f9d)) --- 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 40efd79560..7c2f642294 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.7...9.6.0-alpha.8) (2026-03-10) + + +### Bug Fixes + +* User enumeration via email verification endpoint ([GHSA-w54v-hf9p-8856](https://github.com/parse-community/parse-server/security/advisories/GHSA-w54v-hf9p-8856)) ([#10172](https://github.com/parse-community/parse-server/issues/10172)) ([936abd4](https://github.com/parse-community/parse-server/commit/936abd4905e501838e8d46503da66ce9fe6a4f9d)) + # [9.6.0-alpha.7](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.6...9.6.0-alpha.7) (2026-03-10) diff --git a/package-lock.json b/package-lock.json index 1f3a0d689d..ede082b6db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.7", + "version": "9.6.0-alpha.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.7", + "version": "9.6.0-alpha.8", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 70725ab2ad..eff2faad0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.7", + "version": "9.6.0-alpha.8", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0744225caf2e9a040d77ea51ae7b3e67042faa59 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:29:34 +0000 Subject: [PATCH 047/156] docs: Clarify user lockout when setting empty ACL (#10174) --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 4 +++- src/Routers/UsersRouter.js | 8 ++++---- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 672325c072..401650f838 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -54,7 +54,7 @@ module.exports.SchemaOptions = { module.exports.ParseServerOptions = { accountLockout: { env: 'PARSE_SERVER_ACCOUNT_LOCKOUT', - help: 'The account lockout policy for failed login attempts.', + help: "The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL.", action: parsers.objectParser, type: 'AccountLockoutOptions', }, diff --git a/src/Options/docs.js b/src/Options/docs.js index e076f591aa..52c5e68c07 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -12,7 +12,7 @@ /** * @interface ParseServerOptions - * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts. + * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId * @property {Boolean} allowExpiredAuthDataToken Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`. diff --git a/src/Options/index.js b/src/Options/index.js index 2d0c91f33a..268022fa5d 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -251,7 +251,9 @@ export interface ParseServerOptions { | boolean | (SendEmailVerificationRequest => boolean | Promise) ); - /* The account lockout policy for failed login attempts. */ + /* The account lockout policy for failed login attempts. +

+ Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. */ accountLockout: ?AccountLockoutOptions; /* The password policy for enforcing password related rules. */ passwordPolicy: ?PasswordPolicyOptions; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 2ca2da2641..7d8f98a538 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -132,10 +132,10 @@ export class UsersRouter extends ClassesRouter { if (!isValidPassword) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - // Ensure the user isn't locked out - // A locked out user won't be able to login - // To lock a user out, just set the ACL to `masterKey` only ({}). - // Empty ACL is OK + // A user with an empty ACL (master key only) is considered locked out and + // cannot log in. This only prevents new logins; existing session tokens + // remain valid. To immediately revoke access, also destroy the user's + // sessions via master key. if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } From 4d48847e9909c70761be381d3c3cddcfa9f0fca3 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:53:25 +0000 Subject: [PATCH 048/156] fix: Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) (#10175) --- spec/vulnerabilities.spec.js | 207 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 35 +++++ 2 files changed, 242 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index cd780974b6..3f95c7c4b6 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1706,6 +1706,213 @@ describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-not }); }); +describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => { + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists( + 'SecretClass', + { secretObj: { type: 'Object' }, publicField: { type: 'String' } }, + ); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with dot-notation on protected field in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field directly in where clause', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $or', async () => { + const q1 = new Parse.Query('SecretClass'); + q1._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const q2 = new Parse.Query('SecretClass'); + q2._addCondition('secretObj.apiKey', '$eq', 'other'); + const query = Parse.Query.or(q1, q2); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $and', async () => { + // Build $and manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with protected field in $nor', async () => { + // Build $nor manually since Parse SDK doesn't expose it directly + const query = new Parse.Query('SecretClass'); + query._where = { $nor: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }] }; + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with $regex on protected field (boolean oracle)', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$regex', '^S'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field', async () => { + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.nested.deep.key', '$eq', 'value'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should allow LiveQuery subscription on non-protected fields and strip protected fields from response', async () => { + const query = new Parse.Query('SecretClass'); + query.exists('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should reject admin user querying protected field when both * and role protect it', async () => { + // Common case: protectedFields has both '*' and 'role:admin' entries. + // Even without resolving user roles, the '*' protection applies and blocks the query. + // This validates that role-based exemptions are irrelevant when '*' covers the field. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'], 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + await expectAsync(query.subscribe(user.getSessionToken())).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should not reject when role-only protection exists without * entry', async () => { + // Edge case: protectedFields only has a role entry, no '*'. + // Without resolving roles, the protection set is empty, so the subscription is allowed. + // This is a correctness gap, not a security issue: the role entry means "protect this + // field FROM role members" (i.e. admins should not see it). Not resolving roles means + // the admin loses their own restriction — they see data meant to be hidden from them. + // This does not allow unprivileged users to access protected data. + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { 'role:admin': ['secretObj'] }, + } + ); + + const user = new Parse.User(); + user.setUsername('adminuser2'); + user.setPassword('password'); + await user.signUp(); + + const roleACL = new Parse.ACL(); + roleACL.setPublicReadAccess(true); + const role = new Parse.Role('admin', roleACL); + role.getUsers().add(user); + await role.save(null, { useMasterKey: true }); + + // This subscribes successfully because without '*' entry, no fields are protected + // for purposes of WHERE clause validation. The role-only config means "hide secretObj + // from admins" — a restriction ON the privileged user, not a security boundary. + const query = new Parse.Query('SecretClass'); + query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123'); + const subscription = await query.subscribe(user.getSessionToken()); + expect(subscription).toBeDefined(); + }); + + // Note: master key bypass is inherently tested by the `!client.hasMasterKey` guard + // in the implementation. Testing master key LiveQuery requires configuring keyPairs + // in the LiveQuery server config, which is not part of the default test setup. +}); + describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => { let sendVerificationEmail; diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index d8badaa913..3deaa0e7b2 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -916,6 +916,41 @@ class ParseLiveQueryServer { op ); + // Check protected fields in WHERE clause + if (!client.hasMasterKey) { + const auth = request.user ? { user: request.user, userRoles: [] } : {}; + const protectedFields = + appConfig.database.addProtectedFields( + classLevelPermissions, + className, + request.query.where, + aclGroup, + auth + ) || []; + if (protectedFields.length > 0 && request.query.where) { + const checkWhere = (where: any) => { + if (typeof where !== 'object' || where === null) { + return; + } + for (const whereKey of Object.keys(where)) { + const rootField = whereKey.split('.')[0]; + if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Permission denied' + ); + } + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + where[op].forEach((subQuery: any) => checkWhere(subQuery)); + } + } + }; + checkWhere(request.query.where); + } + } + // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); // Add className to subscriptions if necessary From c795c14a77efe1fd16a02b33cb91ce98cb355772 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 10 Mar 2026 20:54:25 +0000 Subject: [PATCH 049/156] chore(release): 9.6.0-alpha.9 [skip ci] # [9.6.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.8...9.6.0-alpha.9) (2026-03-10) ### Bug Fixes * Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) ([#10175](https://github.com/parse-community/parse-server/issues/10175)) ([4d48847](https://github.com/parse-community/parse-server/commit/4d48847e9909c70761be381d3c3cddcfa9f0fca3)) --- 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 7c2f642294..beef1749ae 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.8...9.6.0-alpha.9) (2026-03-10) + + +### Bug Fixes + +* Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-j7mm-f4rv-6q6q)) ([#10175](https://github.com/parse-community/parse-server/issues/10175)) ([4d48847](https://github.com/parse-community/parse-server/commit/4d48847e9909c70761be381d3c3cddcfa9f0fca3)) + # [9.6.0-alpha.8](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.7...9.6.0-alpha.8) (2026-03-10) diff --git a/package-lock.json b/package-lock.json index ede082b6db..2d5f3372e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.8", + "version": "9.6.0-alpha.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.8", + "version": "9.6.0-alpha.9", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index eff2faad0d..d639e66c9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.8", + "version": "9.6.0-alpha.9", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From b43b22467d0221b61189a8513d9c8c29cfce620d Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:22:41 +0000 Subject: [PATCH 050/156] SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) (#10177) --- spec/vulnerabilities.spec.js | 255 ++++++++++++++++++ .../Postgres/PostgresStorageAdapter.js | 13 +- src/Controllers/DatabaseController.js | 84 ++++-- 3 files changed, 322 insertions(+), 30 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 3f95c7c4b6..f9ed669079 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2112,3 +2112,258 @@ describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint } }); }); + +describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + + beforeEach(async () => { + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + await obj.save(null, { useMasterKey: true }); + }); + + it('rejects field names containing double quotes in $regex query with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing single quotes in $regex query with master key', async () => { + const maliciousField = "playerName' OR '1'='1"; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing semicolons in $regex query with master key', async () => { + const maliciousField = 'playerName; DROP TABLE "TestClass" --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects field names containing parentheses in $regex query with master key', async () => { + const maliciousField = 'playerName" ~ \'x\' OR (SELECT 1) --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $regex: 'x' }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('allows legitimate $regex query with master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); + }); + + it('allows legitimate $regex query with dot notation and master key', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('metadata', { tag: 'hello-world' }); + await obj.save(null, { useMasterKey: true }); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + 'metadata.tag': { $regex: 'hello' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].metadata.tag).toBe('hello-world'); + }); + + it('allows legitimate $regex query without master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { + where: JSON.stringify({ + playerName: { $regex: 'Ali' }, + }), + }, + }); + expect(response.data.results.length).toBe(1); + expect(response.data.results[0].playerName).toBe('Alice'); + }); + + it('rejects field names with SQL injection via non-$regex operators with master key', async () => { + const maliciousField = 'playerName" OR 1=1 --'; + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ + [maliciousField]: { $exists: true }, + }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('validateQuery key name enforcement', () => { + const maliciousField = 'field"; DROP TABLE test --'; + const noMasterHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects malicious field name in find without master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('rejects malicious field name in find with master key', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/TestClass`, + headers, + qs: { + where: JSON.stringify({ [maliciousField]: 'value' }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it('allows master key to query whitelisted internal field _email_verify_token', async () => { + await reconfigureServer({ + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + }); + const user = new Parse.User(); + user.setUsername('testuser'); + user.setPassword('testpass'); + user.setEmail('test@example.com'); + await user.signUp(); + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }); + expect(response.data.results.length).toBeGreaterThan(0); + }); + + it('rejects non-master key querying internal field _email_verify_token', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/classes/_User`, + headers: noMasterHeaders, + qs: { + where: JSON.stringify({ _email_verify_token: { $exists: true } }), + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + describe('non-master key cannot update internal fields', () => { + const internalFields = [ + '_rperm', + '_wperm', + '_hashed_password', + '_email_verify_token', + '_perishable_token', + '_perishable_token_expires_at', + '_email_verify_token_expires_at', + '_failed_login_count', + '_account_lockout_expires_at', + '_password_changed_at', + '_password_history', + '_tombstone', + '_session_token', + ]; + + for (const field of internalFields) { + it(`rejects non-master key updating ${field}`, async () => { + const user = new Parse.User(); + user.setUsername(`updatetest_${field}`); + user.setPassword('password123'); + await user.signUp(); + const response = await request({ + method: 'PUT', + url: `${serverURL}/classes/_User/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ [field]: 'malicious_value' }), + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + } + }); + }); +}); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 97933cfed2..3dd4c6e5d1 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -225,7 +225,7 @@ const transformDotFieldToComponents = fieldName => { const transformDotField = fieldName => { if (fieldName.indexOf('.') === -1) { - return `"${fieldName}"`; + return `"${fieldName.replace(/"/g, '""')}"`; } const components = transformDotFieldToComponents(fieldName); let name = components.slice(0, components.length - 1).join('->'); @@ -760,11 +760,16 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus } } - const name = transformDotField(fieldName); regex = processRegexPattern(regex); - patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`); - values.push(name, regex); + if (fieldName.indexOf('.') >= 0) { + const name = transformDotField(fieldName); + patterns.push(`$${index}:raw ${operator} '$${index + 1}:raw'`); + values.push(name, regex); + } else { + patterns.push(`$${index}:name ${operator} '$${index + 1}:raw'`); + values.push(fieldName, regex); + } index += 2; } diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 3784c8755f..8ee9d8d01a 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -22,6 +22,61 @@ import type { ParseServerOptions } from '../Options'; import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter'; import { createSanitizedError } from '../Error'; +// Query operators that always pass validation regardless of auth level. +const queryOperators = ['$and', '$or', '$nor']; + +// Registry of internal fields with access permissions. +// Internal fields are never directly writable by clients, so clientWrite is omitted. +// - clientRead: any client can use this field in queries +// - masterRead: master key can use this field in queries +// - masterWrite: master key can use this field in updates +const internalFields = { + _rperm: { clientRead: true, masterRead: true, masterWrite: true }, + _wperm: { clientRead: true, masterRead: true, masterWrite: true }, + _hashed_password: { clientRead: false, masterRead: false, masterWrite: true }, + _email_verify_token: { clientRead: false, masterRead: true, masterWrite: true }, + _perishable_token: { clientRead: false, masterRead: true, masterWrite: true }, + _perishable_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _email_verify_token_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _failed_login_count: { clientRead: false, masterRead: true, masterWrite: true }, + _account_lockout_expires_at: { clientRead: false, masterRead: true, masterWrite: true }, + _password_changed_at: { clientRead: false, masterRead: true, masterWrite: true }, + _password_history: { clientRead: false, masterRead: true, masterWrite: true }, + _tombstone: { clientRead: false, masterRead: true, masterWrite: false }, + _session_token: { clientRead: false, masterRead: true, masterWrite: false }, + ///////////////////////////////////////////////////////////////////////////////////////////// + // The following fields are not accessed by their _-prefixed name through the API; + // they are mapped to REST-level names in the adapter layer or handled through + // separate code paths. + ///////////////////////////////////////////////////////////////////////////////////////////// + // System fields (mapped to REST-level names): + // _id (objectId) + // _created_at (createdAt) + // _updated_at (updatedAt) + // _last_used (lastUsed) + // _expiresAt (expiresAt) + ///////////////////////////////////////////////////////////////////////////////////////////// + // Legacy ACL format: mapped to/from _rperm/_wperm + // _acl + ///////////////////////////////////////////////////////////////////////////////////////////// + // Schema metadata: not data fields, used only for schema configuration + // _metadata + // _client_permissions + ///////////////////////////////////////////////////////////////////////////////////////////// + // Dynamic auth data fields: used only in projections and updates, not in queries + // _auth_data_ +}; + +// Derived access lists +const specialQueryKeys = [ + ...queryOperators, + ...Object.keys(internalFields).filter(k => internalFields[k].clientRead), +]; +const specialMasterQueryKeys = [ + ...queryOperators, + ...Object.keys(internalFields).filter(k => internalFields[k].masterRead), +]; + function addWriteACL(query, acl) { const newQuery = _.cloneDeep(query); //Can't be any existing '_wperm' query, we don't allow client queries on that, no need to $and @@ -56,19 +111,6 @@ const transformObjectACL = ({ ACL, ...result }) => { return result; }; -const specialQueryKeys = ['$and', '$or', '$nor', '_rperm', '_wperm']; -const specialMasterQueryKeys = [ - ...specialQueryKeys, - '_email_verify_token', - '_perishable_token', - '_tombstone', - '_email_verify_token_expires_at', - '_failed_login_count', - '_account_lockout_expires_at', - '_password_changed_at', - '_password_history', -]; - const validateQuery = ( query: any, isMaster: boolean, @@ -122,8 +164,8 @@ const validateQuery = ( } if ( !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/) && - ((!specialQueryKeys.includes(key) && !isMaster && !update) || - (update && isMaster && !specialMasterQueryKeys.includes(key))) + !specialQueryKeys.includes(key) && + !(isMaster && specialMasterQueryKeys.includes(key)) ) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid key name: ${key}`); } @@ -250,17 +292,7 @@ const filterSensitiveData = ( // acl: a list of strings. If the object to be updated has an ACL, // one of the provided strings must provide the caller with // write permissions. -const specialKeysForUpdate = [ - '_hashed_password', - '_perishable_token', - '_email_verify_token', - '_email_verify_token_expires_at', - '_account_lockout_expires_at', - '_failed_login_count', - '_perishable_token_expires_at', - '_password_changed_at', - '_password_history', -]; +const specialKeysForUpdate = Object.keys(internalFields).filter(k => internalFields[k].masterWrite); const isSpecialUpdateKey = key => { return specialKeysForUpdate.indexOf(key) >= 0; From be281b1ed9c6b7abf992e5583fc2db7875031172 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:39:21 +0000 Subject: [PATCH 051/156] fix: SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) (#10181) From fe005c3b1d2612821b27989cabbf8ab22d30ce86 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 11 Mar 2026 14:40:22 +0000 Subject: [PATCH 052/156] chore(release): 9.6.0-alpha.10 [skip ci] # [9.6.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.9...9.6.0-alpha.10) (2026-03-11) ### Bug Fixes * SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) ([#10181](https://github.com/parse-community/parse-server/issues/10181)) ([be281b1](https://github.com/parse-community/parse-server/commit/be281b1ed9c6b7abf992e5583fc2db7875031172)) --- 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 beef1749ae..a946e8015c 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.9...9.6.0-alpha.10) (2026-03-11) + + +### Bug Fixes + +* SQL injection via query field name when using PostgreSQL ([GHSA-c442-97qw-j6c6](https://github.com/parse-community/parse-server/security/advisories/GHSA-c442-97qw-j6c6)) ([#10181](https://github.com/parse-community/parse-server/issues/10181)) ([be281b1](https://github.com/parse-community/parse-server/commit/be281b1ed9c6b7abf992e5583fc2db7875031172)) + # [9.6.0-alpha.9](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.8...9.6.0-alpha.9) (2026-03-10) diff --git a/package-lock.json b/package-lock.json index 2d5f3372e7..896ea48c6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.9", + "version": "9.6.0-alpha.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.9", + "version": "9.6.0-alpha.10", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index d639e66c9d..bd4b51dfe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.9", + "version": "9.6.0-alpha.10", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 6009bc15c8c19db436dba8078fd59244c955d7ad Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:37:14 +0000 Subject: [PATCH 053/156] fix: OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) (#10183) --- spec/vulnerabilities.spec.js | 90 ++++++++++++++++++++++++++++++++++++ src/Adapters/Auth/index.js | 2 +- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index f9ed669079..b534684ac2 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2366,4 +2366,94 @@ describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field na } }); }); + + describe('(GHSA-2cjm-2gwv-m892) OAuth2 adapter singleton shares mutable state across providers', () => { + it('should return isolated adapter instances for different OAuth2 providers', () => { + const { loadAuthAdapter } = require('../lib/Adapters/Auth/index'); + + const authOptions = { + providerA: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appA'], + }, + providerB: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appB'], + }, + }; + + const resultA = loadAuthAdapter('providerA', authOptions); + const resultB = loadAuthAdapter('providerB', authOptions); + + // Adapters must be different instances to prevent cross-contamination + expect(resultA.adapter).not.toBe(resultB.adapter); + + // After loading providerB, providerA's config must still be intact + expect(resultA.adapter.tokenIntrospectionEndpointUrl).toBe('https://a.example.com/introspect'); + expect(resultA.adapter.appIds).toEqual(['appA']); + expect(resultB.adapter.tokenIntrospectionEndpointUrl).toBe('https://b.example.com/introspect'); + expect(resultB.adapter.appIds).toEqual(['appB']); + }); + + it('should not allow concurrent OAuth2 auth requests to cross-contaminate provider config', async () => { + await reconfigureServer({ + auth: { + oauthProviderA: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://a.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appA'], + }, + oauthProviderB: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://b.example.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['appB'], + }, + }, + }); + + // Provider A: valid token with appA audience + // Provider B: valid token with appB audience + mockFetch([ + { + url: 'https://a.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user1', aud: 'appA' }), + }, + }, + { + url: 'https://b.example.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: true, sub: 'user2', aud: 'appB' }), + }, + }, + ]); + + // Both providers should authenticate independently without cross-contamination + const [userA, userB] = await Promise.all([ + Parse.User.logInWith('oauthProviderA', { + authData: { id: 'user1', access_token: 'tokenA' }, + }), + Parse.User.logInWith('oauthProviderB', { + authData: { id: 'user2', access_token: 'tokenB' }, + }), + ]); + + expect(userA.id).toBeDefined(); + expect(userB.id).toBeDefined(); + }); + }); }); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 7c4f0e7011..51dd5342ef 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -162,7 +162,7 @@ function loadAuthAdapter(provider, authOptions) { } const adapter = - defaultAdapter instanceof AuthAdapter ? defaultAdapter : Object.assign({}, defaultAdapter); + defaultAdapter instanceof AuthAdapter ? new defaultAdapter.constructor() : Object.assign({}, defaultAdapter); const keys = [ 'validateAuthData', 'validateAppId', From ad826e1bf6a2abbe19683d3d5c0ad883818f7def Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 11 Mar 2026 16:38:30 +0000 Subject: [PATCH 054/156] chore(release): 9.6.0-alpha.11 [skip ci] # [9.6.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.10...9.6.0-alpha.11) (2026-03-11) ### Bug Fixes * OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) ([#10183](https://github.com/parse-community/parse-server/issues/10183)) ([6009bc1](https://github.com/parse-community/parse-server/commit/6009bc15c8c19db436dba8078fd59244c955d7ad)) --- 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 a946e8015c..2dadd6ca5d 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.10...9.6.0-alpha.11) (2026-03-11) + + +### Bug Fixes + +* OAuth2 adapter shares mutable state across providers via singleton instance ([GHSA-2cjm-2gwv-m892](https://github.com/parse-community/parse-server/security/advisories/GHSA-2cjm-2gwv-m892)) ([#10183](https://github.com/parse-community/parse-server/issues/10183)) ([6009bc1](https://github.com/parse-community/parse-server/commit/6009bc15c8c19db436dba8078fd59244c955d7ad)) + # [9.6.0-alpha.10](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.9...9.6.0-alpha.10) (2026-03-11) diff --git a/package-lock.json b/package-lock.json index 896ea48c6f..3c516e3289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.10", + "version": "9.6.0-alpha.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.10", + "version": "9.6.0-alpha.11", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index bd4b51dfe2..1a4781f49c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.10", + "version": "9.6.0-alpha.11", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0d0a5543b35c35c12f69d5182693e50182b6faad Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:07:34 +0000 Subject: [PATCH 055/156] fix: Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) (#10185) --- spec/RegexVulnerabilities.spec.js | 164 ++++++++++++++++++++++++++++++ src/Auth.js | 4 + 2 files changed, 168 insertions(+) diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js index 3e85620a48..0686fd1fab 100644 --- a/spec/RegexVulnerabilities.spec.js +++ b/spec/RegexVulnerabilities.spec.js @@ -220,6 +220,129 @@ describe('Regex Vulnerabilities', () => { }); }); + describe('on authData id operator injection', () => { + it('should reject $regex operator in anonymous authData id on login', async () => { + // Create a victim anonymous user with a known ID prefix + const victimId = 'victim_' + Date.now(); + const signupRes = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: victimId } }, + }), + }); + expect(signupRes.data.objectId).toBeDefined(); + + // Attacker tries to login with $regex to match the victim + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: { $regex: '^victim_' } } }, + }), + }); + fail('should not allow $regex in authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should reject $ne operator in anonymous authData id on login', async () => { + const victimId = 'victim_ne_' + Date.now(); + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: victimId } }, + }), + }); + + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: { $ne: 'nonexistent' } } }, + }), + }); + fail('should not allow $ne in authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should reject $exists operator in anonymous authData id on login', async () => { + const victimId = 'victim_exists_' + Date.now(); + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: victimId } }, + }), + }); + + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: { $exists: true } } }, + }), + }); + fail('should not allow $exists in authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); + + it('should allow valid string authData id for anonymous login', async () => { + const userId = 'valid_anon_' + Date.now(); + const signupRes = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: userId } }, + }), + }); + expect(signupRes.data.objectId).toBeDefined(); + + // Same ID should successfully log in + const loginRes = await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { anonymous: { id: userId } }, + }), + }); + expect(loginRes.data.objectId).toEqual(signupRes.data.objectId); + }); + }); + describe('on resend verification email', () => { // The PagesRouter uses express.urlencoded({ extended: false }) which does not parse // nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection. @@ -354,3 +477,44 @@ describe('Regex Vulnerabilities', () => { }); }); }); + +describe('Regex Vulnerabilities - authData operator injection with custom adapter', () => { + it('should reject non-string authData id for custom auth adapter on login', async () => { + await reconfigureServer({ + auth: { + myAdapter: { + validateAuthData: () => Promise.resolve(), + validateAppId: () => Promise.resolve(), + }, + }, + }); + + const victimId = 'adapter_victim_' + Date.now(); + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { myAdapter: { id: victimId, token: 'valid' } }, + }), + }); + + try { + await request({ + url: `${serverURL}/users`, + method: 'POST', + headers, + body: JSON.stringify({ + ...keys, + _method: 'POST', + authData: { myAdapter: { id: { $regex: '^adapter_victim_' }, token: 'valid' } }, + }), + }); + fail('should not allow $regex in custom adapter authData id'); + } catch (e) { + expect(e.data.code).toEqual(Parse.Error.INVALID_VALUE); + } + }); +}); diff --git a/src/Auth.js b/src/Auth.js index 939b44dd90..ec600e18ba 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -452,6 +452,10 @@ const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAu return null; } + if (typeof providerAuthData.id !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_VALUE, `Invalid authData id for provider '${provider}'.`); + } + return { [`authData.${provider}.id`]: providerAuthData.id }; }) ); From 19a3f05c3bf211f60236f7f4b3793021e9749afe Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 11 Mar 2026 18:09:32 +0000 Subject: [PATCH 056/156] chore(release): 9.6.0-alpha.12 [skip ci] # [9.6.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.11...9.6.0-alpha.12) (2026-03-11) ### Bug Fixes * Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) ([#10185](https://github.com/parse-community/parse-server/issues/10185)) ([0d0a554](https://github.com/parse-community/parse-server/commit/0d0a5543b35c35c12f69d5182693e50182b6faad)) --- 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 2dadd6ca5d..f7b76d1497 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.11...9.6.0-alpha.12) (2026-03-11) + + +### Bug Fixes + +* Account takeover via operator injection in authentication data identifier ([GHSA-5fw2-8jcv-xh87](https://github.com/parse-community/parse-server/security/advisories/GHSA-5fw2-8jcv-xh87)) ([#10185](https://github.com/parse-community/parse-server/issues/10185)) ([0d0a554](https://github.com/parse-community/parse-server/commit/0d0a5543b35c35c12f69d5182693e50182b6faad)) + # [9.6.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.10...9.6.0-alpha.11) (2026-03-11) diff --git a/package-lock.json b/package-lock.json index 3c516e3289..7edbb4fb80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.11", + "version": "9.6.0-alpha.12", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.11", + "version": "9.6.0-alpha.12", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 1a4781f49c..f920a4c9df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.11", + "version": "9.6.0-alpha.12", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 7f9f854be7a5c1bc2263ed516b651b16b438cd5d Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:48:06 +0000 Subject: [PATCH 057/156] fix: OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) (#10187) --- spec/Adapters/Auth/oauth2.spec.js | 36 +++++++++++++++++++++++++++++++ src/Adapters/Auth/oauth2.js | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/spec/Adapters/Auth/oauth2.spec.js b/spec/Adapters/Auth/oauth2.spec.js index 7ed9f3e047..e5de368962 100644 --- a/spec/Adapters/Auth/oauth2.spec.js +++ b/spec/Adapters/Auth/oauth2.spec.js @@ -337,6 +337,42 @@ describe('OAuth2Adapter', () => { ); }); + it('should send the correct access token to the introspection endpoint during app ID validation', async () => { + const capturedTokens = []; + const originalFetch = global.fetch; + try { + global.fetch = async (url, options) => { + if (typeof url === 'string' && url === 'https://provider.com/introspect') { + const body = options?.body?.toString() || ''; + const token = new URLSearchParams(body).get('token'); + capturedTokens.push(token); + return { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'user123', + aud: 'valid-app-id', + }), + }; + } + return originalFetch(url, options); + }; + + const authData = { access_token: 'myRealAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('mockOauth', { authData }); + expect(user.id).toBeDefined(); + + // With appidField configured, validateAppId and validateAuthData both call requestTokenInfo. + // Both should receive the actual access token, not 'undefined' from argument mismatch. + expect(capturedTokens.length).toBeGreaterThanOrEqual(2); + for (const token of capturedTokens) { + expect(token).toBe('myRealAccessToken'); + } + } finally { + global.fetch = originalFetch; + } + }); + it('should reject account takeover when useridField is omitted and attacker uses their own token with victim ID', async () => { await reconfigureServer({ auth: { diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index e9e7d97379..8fe41d6f41 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -72,7 +72,7 @@ class OAuth2Adapter extends AuthAdapter { this.authorizationHeader = options.authorizationHeader; } - async validateAppId(authData) { + async validateAppId(appIds, authData) { if (!this.appidField) { return; } From 26109e923064e1e30cb988d46b1d3e31b77814c4 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 11 Mar 2026 23:49:16 +0000 Subject: [PATCH 058/156] chore(release): 9.6.0-alpha.13 [skip ci] # [9.6.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.12...9.6.0-alpha.13) (2026-03-11) ### Bug Fixes * OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10187](https://github.com/parse-community/parse-server/issues/10187)) ([7f9f854](https://github.com/parse-community/parse-server/commit/7f9f854be7a5c1bc2263ed516b651b16b438cd5d)) --- 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 f7b76d1497..77a3c4bcb9 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.12...9.6.0-alpha.13) (2026-03-11) + + +### Bug Fixes + +* OAuth2 adapter app ID validation sends wrong token to introspection endpoint ([GHSA-69xg-f649-w5g2](https://github.com/parse-community/parse-server/security/advisories/GHSA-69xg-f649-w5g2)) ([#10187](https://github.com/parse-community/parse-server/issues/10187)) ([7f9f854](https://github.com/parse-community/parse-server/commit/7f9f854be7a5c1bc2263ed516b651b16b438cd5d)) + # [9.6.0-alpha.12](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.11...9.6.0-alpha.12) (2026-03-11) diff --git a/package-lock.json b/package-lock.json index 7edbb4fb80..69ae5ef135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.12", + "version": "9.6.0-alpha.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.12", + "version": "9.6.0-alpha.13", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index f920a4c9df..d618676ed5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.12", + "version": "9.6.0-alpha.13", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 3ffba757bfc836bd034e1369f4f64304e110e375 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:23:50 +0000 Subject: [PATCH 059/156] fix: GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) (#10189) --- package-lock.json | 51 ++++++++++++--- package.json | 1 - spec/ParseGraphQLServer.spec.js | 36 +---------- spec/vulnerabilities.spec.js | 101 ++++++++++++++++++++++++++++++ src/GraphQL/ParseGraphQLServer.js | 20 +----- 5 files changed, 146 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69ae5ef135..7992b18eba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "rate-limit-redis": "4.2.0", "redis": "5.10.0", "semver": "7.7.2", - "subscriptions-transport-ws": "0.11.0", "tv4": "1.3.0", "uuid": "11.1.0", "winston": "3.19.0", @@ -7552,7 +7551,10 @@ "node_modules/backo2": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", - "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==" + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "dev": true, + "optional": true, + "peer": true }, "node_modules/backoff": { "version": "2.5.0", @@ -10256,7 +10258,10 @@ "node_modules/eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", - "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "dev": true, + "optional": true, + "peer": true }, "node_modules/execa": { "version": "5.1.1", @@ -12843,7 +12848,10 @@ "node_modules/iterall": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", - "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==" + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "dev": true, + "optional": true, + "peer": true }, "node_modules/jackspeak": { "version": "3.4.3", @@ -21058,6 +21066,9 @@ "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz", "integrity": "sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==", "deprecated": "The `subscriptions-transport-ws` package is no longer maintained. We recommend you use `graphql-ws` instead. For help migrating Apollo software to `graphql-ws`, see https://www.apollographql.com/docs/apollo-server/data/subscriptions/#switching-from-subscriptions-transport-ws For general help using `graphql-ws`, see https://github.com/enisdenjo/graphql-ws/blob/master/README.md", + "dev": true, + "optional": true, + "peer": true, "dependencies": { "backo2": "^1.0.2", "eventemitter3": "^3.1.0", @@ -21073,6 +21084,9 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -21081,6 +21095,9 @@ "version": "7.5.9", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, + "optional": true, + "peer": true, "engines": { "node": ">=8.3.0" }, @@ -27987,7 +28004,10 @@ "backo2": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", - "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==" + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "dev": true, + "optional": true, + "peer": true }, "backoff": { "version": "2.5.0", @@ -29877,7 +29897,10 @@ "eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", - "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", + "dev": true, + "optional": true, + "peer": true }, "execa": { "version": "5.1.1", @@ -31673,7 +31696,10 @@ "iterall": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz", - "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==" + "integrity": "sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg==", + "dev": true, + "optional": true, + "peer": true }, "jackspeak": { "version": "3.4.3", @@ -37372,6 +37398,9 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.11.0.tgz", "integrity": "sha512-8D4C6DIH5tGiAIpp5I0wD/xRlNiZAPGHygzCe7VzyzUoxHtawzjNAY9SUTXU05/EY2NMY9/9GF0ycizkXr1CWQ==", + "dev": true, + "optional": true, + "peer": true, "requires": { "backo2": "^1.0.2", "eventemitter3": "^3.1.0", @@ -37383,12 +37412,18 @@ "symbol-observable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", - "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true, + "optional": true, + "peer": true }, "ws": { "version": "7.5.9", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, + "optional": true, + "peer": true, "requires": {} } } diff --git a/package.json b/package.json index d618676ed5..338a0b69af 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "rate-limit-redis": "4.2.0", "redis": "5.10.0", "semver": "7.7.2", - "subscriptions-transport-ws": "0.11.0", "tv4": "1.3.0", "uuid": "11.1.0", "winston": "3.19.0", diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 2e3319c910..1d99b93183 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -3,21 +3,16 @@ const express = require('express'); const req = require('../lib/request'); const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); const FormData = require('form-data'); -const ws = require('ws'); require('./helper'); const { updateCLP } = require('./support/dev'); const pluralize = require('pluralize'); -const { getMainDefinition } = require('@apollo/client/utilities'); const createUploadLink = (...args) => import('apollo-upload-client/createUploadLink.mjs').then(({ default: fn }) => fn(...args)); -const { SubscriptionClient } = require('subscriptions-transport-ws'); -const { WebSocketLink } = require('@apollo/client/link/ws'); const { mergeSchemas } = require('@graphql-tools/schema'); const { ApolloClient, InMemoryCache, ApolloLink, - split, createHttpLink, } = require('@apollo/client/core'); const gql = require('graphql-tag'); @@ -58,7 +53,6 @@ describe('ParseGraphQLServer', () => { parseGraphQLServer = new ParseGraphQLServer(parseServer, { graphQLPath: '/graphql', playgroundPath: '/playground', - subscriptionsPath: '/subscriptions', }); const logger = require('../lib/logger').default; @@ -241,16 +235,6 @@ describe('ParseGraphQLServer', () => { }); }); - describe('createSubscriptions', () => { - it('should require initialization with config.subscriptionsPath', () => { - expect(() => - new ParseGraphQLServer(parseServer, { - graphQLPath: 'graphql', - }).createSubscriptions({}) - ).toThrow('You must provide a config.subscriptionsPath to createSubscriptions!'); - }); - }); - describe('setGraphQLConfig', () => { let parseGraphQLServer; beforeEach(() => { @@ -467,41 +451,23 @@ describe('ParseGraphQLServer', () => { parseGraphQLServer = new ParseGraphQLServer(_parseServer, { graphQLPath: '/graphql', playgroundPath: '/playground', - subscriptionsPath: '/subscriptions', ...parseGraphQLServerOptions, }); parseGraphQLServer.applyGraphQL(expressApp); parseGraphQLServer.applyPlayground(expressApp); - parseGraphQLServer.createSubscriptions(httpServer); await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve)); } beforeEach(async () => { await createGQLFromParseServer(parseServer); - const subscriptionClient = new SubscriptionClient( - 'ws://localhost:13377/subscriptions', - { - reconnect: true, - connectionParams: headers, - }, - ws - ); - const wsLink = new WebSocketLink(subscriptionClient); const httpLink = await createUploadLink({ uri: 'http://localhost:13377/graphql', fetch, headers, }); apolloClient = new ApolloClient({ - link: split( - ({ query }) => { - const { kind, operation } = getMainDefinition(query); - return kind === 'OperationDefinition' && operation === 'subscription'; - }, - wsLink, - httpLink - ), + link: httpLink, cache: new InMemoryCache(), defaultOptions: { query: { diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index b534684ac2..0769bf5393 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1,5 +1,10 @@ +const http = require('http'); +const express = require('express'); +const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); +const ws = require('ws'); const request = require('../lib/request'); const Config = require('../lib/Config'); +const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); describe('Vulnerabilities', () => { describe('(GHSA-8xq9-g7ch-35hg) Custom object ID allows to acquire role privilege', () => { @@ -2456,4 +2461,100 @@ describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field na expect(userB.id).toBeDefined(); }); }); + + describe('(GHSA-p2x3-8689-cwpg) GraphQL WebSocket middleware bypass', () => { + let httpServer; + const gqlPort = 13399; + + const gqlHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'Content-Type': 'application/json', + }; + + async function setupGraphQLServer(serverOptions = {}, graphQLOptions = {}) { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + } + const server = await reconfigureServer(serverOptions); + const expressApp = express(); + httpServer = http.createServer(expressApp); + expressApp.use('/parse', server.app); + const parseGraphQLServer = new ParseGraphQLServer(server, { + graphQLPath: '/graphql', + ...graphQLOptions, + }); + parseGraphQLServer.applyGraphQL(expressApp); + await new Promise(resolve => httpServer.listen({ port: gqlPort }, resolve)); + return parseGraphQLServer; + } + + async function gqlRequest(query, headers = gqlHeaders) { + const response = await fetch(`http://localhost:${gqlPort}/graphql`, { + method: 'POST', + headers, + body: JSON.stringify({ query }), + }); + return { status: response.status, body: await response.json().catch(() => null) }; + } + + afterEach(async () => { + if (httpServer) { + await new Promise(resolve => httpServer.close(resolve)); + httpServer = null; + } + }); + + it('should not have createSubscriptions method', async () => { + const pgServer = await setupGraphQLServer(); + expect(pgServer.createSubscriptions).toBeUndefined(); + }); + + it('should not accept WebSocket connections on /subscriptions path', async () => { + await setupGraphQLServer(); + const connectionResult = await new Promise((resolve) => { + const socket = new ws(`ws://localhost:${gqlPort}/subscriptions`); + socket.on('open', () => { + socket.close(); + resolve('connected'); + }); + socket.on('error', () => { + resolve('refused'); + }); + setTimeout(() => { + socket.close(); + resolve('timeout'); + }, 2000); + }); + expect(connectionResult).not.toBe('connected'); + }); + + it('HTTP GraphQL should still work with API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }'); + expect(result.status).toBe(200); + expect(result.body?.data?.health).toBeTruthy(); + }); + + it('HTTP GraphQL should still reject requests without API key', async () => { + await setupGraphQLServer(); + const result = await gqlRequest('{ health }', { 'Content-Type': 'application/json' }); + expect(result.status).toBe(403); + }); + + it('HTTP introspection control should still work', async () => { + await setupGraphQLServer({}, { graphQLPublicIntrospection: false }); + const result = await gqlRequest('{ __schema { types { name } } }'); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toContain('Introspection is not allowed'); + }); + + it('HTTP complexity limits should still work', async () => { + await setupGraphQLServer({ requestComplexity: { graphQLFields: 5 } }); + const fields = Array.from({ length: 10 }, (_, i) => `f${i}: health`).join(' '); + const result = await gqlRequest(`{ ${fields} }`); + expect(result.body?.errors).toBeDefined(); + expect(result.body.errors[0].message).toMatch(/exceeds maximum allowed/); + }); + }); }); diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 6c7c2ff179..3f902fb99b 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -4,8 +4,7 @@ import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@as-integrations/express5'; import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; import express from 'express'; -import { execute, subscribe, GraphQLError, parse } from 'graphql'; -import { SubscriptionServer } from 'subscriptions-transport-ws'; +import { GraphQLError, parse } from 'graphql'; import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; import requiredParameter from '../requiredParameter'; import defaultLogger from '../logger'; @@ -261,23 +260,6 @@ class ParseGraphQLServer { ); } - createSubscriptions(server) { - SubscriptionServer.create( - { - execute, - subscribe, - onOperation: async (_message, params, webSocket) => - Object.assign({}, params, await this._getGraphQLOptions(webSocket.upgradeReq)), - }, - { - server, - path: - this.config.subscriptionsPath || - requiredParameter('You must provide a config.subscriptionsPath to createSubscriptions!'), - } - ); - } - setGraphQLConfig(graphQLConfig: ParseGraphQLConfig): Promise { return this.parseGraphQLController.updateGraphQLConfig(graphQLConfig); } From 9b44d25e9f9b7e6fdf3c08ab946de25e4075dc59 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 12 Mar 2026 14:24:52 +0000 Subject: [PATCH 060/156] chore(release): 9.6.0-alpha.14 [skip ci] # [9.6.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.13...9.6.0-alpha.14) (2026-03-12) ### Bug Fixes * GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10189](https://github.com/parse-community/parse-server/issues/10189)) ([3ffba75](https://github.com/parse-community/parse-server/commit/3ffba757bfc836bd034e1369f4f64304e110e375)) --- 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 77a3c4bcb9..43f7f00d76 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.13...9.6.0-alpha.14) (2026-03-12) + + +### Bug Fixes + +* GraphQL WebSocket endpoint bypasses security middleware ([GHSA-p2x3-8689-cwpg](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2x3-8689-cwpg)) ([#10189](https://github.com/parse-community/parse-server/issues/10189)) ([3ffba75](https://github.com/parse-community/parse-server/commit/3ffba757bfc836bd034e1369f4f64304e110e375)) + # [9.6.0-alpha.13](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.12...9.6.0-alpha.13) (2026-03-11) diff --git a/package-lock.json b/package-lock.json index 7992b18eba..4dd8c062bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.13", + "version": "9.6.0-alpha.14", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.13", + "version": "9.6.0-alpha.14", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 338a0b69af..ea06be85e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.13", + "version": "9.6.0-alpha.14", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 4f53ab3cad5502a51a509d53f999e00ff7217b8d Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:34:02 +0100 Subject: [PATCH 061/156] fix: Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) (#10191) --- spec/vulnerabilities.spec.js | 87 ++++++++++++++++++++++++++++++++++++ src/Options/Definitions.js | 4 +- src/Options/docs.js | 2 +- src/Options/index.js | 4 +- src/Routers/FilesRouter.js | 3 +- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 0769bf5393..39a04180c6 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1289,6 +1289,93 @@ describe('(GHSA-v5hf-f4c3-m5rv) Stored XSS via .svgz, .xht, .xml, .xsl, .xslt fi }); }); +describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Content-Type and additional XML extensions', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + }, + }); + }); + + for (const { ext, contentType } of [ + { ext: 'xsd', contentType: 'application/xml' }, + { ext: 'rng', contentType: 'application/xml' }, + { ext: 'rdf', contentType: 'application/rdf+xml' }, + { ext: 'owl', contentType: 'application/rdf+xml' }, + { ext: 'mathml', contentType: 'application/mathml+xml' }, + ]) { + it(`blocks .${ext} file upload by default`, async () => { + const content = Buffer.from( + '' + ).toString('base64'); + for (const extension of [ext, ext.toUpperCase(), ext[0].toUpperCase() + ext.slice(1)]) { + await expectAsync( + request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/files/malicious.${extension}`, + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File upload of extension ${extension} is disabled.` + ) + ); + } + }); + } + + it('blocks extensionless upload with parameterized Content-Type that bypasses regex', async () => { + const content = Buffer.from( + '' + ).toString('base64'); + // MIME parameters like ;charset=utf-8 should not bypass the extension filter + const dangerousContentTypes = [ + 'application/xhtml+xml;charset=utf-8', + 'application/xhtml+xml; charset=utf-8', + 'application/xhtml+xml\t;charset=utf-8', + 'image/svg+xml;charset=utf-8', + 'application/xml;charset=utf-8', + 'text/html;charset=utf-8', + 'application/xslt+xml;charset=utf-8', + 'application/rdf+xml;charset=utf-8', + 'application/mathml+xml;charset=utf-8', + ]; + for (const contentType of dangerousContentTypes) { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/files/payload', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: contentType, + base64: content, + }), + }).catch(e => { + throw new Error(e.data.error); + }) + ).toBeRejectedWith(jasmine.objectContaining({ + message: jasmine.stringMatching(/File upload of extension .+ is disabled/), + })); + } + }); +}); + describe('(GHSA-3jmq-rrxf-gqrg) Stored XSS via file serving', () => { it('sets X-Content-Type-Options: nosniff on file GET response', async () => { const file = new Parse.File('hello.txt', [1, 2, 3], 'text/plain'); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 401650f838..aa9ff33e94 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -1065,10 +1065,10 @@ module.exports.FileUploadOptions = { }, fileExtensions: { env: 'PARSE_SERVER_FILE_UPLOAD_FILE_EXTENSIONS', - help: "Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser.", + help: 'Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.

The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser\'s local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.

Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\\\+[xX][mM][lL])?)$)"]`.', action: parsers.arrayParser, default: [ - '^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)', + '^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)', ], }, }; diff --git a/src/Options/docs.js b/src/Options/docs.js index 52c5e68c07..432ff24440 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -249,7 +249,7 @@ * @property {Boolean} enableForAnonymousUser Is true if file upload should be allowed for anonymous users. * @property {Boolean} enableForAuthenticatedUser Is true if file upload should be allowed for authenticated users. * @property {Boolean} enableForPublic Is true if file upload should be allowed for anyone, regardless of user authentication. - * @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. + * @property {String[]} fileExtensions Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.

The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser's local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.

Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"]`. */ /** diff --git a/src/Options/index.js b/src/Options/index.js index 268022fa5d..79c46679c2 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -657,8 +657,8 @@ export interface PasswordPolicyOptions { } export interface FileUploadOptions { - /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to restrict the file upload extensions as much as possible. HTML, SVG, and XML files are especially problematic as they may be used by an attacker who uploads a HTML form, SVG image, or XML document to look legitimate under your app's domain name, or to compromise the session token of another user via accessing the browser's local storage.

Defaults to `^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)` which allows any file extension except those that are rendered as website or active content by a web browser. - :DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?)$)"] */ + /* Sets the allowed file extensions for uploading files. The extension is defined as an array of file extensions, or a regex pattern.

It is recommended to only allow the file extensions that your app actually needs, rather than relying on blocking dangerous extensions. This allowlist approach is more secure because new dangerous file extensions may emerge that are not covered by the default blocklist.

The default blocks the most common file extensions that are known to be rendered as active content by web browsers, such as HTML, SVG, and XML files, which may be used by an attacker to compromise the session token of another user via accessing the browser's local storage. The blocked extensions are: `html`, `htm`, `shtml`, `xhtml`, `xhtml+xml`, `xht`, `svg`, `svgz`, `svg+xml`, `xml`, `xsl`, `xslt`, `xslt+xml`, `xsd`, `rng`, `rdf`, `rdf+xml`, `owl`, `mathml`, `mathml+xml`.

Defaults to `["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"]`. + :DEFAULT: ["^(?!([xXsS]?[hH][tT][mM][lL]?(\\+[xX][mM][lL])?|[xX][hH][tT]|[sS][vV][gG]([zZ]|\\+[xX][mM][lL])?|[xX][mM][lL]|[xX][sS][lL][tT]?(\\+[xX][mM][lL])?|[xX][sS][dD]|[rR][nN][gG]|[rR][dD][fF](\\+[xX][mM][lL])?|[oO][wW][lL]|[mM][aA][tT][hH][mM][lL](\\+[xX][mM][lL])?)$)"] */ fileExtensions: ?(string[]); /* Is true if file upload should be allowed for anonymous users. :DEFAULT: false */ diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 112f38f7c3..e7d180c179 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -368,7 +368,8 @@ export class FilesRouter { } else if (contentType && contentType.includes('/')) { extension = contentType.split('/')[1]; } - extension = extension?.split(' ')?.join(''); + // Strip MIME parameters (e.g. ";charset=utf-8") and whitespace + extension = extension?.split(';')[0]?.replace(/\s+/g, ''); if (extension && !isValidExtension(extension)) { next( From 360b846d158e3905ae4848a93376e06d58c334ba Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 13 Mar 2026 20:34:56 +0000 Subject: [PATCH 062/156] chore(release): 9.6.0-alpha.15 [skip ci] # [9.6.0-alpha.15](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.14...9.6.0-alpha.15) (2026-03-13) ### Bug Fixes * Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10191](https://github.com/parse-community/parse-server/issues/10191)) ([4f53ab3](https://github.com/parse-community/parse-server/commit/4f53ab3cad5502a51a509d53f999e00ff7217b8d)) --- 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 43f7f00d76..3570fcade8 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.15](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.14...9.6.0-alpha.15) (2026-03-13) + + +### Bug Fixes + +* Stored XSS filter bypass via Content-Type MIME parameter and missing XML extension blocklist entries ([GHSA-42ph-pf9q-cr72](https://github.com/parse-community/parse-server/security/advisories/GHSA-42ph-pf9q-cr72)) ([#10191](https://github.com/parse-community/parse-server/issues/10191)) ([4f53ab3](https://github.com/parse-community/parse-server/commit/4f53ab3cad5502a51a509d53f999e00ff7217b8d)) + # [9.6.0-alpha.14](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.13...9.6.0-alpha.14) (2026-03-12) diff --git a/package-lock.json b/package-lock.json index 4dd8c062bf..17113ffd8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.14", + "version": "9.6.0-alpha.15", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.14", + "version": "9.6.0-alpha.15", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index ea06be85e2..3a28e3bb99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.14", + "version": "9.6.0-alpha.15", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From a944203b268cf467ab4c720928f744d0c889b1e5 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:47:51 +0100 Subject: [PATCH 063/156] fix: Session token expiration unchecked on cache hit (#10194) --- spec/ParseUser.spec.js | 41 +++++++++++++++++++++++++++++++++++++++++ src/Auth.js | 11 ++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 66fce2b129..cc102ec294 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -3318,6 +3318,47 @@ describe('Parse.User testing', () => { expect(session.get('expiresAt')).toEqual(expiresAt); }); + it('should reject expired session token even when served from cache', async () => { + // Use a 1-second session length with a 5-second cache TTL (default) + // so the session expires while the cache entry is still alive + await reconfigureServer({ sessionLength: 1 }); + + // Sign up user — creates a session with expiresAt = now + 1 second + const user = await Parse.User.signUp('cacheuser', 'somepass'); + const sessionToken = user.getSessionToken(); + + // Make an authenticated request to prime the user cache + await request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + + // Wait for the session to expire (1 second), but cache entry (5s TTL) is still alive + await new Promise(resolve => setTimeout(resolve, 1500)); + + // This request should be served from cache but still reject the expired session + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + fail('Should have rejected expired session token from cache'); + } catch (error) { + expect(error.data.code).toEqual(209); + expect(error.data.error).toEqual('Session token is expired.'); + } + }); + it('should not create extraneous session tokens', done => { const config = Config.get(Parse.applicationId); config.database diff --git a/src/Auth.js b/src/Auth.js index ec600e18ba..7dd8660ad7 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -133,8 +133,13 @@ const getAuthForSessionToken = async function ({ }) { cacheController = cacheController || (config && config.cacheController); if (cacheController) { - const userJSON = await cacheController.user.get(sessionToken); - if (userJSON) { + const cached = await cacheController.user.get(sessionToken); + if (cached) { + const { expiresAt: cachedExpiresAt, ...userJSON } = cached; + if (cachedExpiresAt && new Date(cachedExpiresAt) < new Date()) { + cacheController.user.del(sessionToken); + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is expired.'); + } const cachedUser = Parse.Object.fromJSON(userJSON); renewSessionIfNeeded({ config, sessionToken }); return Promise.resolve( @@ -195,7 +200,7 @@ const getAuthForSessionToken = async function ({ obj['className'] = '_User'; obj['sessionToken'] = sessionToken; if (cacheController) { - cacheController.user.put(sessionToken, obj); + cacheController.user.put(sessionToken, { ...obj, expiresAt: expiresAt?.toISOString() }); } renewSessionIfNeeded({ config, session, sessionToken }); const userObject = Parse.Object.fromJSON(obj); From fa7d056ea5992392a1453320e74e0d077991bec7 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 13 Mar 2026 22:48:43 +0000 Subject: [PATCH 064/156] chore(release): 9.6.0-alpha.16 [skip ci] # [9.6.0-alpha.16](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.15...9.6.0-alpha.16) (2026-03-13) ### Bug Fixes * Session token expiration unchecked on cache hit ([#10194](https://github.com/parse-community/parse-server/issues/10194)) ([a944203](https://github.com/parse-community/parse-server/commit/a944203b268cf467ab4c720928f744d0c889b1e5)) --- 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 3570fcade8..e1416515a5 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.16](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.15...9.6.0-alpha.16) (2026-03-13) + + +### Bug Fixes + +* Session token expiration unchecked on cache hit ([#10194](https://github.com/parse-community/parse-server/issues/10194)) ([a944203](https://github.com/parse-community/parse-server/commit/a944203b268cf467ab4c720928f744d0c889b1e5)) + # [9.6.0-alpha.15](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.14...9.6.0-alpha.15) (2026-03-13) diff --git a/package-lock.json b/package-lock.json index 17113ffd8d..b77225872b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.15", + "version": "9.6.0-alpha.16", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.15", + "version": "9.6.0-alpha.16", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 3a28e3bb99..edbb458d32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.15", + "version": "9.6.0-alpha.16", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 7ccfb972d4a6679726f3a0b3cc8d6a8f1838273c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:35:56 +0100 Subject: [PATCH 065/156] fix: Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) (#10195) --- spec/ParseSession.spec.js | 87 +++++++++++++++++++++++++++++++++++++++ src/RestWrite.js | 2 +- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/spec/ParseSession.spec.js b/spec/ParseSession.spec.js index aca4c07263..1352fae06a 100644 --- a/spec/ParseSession.spec.js +++ b/spec/ParseSession.spec.js @@ -169,4 +169,91 @@ describe('Parse.Session', () => { expect(first.get('user').id).toBe(firstUser); expect(second.get('user').id).toBe(secondUser); }); + + it('should ignore sessionToken when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser', 'password'); + const sessionToken = user.getSessionToken(); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + sessionToken: 'r:ATTACKER_CONTROLLED_TOKEN', + }, + }); + + // The returned session should have a server-generated token, not the attacker's + expect(response.data.sessionToken).not.toBe('r:ATTACKER_CONTROLLED_TOKEN'); + expect(response.data.sessionToken).toMatch(/^r:/); + }); + + it('should ignore expiresAt when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser2', 'password'); + const sessionToken = user.getSessionToken(); + const farFuture = new Date('2099-12-31T23:59:59.000Z'); + + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: farFuture.toISOString() }, + }, + }); + + // Fetch the newly created session and verify expiresAt is server-generated, not 2099 + const sessions = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + const newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken); + const expiresAt = new Date(newSession.expiresAt.iso); + expect(expiresAt.getFullYear()).not.toBe(2099); + }); + + it('should ignore createdWith when creating a session via POST /classes/_Session', async () => { + const user = await Parse.User.signUp('sessionuser3', 'password'); + const sessionToken = user.getSessionToken(); + + await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + createdWith: { action: 'attacker', authProvider: 'evil' }, + }, + }); + + const sessions = await request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/_Session', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + const newSession = sessions.data.results.find(s => s.sessionToken !== sessionToken); + expect(newSession.createdWith.action).toBe('create'); + expect(newSession.createdWith.authProvider).toBeUndefined(); + }); }); diff --git a/src/RestWrite.js b/src/RestWrite.js index 05dc3f7fd6..b40d8d619b 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1195,7 +1195,7 @@ RestWrite.prototype.handleSession = function () { if (!this.query && !this.auth.isMaster && !this.auth.isMaintenance) { const additionalSessionData = {}; for (var key in this.data) { - if (key === 'objectId' || key === 'user') { + if (key === 'objectId' || key === 'user' || key === 'sessionToken' || key === 'expiresAt' || key === 'createdWith') { continue; } additionalSessionData[key] = this.data[key]; From 10854f42ea9286ed22bce12155061551250e270c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 13 Mar 2026 23:36:53 +0000 Subject: [PATCH 066/156] chore(release): 9.6.0-alpha.17 [skip ci] # [9.6.0-alpha.17](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.16...9.6.0-alpha.17) (2026-03-13) ### Bug Fixes * Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10195](https://github.com/parse-community/parse-server/issues/10195)) ([7ccfb97](https://github.com/parse-community/parse-server/commit/7ccfb972d4a6679726f3a0b3cc8d6a8f1838273c)) --- 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 e1416515a5..f160e77f99 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.17](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.16...9.6.0-alpha.17) (2026-03-13) + + +### Bug Fixes + +* Session creation endpoint allows overwriting server-generated session fields ([GHSA-5v7g-9h8f-8pgg](https://github.com/parse-community/parse-server/security/advisories/GHSA-5v7g-9h8f-8pgg)) ([#10195](https://github.com/parse-community/parse-server/issues/10195)) ([7ccfb97](https://github.com/parse-community/parse-server/commit/7ccfb972d4a6679726f3a0b3cc8d6a8f1838273c)) + # [9.6.0-alpha.16](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.15...9.6.0-alpha.16) (2026-03-13) diff --git a/package-lock.json b/package-lock.json index b77225872b..b2f78cd99d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.16", + "version": "9.6.0-alpha.17", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.16", + "version": "9.6.0-alpha.17", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index edbb458d32..1362749ef7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.16", + "version": "9.6.0-alpha.17", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From b04ca5eec41065caccc7f7dbed8a0595f0364914 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:52:58 +0100 Subject: [PATCH 067/156] fix: Security upgrade fast-xml-parser from 5.3.7 to 5.4.2 (#10086) --- package-lock.json | 62 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2f78cd99d..335775489b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10475,19 +10475,35 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, "node_modules/fast-xml-parser": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", - "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", + "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", "optional": true, "dependencies": { + "fast-xml-builder": "^1.1.3", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { @@ -18525,6 +18541,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -30054,12 +30085,23 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "optional": true, + "requires": { + "path-expression-matcher": "^1.1.3" + } + }, "fast-xml-parser": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", - "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", + "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", "optional": true, "requires": { + "fast-xml-builder": "^1.1.3", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" } }, @@ -35620,6 +35662,12 @@ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true }, + "path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "optional": true + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", From 0cef831324aad3e0c7a7e32b418fbe73435aea4c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 14 Mar 2026 11:53:57 +0000 Subject: [PATCH 068/156] chore(release): 9.6.0-alpha.18 [skip ci] # [9.6.0-alpha.18](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.17...9.6.0-alpha.18) (2026-03-14) ### Bug Fixes * Security upgrade fast-xml-parser from 5.3.7 to 5.4.2 ([#10086](https://github.com/parse-community/parse-server/issues/10086)) ([b04ca5e](https://github.com/parse-community/parse-server/commit/b04ca5eec41065caccc7f7dbed8a0595f0364914)) --- 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 f160e77f99..452d4a920d 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.18](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.17...9.6.0-alpha.18) (2026-03-14) + + +### Bug Fixes + +* Security upgrade fast-xml-parser from 5.3.7 to 5.4.2 ([#10086](https://github.com/parse-community/parse-server/issues/10086)) ([b04ca5e](https://github.com/parse-community/parse-server/commit/b04ca5eec41065caccc7f7dbed8a0595f0364914)) + # [9.6.0-alpha.17](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.16...9.6.0-alpha.17) (2026-03-13) diff --git a/package-lock.json b/package-lock.json index 335775489b..4962864f2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.17", + "version": "9.6.0-alpha.18", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.17", + "version": "9.6.0-alpha.18", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 1362749ef7..63f732b51d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.17", + "version": "9.6.0-alpha.18", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0ae0eeee524204325e09efcb315c50096aaf20f8 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:23:27 +0100 Subject: [PATCH 069/156] fix: LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) (#10197) --- spec/ParseLiveQuery.spec.js | 76 +++++++++++++++++++++++++ spec/QueryTools.spec.js | 43 ++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 81 ++++++++++++++++++++++++--- src/LiveQuery/QueryTools.js | 33 +++++------ 4 files changed, 208 insertions(+), 25 deletions(-) diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 48bc1504c8..ac46535787 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -646,6 +646,82 @@ describe('ParseLiveQuery', function () { ); }); + it('rejects subscription with invalid $regex pattern', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const query = new Parse.Query('TestObject'); + query._where = { foo: { $regex: '[invalid' } }; + await expectAsync(query.subscribe()).toBeRejectedWithError(/Invalid regular expression/); + }); + + it('rejects subscription with non-string $regex value', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const query = new Parse.Query('TestObject'); + query._where = { foo: { $regex: 123 } }; + await expectAsync(query.subscribe()).toBeRejectedWithError( + /\$regex must be a string or RegExp/ + ); + }); + + it('does not crash server when subscription matching throws and other subscriptions still work', async () => { + const server = await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + // Create a valid subscription + const validQuery = new Parse.Query('TestObject'); + validQuery.equalTo('objectId', object.id); + const validSubscription = await validQuery.subscribe(); + + // Inject a malformed subscription directly into the LiveQuery server + // to bypass subscribe-time validation and test the try-catch in _onAfterSave + const lqServer = server.liveQueryServer; + const Subscription = require('../lib/LiveQuery/Subscription').Subscription; + const badSubscription = new Subscription('TestObject', { foo: { $regex: '[invalid' } }); + badSubscription.addClientSubscription('fakeClientId', 'fakeRequestId'); + const classSubscriptions = lqServer.subscriptions.get('TestObject'); + classSubscriptions.set('bad-hash', badSubscription); + + // Verify the valid subscription still receives updates despite the bad subscription + const updatePromise = new Promise(resolve => { + validSubscription.on('update', obj => { + expect(obj.get('foo')).toBe('baz'); + resolve(); + }); + }); + + object.set('foo', 'baz'); + await object.save(); + await updatePromise; + + // Clean up the injected subscription + classSubscriptions.delete('bad-hash'); + }); + it('can handle mutate beforeSubscribe query', async done => { await reconfigureServer({ liveQuery: { diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index 8819aadd66..0ea1b560e7 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -567,6 +567,49 @@ describe('matchesQuery', function () { expect(config.liveQuery.regexTimeout).toBe(100); }); + it('does not throw on invalid $regex pattern', function () { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + // Invalid regex syntax should not throw, just return false + const q = new Parse.Query('Player'); + q._where = { name: { $regex: '[invalid' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + }); + + it('does not throw on invalid $regex pattern with regexTimeout enabled', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + const q = new Parse.Query('Player'); + q._where = { name: { $regex: '[invalid' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + } finally { + setRegexTimeout(0); + } + }); + + it('does not throw on invalid $regex flags', function () { + const player = { + id: new Id('Player', 'P1'), + name: 'Player 1', + }; + + const q = new Parse.Query('Player'); + q._where = { name: { $regex: 'valid', $options: 'xyz' } }; + expect(() => matchesQuery(player, q)).not.toThrow(); + expect(matchesQuery(player, q)).toBe(false); + }); + it('matches $nearSphere queries', function () { let q = new Parse.Query('Checkin'); q.near('location', new Parse.GeoPoint(20, 20)); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 3deaa0e7b2..99f1fe2d21 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -190,7 +190,13 @@ class ParseLiveQueryServer { } for (const subscription of classSubscriptions.values()) { - const isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); + let isSubscriptionMatched; + try { + isSubscriptionMatched = this._matchesSubscription(deletedParseObject, subscription); + } catch (e) { + logger.error(`Failed matching subscription for class ${className}: ${e.message}`); + continue; + } if (!isSubscriptionMatched) { continue; } @@ -286,14 +292,21 @@ class ParseLiveQueryServer { return; } for (const subscription of classSubscriptions.values()) { - const isOriginalSubscriptionMatched = this._matchesSubscription( - originalParseObject, - subscription - ); - const isCurrentSubscriptionMatched = this._matchesSubscription( - currentParseObject, - subscription - ); + let isOriginalSubscriptionMatched; + let isCurrentSubscriptionMatched; + try { + isOriginalSubscriptionMatched = this._matchesSubscription( + originalParseObject, + subscription + ); + isCurrentSubscriptionMatched = this._matchesSubscription( + currentParseObject, + subscription + ); + } catch (e) { + logger.error(`Failed matching subscription for class ${className}: ${e.message}`); + continue; + } for (const [clientId, requestIds] of _.entries(subscription.clientRequestIds)) { const client = this.clients.get(clientId); if (typeof client === 'undefined') { @@ -520,6 +533,53 @@ class ParseLiveQueryServer { }); } + _validateQueryConstraints(where: any): void { + if (typeof where !== 'object' || where === null) { + return; + } + for (const key of Object.keys(where)) { + const constraint = where[key]; + if (typeof constraint === 'object' && constraint !== null) { + if (constraint.$regex !== undefined) { + const regex = constraint.$regex; + const isRegExpLike = + regex !== null && + typeof regex === 'object' && + typeof regex.source === 'string' && + typeof regex.flags === 'string'; + if (typeof regex !== 'string' && !isRegExpLike) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + 'Invalid regular expression: $regex must be a string or RegExp' + ); + } + const pattern = isRegExpLike ? regex.source : regex; + const flags = isRegExpLike ? regex.flags : constraint.$options || ''; + try { + new RegExp(pattern, flags); + } catch (e) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Invalid regular expression: ${e.message}` + ); + } + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(constraint[op])) { + constraint[op].forEach((subQuery: any) => { + this._validateQueryConstraints(subQuery); + }); + } + } + if (Array.isArray(where[key])) { + where[key].forEach((subQuery: any) => { + this._validateQueryConstraints(subQuery); + }); + } + } + } + } + _matchesSubscription(parseObject: any, subscription: any): boolean { // Object is undefined or null, not match if (!parseObject) { @@ -951,6 +1011,9 @@ class ParseLiveQueryServer { } } + // Validate regex patterns in the subscription query + this._validateQueryConstraints(request.query.where); + // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); // Add className to subscriptions if necessary diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 0d182b8007..95159ed8b9 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -14,28 +14,29 @@ function setRegexTimeout(ms) { } function safeRegexTest(pattern, flags, input) { - if (!regexTimeout) { - var re = new RegExp(pattern, flags); - return re.test(input); - } - var cacheKey = flags + ':' + pattern; - var script = scriptCache.get(cacheKey); - if (!script) { - if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); } - script = new vm.Script('new RegExp(pattern, flags).test(input)'); - scriptCache.set(cacheKey, script); - } - vmContext.pattern = pattern; - vmContext.flags = flags; - vmContext.input = input; try { + if (!regexTimeout) { + var re = new RegExp(pattern, flags); + return re.test(input); + } + var cacheKey = flags + ':' + pattern; + var script = scriptCache.get(cacheKey); + if (!script) { + if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); } + script = new vm.Script('new RegExp(pattern, flags).test(input)'); + scriptCache.set(cacheKey, script); + } + vmContext.pattern = pattern; + vmContext.flags = flags; + vmContext.input = input; return script.runInContext(vmContext, { timeout: regexTimeout }); } catch (e) { if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`); - return false; + } else { + logger.warn(`Invalid regex: pattern "${pattern}" with flags "${flags}": ${e.message}`); } - throw e; + return false; } } From 97b6b3c12e7ddef9525a033e7f98077d8c6b70d1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 14 Mar 2026 13:29:40 +0000 Subject: [PATCH 070/156] chore(release): 9.6.0-alpha.19 [skip ci] # [9.6.0-alpha.19](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.18...9.6.0-alpha.19) (2026-03-14) ### Bug Fixes * LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10197](https://github.com/parse-community/parse-server/issues/10197)) ([0ae0eee](https://github.com/parse-community/parse-server/commit/0ae0eeee524204325e09efcb315c50096aaf20f8)) --- 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 452d4a920d..bf4d5a9a19 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.19](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.18...9.6.0-alpha.19) (2026-03-14) + + +### Bug Fixes + +* LiveQuery subscription with invalid regular expression crashes server ([GHSA-827p-g5x5-h86c](https://github.com/parse-community/parse-server/security/advisories/GHSA-827p-g5x5-h86c)) ([#10197](https://github.com/parse-community/parse-server/issues/10197)) ([0ae0eee](https://github.com/parse-community/parse-server/commit/0ae0eeee524204325e09efcb315c50096aaf20f8)) + # [9.6.0-alpha.18](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.17...9.6.0-alpha.18) (2026-03-14) diff --git a/package-lock.json b/package-lock.json index 4962864f2e..fb8336d301 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.18", + "version": "9.6.0-alpha.19", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.18", + "version": "9.6.0-alpha.19", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 63f732b51d..754867e000 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.18", + "version": "9.6.0-alpha.19", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From b321423867f5e779b4750f97c4e42d408499fc3b Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:01:04 +0100 Subject: [PATCH 071/156] fix: Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) (#10200) --- package-lock.json | 30 ------ package.json | 1 - spec/vulnerabilities.spec.js | 114 +++++++++++++++++++-- src/Controllers/DatabaseController.js | 4 +- src/Controllers/SchemaController.js | 4 +- src/GraphQL/loaders/functionsMutations.js | 5 +- src/GraphQL/loaders/parseClassMutations.js | 10 +- src/GraphQL/loaders/parseClassQueries.js | 8 +- src/GraphQL/loaders/schemaMutations.js | 10 +- src/GraphQL/loaders/schemaQueries.js | 6 +- src/GraphQL/loaders/usersMutations.js | 9 +- src/GraphQL/parseGraphQLUtils.js | 8 ++ src/LiveQuery/ParseLiveQueryServer.ts | 4 +- src/Push/PushWorker.js | 4 +- src/Push/utils.js | 6 +- src/RestWrite.js | 15 ++- 16 files changed, 156 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb8336d301..08466faa82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "bcryptjs": "3.0.3", "commander": "14.0.3", "cors": "2.8.6", - "deepcopy": "2.1.0", "express": "5.2.1", "express-rate-limit": "8.2.1", "follow-redirects": "1.15.9", @@ -9172,14 +9171,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/deepcopy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz", - "integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==", - "dependencies": { - "type-detect": "^4.0.8" - } - }, "node_modules/default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -21776,14 +21767,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "engines": { - "node": ">=4" - } - }, "node_modules/type-fest": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz", @@ -29165,14 +29148,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "deepcopy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz", - "integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==", - "requires": { - "type-detect": "^4.0.8" - } - }, "default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -37917,11 +37892,6 @@ "prelude-ls": "^1.2.1" } }, - "type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" - }, "type-fest": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.21.0.tgz", diff --git a/package.json b/package.json index 754867e000..212fad9289 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "bcryptjs": "3.0.3", "commander": "14.0.3", "cors": "2.8.6", - "deepcopy": "2.1.0", "express": "5.2.1", "express-rate-limit": "8.2.1", "follow-redirects": "1.15.9", diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 39a04180c6..b9e4042761 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -353,12 +353,23 @@ describe('Vulnerabilities', () => { }); 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); + 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/PP', + body: JSON.stringify( + JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}') + ), + }).catch(e => e); + expect(response.status).toBe(400); + const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toContain('__proto__'); }); it('denies constructor after a sibling nested object', async () => { @@ -2644,4 +2655,95 @@ describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field na expect(result.body.errors[0].message).toMatch(/exceeds maximum allowed/); }); }); + + describe('(GHSA-9ccr-fpp6-78qf) Schema poisoning via __proto__ bypassing requestKeywordDenylist and addField CLP', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects __proto__ in request body via HTTP', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/ProtoTest', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"injected":"value"}}')), + }).catch(e => e); + expect(response.status).toBe(400); + const text = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME); + expect(text.error).toContain('__proto__'); + }); + + it('does not add fields to a locked schema via __proto__', async () => { + const schema = new Parse.Schema('LockedSchema'); + schema.addString('name'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + // Attempt to inject a field via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/LockedSchema', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"newField":"bypassed"}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify schema was not modified + const schemaResponse = await request({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + method: 'GET', + url: 'http://localhost:8378/1/schemas/LockedSchema', + }); + const fields = schemaResponse.data.fields; + expect(fields.newField).toBeUndefined(); + }); + + it('does not cause schema type conflict via __proto__', async () => { + const schema = new Parse.Schema('TypeConflict'); + schema.addString('name'); + schema.addString('score'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + await schema.save(); + + // Attempt to inject 'score' as Number via __proto__ + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/classes/TypeConflict', + body: JSON.stringify(JSON.parse('{"name":"test","__proto__":{"score":42}}')), + }).catch(e => e); + + // Should be rejected by denylist + expect(response.status).toBe(400); + + // Verify 'score' field is still String type + const obj = new Parse.Object('TypeConflict'); + obj.set('name', 'valid'); + obj.set('score', 'string-value'); + await obj.save(); + expect(obj.get('score')).toBe('string-value'); + }); + }); }); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 8ee9d8d01a..541be63076 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -8,8 +8,6 @@ import { Parse } from 'parse/node'; import _ from 'lodash'; // @flow-disable-next import intersect from 'intersect'; -// @flow-disable-next -import deepcopy from 'deepcopy'; import logger from '../logger'; import Utils from '../Utils'; import * as SchemaController from './SchemaController'; @@ -541,7 +539,7 @@ class DatabaseController { const originalQuery = query; const originalUpdate = update; // Make a copy of the object, so we don't mutate the incoming data. - update = deepcopy(update); + update = structuredClone(update); var relationUpdates = []; var isMaster = acl === undefined; var aclGroup = acl || []; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index b605fba632..e112480e65 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -21,8 +21,6 @@ import SchemaCache from '../Adapters/Cache/SchemaCache'; import DatabaseController from './DatabaseController'; import Config from '../Config'; import { createSanitizedError } from '../Error'; -// @flow-disable-next -import deepcopy from 'deepcopy'; import type { Schema, SchemaFields, @@ -573,7 +571,7 @@ class SchemaData { if (!this.__data[schema.className]) { const data = {}; data.fields = injectDefaultSchema(schema).fields; - data.classLevelPermissions = deepcopy(schema.classLevelPermissions); + data.classLevelPermissions = structuredClone(schema.classLevelPermissions); data.indexes = schema.indexes; const classProtectedFields = this.__protectedFields[schema.className]; diff --git a/src/GraphQL/loaders/functionsMutations.js b/src/GraphQL/loaders/functionsMutations.js index 8eae5b2072..c6761d7966 100644 --- a/src/GraphQL/loaders/functionsMutations.js +++ b/src/GraphQL/loaders/functionsMutations.js @@ -1,6 +1,7 @@ import { GraphQLNonNull, GraphQLEnumType } from 'graphql'; -import deepcopy from 'deepcopy'; + import { mutationWithClientMutationId } from 'graphql-relay'; +import { cloneArgs } from '../parseGraphQLUtils'; import { FunctionsRouter } from '../../Routers/FunctionsRouter'; import * as defaultGraphQLTypes from './defaultGraphQLTypes'; @@ -44,7 +45,7 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context) => { try { - const { functionName, params } = deepcopy(args); + const { functionName, params } = cloneArgs(args); const { config, auth, info } = context; return { diff --git a/src/GraphQL/loaders/parseClassMutations.js b/src/GraphQL/loaders/parseClassMutations.js index 1c733b6a1f..df9a096995 100644 --- a/src/GraphQL/loaders/parseClassMutations.js +++ b/src/GraphQL/loaders/parseClassMutations.js @@ -1,9 +1,9 @@ import { GraphQLNonNull } from 'graphql'; import { fromGlobalId, mutationWithClientMutationId } from 'graphql-relay'; import getFieldNames from 'graphql-list-fields'; -import deepcopy from 'deepcopy'; + import * as defaultGraphQLTypes from './defaultGraphQLTypes'; -import { extractKeysAndInclude, getParseClassMutationConfig } from '../parseGraphQLUtils'; +import { extractKeysAndInclude, getParseClassMutationConfig, cloneArgs } from '../parseGraphQLUtils'; import * as objectsMutations from '../helpers/objectsMutations'; import * as objectsQueries from '../helpers/objectsQueries'; import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; @@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - let { fields } = deepcopy(args); + let { fields } = cloneArgs(args); if (!fields) { fields = {}; } const { config, auth, info } = context; @@ -178,7 +178,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - let { id, fields } = deepcopy(args); + let { id, fields } = cloneArgs(args); if (!fields) { fields = {}; } const { config, auth, info } = context; @@ -284,7 +284,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - let { id } = deepcopy(args); + let { id } = cloneArgs(args); const { config, auth, info } = context; const globalIdObject = fromGlobalId(id); diff --git a/src/GraphQL/loaders/parseClassQueries.js b/src/GraphQL/loaders/parseClassQueries.js index edf210ace3..6c34d320c6 100644 --- a/src/GraphQL/loaders/parseClassQueries.js +++ b/src/GraphQL/loaders/parseClassQueries.js @@ -1,13 +1,13 @@ import { GraphQLNonNull } from 'graphql'; import { fromGlobalId } from 'graphql-relay'; import getFieldNames from 'graphql-list-fields'; -import deepcopy from 'deepcopy'; + import pluralize from 'pluralize'; import * as defaultGraphQLTypes from './defaultGraphQLTypes'; import * as objectsQueries from '../helpers/objectsQueries'; import { ParseGraphQLClassConfig } from '../../Controllers/ParseGraphQLController'; import { transformClassNameToGraphQL } from '../transformers/className'; -import { extractKeysAndInclude } from '../parseGraphQLUtils'; +import { extractKeysAndInclude, cloneArgs } from '../parseGraphQLUtils'; const getParseClassQueryConfig = function (parseClassConfig: ?ParseGraphQLClassConfig) { return (parseClassConfig && parseClassConfig.query) || {}; @@ -75,7 +75,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG return await getQuery( parseClass, _source, - deepcopy(args), + cloneArgs(args), context, queryInfo, parseGraphQLSchema.parseClasses @@ -99,7 +99,7 @@ const load = function (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseG async resolve(_source, args, context, queryInfo) { try { // Deep copy args to avoid internal re assign issue - const { where, order, skip, first, after, last, before, options } = deepcopy(args); + const { where, order, skip, first, after, last, before, options } = cloneArgs(args); const { readPreference, includeReadPreference, subqueryReadPreference } = options || {}; const { config, auth, info } = context; const selectedFields = getFieldNames(queryInfo); diff --git a/src/GraphQL/loaders/schemaMutations.js b/src/GraphQL/loaders/schemaMutations.js index 93cd89d54a..69c8e1c54c 100644 --- a/src/GraphQL/loaders/schemaMutations.js +++ b/src/GraphQL/loaders/schemaMutations.js @@ -1,10 +1,10 @@ import Parse from 'parse/node'; import { GraphQLNonNull } from 'graphql'; -import deepcopy from 'deepcopy'; + import { mutationWithClientMutationId } from 'graphql-relay'; import * as schemaTypes from './schemaTypes'; import { transformToParse, transformToGraphQL } from '../transformers/schemaFields'; -import { enforceMasterKeyAccess } from '../parseGraphQLUtils'; +import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils'; import { getClass } from './schemaQueries'; import { createSanitizedError } from '../../Error'; @@ -28,7 +28,7 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context) => { try { - const { name, schemaFields } = deepcopy(args); + const { name, schemaFields } = cloneArgs(args); const { config, auth } = context; enforceMasterKeyAccess(auth, config); @@ -78,7 +78,7 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context) => { try { - const { name, schemaFields } = deepcopy(args); + const { name, schemaFields } = cloneArgs(args); const { config, auth } = context; enforceMasterKeyAccess(auth, config); @@ -130,7 +130,7 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context) => { try { - const { name } = deepcopy(args); + const { name } = cloneArgs(args); const { config, auth } = context; enforceMasterKeyAccess(auth, config); diff --git a/src/GraphQL/loaders/schemaQueries.js b/src/GraphQL/loaders/schemaQueries.js index 2956b47934..6e16a54bfb 100644 --- a/src/GraphQL/loaders/schemaQueries.js +++ b/src/GraphQL/loaders/schemaQueries.js @@ -1,9 +1,9 @@ import Parse from 'parse/node'; -import deepcopy from 'deepcopy'; + import { GraphQLNonNull, GraphQLList } from 'graphql'; import { transformToGraphQL } from '../transformers/schemaFields'; import * as schemaTypes from './schemaTypes'; -import { enforceMasterKeyAccess } from '../parseGraphQLUtils'; +import { enforceMasterKeyAccess, cloneArgs } from '../parseGraphQLUtils'; const getClass = async (name, schema) => { try { @@ -28,7 +28,7 @@ const load = parseGraphQLSchema => { type: new GraphQLNonNull(schemaTypes.CLASS), resolve: async (_source, args, context) => { try { - const { name } = deepcopy(args); + const { name } = cloneArgs(args); const { config, auth } = context; enforceMasterKeyAccess(auth, config); diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js index 2f59081a03..1067035b93 100644 --- a/src/GraphQL/loaders/usersMutations.js +++ b/src/GraphQL/loaders/usersMutations.js @@ -1,6 +1,7 @@ import { GraphQLNonNull, GraphQLString, GraphQLBoolean, GraphQLInputObjectType } from 'graphql'; import { mutationWithClientMutationId } from 'graphql-relay'; -import deepcopy from 'deepcopy'; + +import { cloneArgs } from '../parseGraphQLUtils'; import UsersRouter from '../../Routers/UsersRouter'; import * as objectsMutations from '../helpers/objectsMutations'; import { OBJECT } from './defaultGraphQLTypes'; @@ -32,7 +33,7 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - const { fields } = deepcopy(args); + const { fields } = cloneArgs(args); const { config, auth, info } = context; const parseFields = await transformTypes('create', fields, { @@ -109,7 +110,7 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - const { fields, authData } = deepcopy(args); + const { fields, authData } = cloneArgs(args); const { config, auth, info } = context; const parseFields = await transformTypes('create', fields, { @@ -173,7 +174,7 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - const { username, password, authData } = deepcopy(args); + const { username, password, authData } = cloneArgs(args); const { config, auth, info } = context; const { sessionToken, objectId, authDataResponse } = ( diff --git a/src/GraphQL/parseGraphQLUtils.js b/src/GraphQL/parseGraphQLUtils.js index ba5fd1b416..a7e92405ec 100644 --- a/src/GraphQL/parseGraphQLUtils.js +++ b/src/GraphQL/parseGraphQLUtils.js @@ -55,3 +55,11 @@ export const extractKeysAndInclude = selectedFields => { export const getParseClassMutationConfig = function (parseClassConfig) { return (parseClassConfig && parseClassConfig.mutation) || {}; }; + +export function cloneArgs(args) { + try { + return structuredClone(args); + } catch { + return JSON.parse(JSON.stringify(args)); + } +} diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 99f1fe2d21..a83b8e1a62 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -25,7 +25,7 @@ import { LRUCache as LRU } from 'lru-cache'; import UserRouter from '../Routers/UsersRouter'; import DatabaseController from '../Controllers/DatabaseController'; import { isDeepStrictEqual } from 'util'; -import deepcopy from 'deepcopy'; + class ParseLiveQueryServer { server: any; @@ -585,7 +585,7 @@ class ParseLiveQueryServer { if (!parseObject) { return false; } - return matchesQuery(deepcopy(parseObject), subscription.query); + return matchesQuery(structuredClone(parseObject), subscription.query); } async _clearCachedRoles(userId: string) { diff --git a/src/Push/PushWorker.js b/src/Push/PushWorker.js index 2b3c4a2fb7..6ffd960f33 100644 --- a/src/Push/PushWorker.js +++ b/src/Push/PushWorker.js @@ -1,6 +1,4 @@ // @flow -// @flow-disable-next -import deepcopy from 'deepcopy'; import AdaptableController from '../Controllers/AdaptableController'; import { master } from '../Auth'; import Config from '../Config'; @@ -91,7 +89,7 @@ export class PushWorker { // Map the on the badges count and return the send result const promises = Object.keys(badgeInstallationsMap).map(badge => { - const payload = deepcopy(body); + const payload = structuredClone(body); payload.data.badge = parseInt(badge); const installations = badgeInstallationsMap[badge]; return this.sendToAdapter(payload, installations, pushStatus, config, UTCOffset); diff --git a/src/Push/utils.js b/src/Push/utils.js index 5be13c272e..4437cc8099 100644 --- a/src/Push/utils.js +++ b/src/Push/utils.js @@ -1,5 +1,5 @@ import Parse from 'parse/node'; -import deepcopy from 'deepcopy'; + export function isPushIncrementing(body) { if (!body.data || !body.data.badge) { @@ -45,7 +45,7 @@ export function transformPushBodyForLocale(body, locale) { if (!data) { return body; } - body = deepcopy(body); + body = structuredClone(body); localizableKeys.forEach(key => { const localeValue = body.data[`${key}-${locale}`]; if (localeValue) { @@ -128,7 +128,7 @@ export function validatePushType(where = {}, validPushTypes = []) { } export function applyDeviceTokenExists(where) { - where = deepcopy(where); + where = structuredClone(where); if (!Object.prototype.hasOwnProperty.call(where, 'deviceToken')) { where['deviceToken'] = { $exists: true }; } diff --git a/src/RestWrite.js b/src/RestWrite.js index b40d8d619b..937f8644e9 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -3,7 +3,6 @@ // This could be either a "create" or an "update". var SchemaController = require('./Controllers/SchemaController'); -var deepcopy = require('deepcopy'); const Auth = require('./Auth'); const Utils = require('./Utils'); @@ -75,8 +74,8 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK // Processing this operation may mutate our data, so we operate on a // copy - this.query = deepcopy(query); - this.data = deepcopy(data); + this.query = structuredClone(query); + this.data = structuredClone(data); // We never change originalData, so we do not need a deep copy this.originalData = originalData; @@ -377,10 +376,10 @@ RestWrite.prototype.setRequiredFieldsIfNeeded = function () { JSON.stringify(schema.classLevelPermissions.ACL) !== JSON.stringify({ '*': { read: true, write: true } }) ) { - const acl = deepcopy(schema.classLevelPermissions.ACL); + const acl = structuredClone(schema.classLevelPermissions.ACL); if (acl.currentUser) { if (this.auth.user?.id) { - acl[this.auth.user?.id] = deepcopy(acl.currentUser); + acl[this.auth.user?.id] = structuredClone(acl.currentUser); } delete acl.currentUser; } @@ -617,7 +616,7 @@ RestWrite.prototype.handleAuthData = async function (authData) { // Run beforeLogin hook before storing any updates // to authData on the db; changes to userResult // will be ignored. - await this.runBeforeLoginTrigger(deepcopy(userResult)); + await this.runBeforeLoginTrigger(structuredClone(userResult)); // If we are in login operation via authData // we need to be sure that the user has provided @@ -1788,7 +1787,7 @@ RestWrite.prototype.sanitizedData = function () { delete data[key]; } return data; - }, deepcopy(this.data)); + }, structuredClone(this.data)); return Parse._decode(undefined, data); }; @@ -1838,7 +1837,7 @@ RestWrite.prototype.buildParseObjects = function () { delete data[key]; } return data; - }, deepcopy(this.data)); + }, structuredClone(this.data)); const sanitized = this.sanitizedData(); for (const attribute of readOnlyAttributes) { From bc9aa37c8509c425701726c4cb40bfa367906f19 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 14 Mar 2026 15:01:57 +0000 Subject: [PATCH 072/156] chore(release): 9.6.0-alpha.20 [skip ci] # [9.6.0-alpha.20](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.19...9.6.0-alpha.20) (2026-03-14) ### Bug Fixes * Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10200](https://github.com/parse-community/parse-server/issues/10200)) ([b321423](https://github.com/parse-community/parse-server/commit/b321423867f5e779b4750f97c4e42d408499fc3b)) --- 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 bf4d5a9a19..c8cf281da2 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.20](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.19...9.6.0-alpha.20) (2026-03-14) + + +### Bug Fixes + +* Schema poisoning via prototype pollution in deep copy ([GHSA-9ccr-fpp6-78qf](https://github.com/parse-community/parse-server/security/advisories/GHSA-9ccr-fpp6-78qf)) ([#10200](https://github.com/parse-community/parse-server/issues/10200)) ([b321423](https://github.com/parse-community/parse-server/commit/b321423867f5e779b4750f97c4e42d408499fc3b)) + # [9.6.0-alpha.19](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.18...9.6.0-alpha.19) (2026-03-14) diff --git a/package-lock.json b/package-lock.json index 08466faa82..49e331bdde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.19", + "version": "9.6.0-alpha.20", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.19", + "version": "9.6.0-alpha.20", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 212fad9289..5f037858f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.19", + "version": "9.6.0-alpha.20", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From f44e3061471c9d527b7c0894bbd86f1823de52c4 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 03:54:49 +0100 Subject: [PATCH 073/156] fix: Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) (#10202) --- spec/RequestComplexity.spec.js | 128 ++++++++++++++++++ spec/SecurityCheckGroups.spec.js | 2 + spec/vulnerabilities.spec.js | 22 +++ src/Controllers/DatabaseController.js | 23 +++- src/Options/Definitions.js | 6 + src/Options/docs.js | 1 + src/Options/index.js | 3 + .../CheckGroups/CheckGroupServerConfig.js | 2 +- 8 files changed, 179 insertions(+), 8 deletions(-) diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index 6ee159f548..2765ca02ec 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -37,6 +37,30 @@ describe('request complexity', () => { return where; } + function buildNestedOrQuery(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $or: [where, { username: 'test' }] }; + } + return where; + } + + function buildNestedAndQuery(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $and: [where, { username: 'test' }] }; + } + return where; + } + + function buildNestedNorQuery(depth) { + let where = { username: 'test' }; + for (let i = 0; i < depth; i++) { + where = { $nor: [where, { username: 'test' }] }; + } + return where; + } + describe('config validation', () => { it('should accept valid requestComplexity config', async () => { await expectAsync( @@ -45,6 +69,7 @@ describe('request complexity', () => { includeDepth: 10, includeCount: 100, subqueryDepth: 5, + queryDepth: 10, graphQLDepth: 15, graphQLFields: 300, }, @@ -59,6 +84,7 @@ describe('request complexity', () => { includeDepth: -1, includeCount: -1, subqueryDepth: -1, + queryDepth: -1, graphQLDepth: -1, graphQLFields: -1, }, @@ -112,6 +138,7 @@ describe('request complexity', () => { expect(config.requestComplexity.includeDepth).toBe(3); expect(config.requestComplexity.includeCount).toBe(50); expect(config.requestComplexity.subqueryDepth).toBe(5); + expect(config.requestComplexity.queryDepth).toBe(-1); expect(config.requestComplexity.graphQLDepth).toBe(50); expect(config.requestComplexity.graphQLFields).toBe(200); }); @@ -123,6 +150,7 @@ describe('request complexity', () => { includeDepth: 5, includeCount: 50, subqueryDepth: 5, + queryDepth: -1, graphQLDepth: 50, graphQLFields: 200, }); @@ -216,6 +244,106 @@ describe('request complexity', () => { }); }); + describe('query depth', () => { + let config; + + beforeEach(async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 3 }, + }); + config = Config.get('test'); + }); + + it('should allow $or within depth limit', async () => { + const where = buildNestedOrQuery(3); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject $or exceeding depth limit', async () => { + const where = buildNestedOrQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $and exceeding depth limit', async () => { + const where = buildNestedAndQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject $nor exceeding depth limit', async () => { + const where = buildNestedNorQuery(4); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should reject mixed nested operators exceeding depth limit', async () => { + // $or > $and > $nor > $or = depth 4 + const where = { + $or: [ + { + $and: [ + { + $nor: [ + { $or: [{ username: 'a' }, { username: 'b' }] }, + ], + }, + ], + }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/), + }) + ); + }); + + it('should allow with master key even when exceeding limit', async () => { + const where = buildNestedOrQuery(4); + await expectAsync( + rest.find(config, auth.master(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow with maintenance key even when exceeding limit', async () => { + const where = buildNestedOrQuery(4); + await expectAsync( + rest.find(config, auth.maintenance(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow unlimited when queryDepth is -1', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: -1 }, + }); + config = Config.get('test'); + const where = buildNestedOrQuery(15); + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + }); + describe('include limits', () => { let config; diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index 354fed8b0e..960281bc14 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -37,6 +37,7 @@ describe('Security Check Groups', () => { config.mountPlayground = false; config.readOnlyMasterKey = 'someReadOnlyMasterKey'; config.readOnlyMasterKeyIps = ['127.0.0.1', '::1']; + config.requestComplexity = { queryDepth: 10 }; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -66,6 +67,7 @@ describe('Security Check Groups', () => { includeDepth: -1, includeCount: -1, subqueryDepth: -1, + queryDepth: -1, graphQLDepth: -1, graphQLFields: -1, }; diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index b9e4042761..8fa3436537 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2747,3 +2747,25 @@ describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field na }); }); }); + +describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested query operators', () => { + it('rejects deeply nested $or query when queryDepth is set', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where, { username: 'test' }] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); +}); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 541be63076..59e3b089be 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -113,18 +113,27 @@ const validateQuery = ( query: any, isMaster: boolean, isMaintenance: boolean, - update: boolean + update: boolean, + options: ?ParseServerOptions, + _depth: number = 0 ): void => { if (isMaintenance) { isMaster = true; } + const rc = options?.requestComplexity; + if (!isMaster && rc && rc.queryDepth !== -1 && _depth > rc.queryDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${rc.queryDepth}` + ); + } if (query.ACL) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Cannot query on ACL.'); } if (query.$or) { if (query.$or instanceof Array) { - query.$or.forEach(value => validateQuery(value, isMaster, isMaintenance, update)); + query.$or.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $or format - use an array value.'); } @@ -132,7 +141,7 @@ const validateQuery = ( if (query.$and) { if (query.$and instanceof Array) { - query.$and.forEach(value => validateQuery(value, isMaster, isMaintenance, update)); + query.$and.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $and format - use an array value.'); } @@ -140,7 +149,7 @@ const validateQuery = ( if (query.$nor) { if (query.$nor instanceof Array && query.$nor.length > 0) { - query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update)); + query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error( Parse.Error.INVALID_QUERY, @@ -581,7 +590,7 @@ class DatabaseController { if (acl) { query = addWriteACL(query, acl); } - validateQuery(query, isMaster, false, true); + validateQuery(query, isMaster, false, true, this.options); return schemaController .getOneSchema(className, true) .catch(error => { @@ -829,7 +838,7 @@ class DatabaseController { if (acl) { query = addWriteACL(query, acl); } - validateQuery(query, isMaster, false, false); + validateQuery(query, isMaster, false, false, this.options); return schemaController .getOneSchema(className) .catch(error => { @@ -1340,7 +1349,7 @@ class DatabaseController { query = addReadACL(query, aclGroup); } } - validateQuery(query, isMaster, isMaintenance, false); + validateQuery(query, isMaster, isMaintenance, false, this.options); if (count) { if (!classExists) { return 0; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index aa9ff33e94..763192dca3 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -692,6 +692,12 @@ module.exports.RequestComplexityOptions = { action: parsers.numberParser('includeDepth'), default: 5, }, + queryDepth: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_QUERY_DEPTH', + help: 'Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('queryDepth'), + default: -1, + }, subqueryDepth: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH', help: 'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 432ff24440..5fb0f334fb 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -134,6 +134,7 @@ * @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`. * @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`. * @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`. + * @property {Number} queryDepth Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. * @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. */ diff --git a/src/Options/index.js b/src/Options/index.js index 79c46679c2..9352efe2d6 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -434,6 +434,9 @@ export interface RequestComplexityOptions { /* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. :DEFAULT: 5 */ subqueryDepth: ?number; + /* Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + queryDepth: ?number; /* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`. :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH :DEFAULT: 50 */ diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index 36b327dc91..8f93e51c35 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -145,7 +145,7 @@ class CheckGroupServerConfig extends CheckGroup { if (!rc) { throw 1; } - const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.graphQLDepth, rc.graphQLFields]; + const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.queryDepth, rc.graphQLDepth, rc.graphQLFields]; if (values.some(v => v === -1)) { throw 1; } From f06601c62f60424bdf40b9467886e26f564ee3c6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 15 Mar 2026 02:56:09 +0000 Subject: [PATCH 074/156] chore(release): 9.6.0-alpha.21 [skip ci] # [9.6.0-alpha.21](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.20...9.6.0-alpha.21) (2026-03-15) ### Bug Fixes * Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10202](https://github.com/parse-community/parse-server/issues/10202)) ([f44e306](https://github.com/parse-community/parse-server/commit/f44e3061471c9d527b7c0894bbd86f1823de52c4)) --- 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 c8cf281da2..e274ae6e7a 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.21](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.20...9.6.0-alpha.21) (2026-03-15) + + +### Bug Fixes + +* Server crash via deeply nested query condition operators ([GHSA-9xp9-j92r-p88v](https://github.com/parse-community/parse-server/security/advisories/GHSA-9xp9-j92r-p88v)) ([#10202](https://github.com/parse-community/parse-server/issues/10202)) ([f44e306](https://github.com/parse-community/parse-server/commit/f44e3061471c9d527b7c0894bbd86f1823de52c4)) + # [9.6.0-alpha.20](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.19...9.6.0-alpha.20) (2026-03-14) diff --git a/package-lock.json b/package-lock.json index 49e331bdde..350ae44f7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.20", + "version": "9.6.0-alpha.21", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.20", + "version": "9.6.0-alpha.21", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 5f037858f6..73744fba70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.20", + "version": "9.6.0-alpha.21", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From ab8dd54d8bcfea996aa60f0b9fac67dedb79d0e6 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 04:33:22 +0100 Subject: [PATCH 075/156] fix: Revert accidental breaking default values for query complexity limits (#10205) --- spec/RequestComplexity.spec.js | 18 +++++++++--------- spec/SecurityCheckGroups.spec.js | 9 ++++++++- src/Options/Definitions.js | 20 ++++++++++---------- src/Options/docs.js | 10 +++++----- src/Options/index.js | 20 ++++++++++---------- 5 files changed, 42 insertions(+), 35 deletions(-) diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index 2765ca02ec..2b9ec60390 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -136,23 +136,23 @@ describe('request complexity', () => { }); const config = Config.get('test'); expect(config.requestComplexity.includeDepth).toBe(3); - expect(config.requestComplexity.includeCount).toBe(50); - expect(config.requestComplexity.subqueryDepth).toBe(5); + expect(config.requestComplexity.includeCount).toBe(-1); + expect(config.requestComplexity.subqueryDepth).toBe(-1); expect(config.requestComplexity.queryDepth).toBe(-1); - expect(config.requestComplexity.graphQLDepth).toBe(50); - expect(config.requestComplexity.graphQLFields).toBe(200); + expect(config.requestComplexity.graphQLDepth).toBe(-1); + expect(config.requestComplexity.graphQLFields).toBe(-1); }); it('should apply full defaults when not configured', async () => { await reconfigureServer({}); const config = Config.get('test'); expect(config.requestComplexity).toEqual({ - includeDepth: 5, - includeCount: 50, - subqueryDepth: 5, + includeDepth: -1, + includeCount: -1, + subqueryDepth: -1, queryDepth: -1, - graphQLDepth: 50, - graphQLFields: 200, + graphQLDepth: -1, + graphQLFields: -1, }); }); }); diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index 960281bc14..7867c8c7d5 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -37,7 +37,14 @@ describe('Security Check Groups', () => { config.mountPlayground = false; config.readOnlyMasterKey = 'someReadOnlyMasterKey'; config.readOnlyMasterKeyIps = ['127.0.0.1', '::1']; - config.requestComplexity = { queryDepth: 10 }; + config.requestComplexity = { + includeDepth: 5, + includeCount: 50, + subqueryDepth: 5, + queryDepth: 10, + graphQLDepth: 50, + graphQLFields: 200, + }; await reconfigureServer(config); const group = new CheckGroupServerConfig(); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 763192dca3..e502ba6db5 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -670,27 +670,27 @@ module.exports.RateLimitOptions = { module.exports.RequestComplexityOptions = { graphQLDepth: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH', - help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.', + help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.', action: parsers.numberParser('graphQLDepth'), - default: 50, + default: -1, }, graphQLFields: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS', - help: 'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`.', + help: 'Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`.', action: parsers.numberParser('graphQLFields'), - default: 200, + default: -1, }, includeCount: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_COUNT', - help: 'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`.', + help: 'Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`.', action: parsers.numberParser('includeCount'), - default: 50, + default: -1, }, includeDepth: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_INCLUDE_DEPTH', - help: 'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`.', + help: 'Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`.', action: parsers.numberParser('includeDepth'), - default: 5, + default: -1, }, queryDepth: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_QUERY_DEPTH', @@ -700,9 +700,9 @@ module.exports.RequestComplexityOptions = { }, subqueryDepth: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_SUBQUERY_DEPTH', - help: 'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`.', + help: 'Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`.', action: parsers.numberParser('subqueryDepth'), - default: 5, + default: -1, }, }; module.exports.SecurityOptions = { diff --git a/src/Options/docs.js b/src/Options/docs.js index 5fb0f334fb..1e19bfa703 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -130,12 +130,12 @@ /** * @interface RequestComplexityOptions - * @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`. - * @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`. - * @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`. - * @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`. + * @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`. + * @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`. + * @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`. + * @property {Number} includeDepth Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`. * @property {Number} queryDepth Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. - * @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. + * @property {Number} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`. */ /** diff --git a/src/Options/index.js b/src/Options/index.js index 9352efe2d6..43cc58b287 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -425,25 +425,25 @@ export interface RateLimitOptions { } export interface RequestComplexityOptions { - /* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `5`. - :DEFAULT: 5 */ + /* Maximum depth of include pointer chains (e.g. `a.b.c` = depth 3). Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ includeDepth: ?number; - /* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `50`. - :DEFAULT: 50 */ + /* Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ includeCount: ?number; - /* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `5`. - :DEFAULT: 5 */ + /* Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ subqueryDepth: ?number; /* Maximum nesting depth of `$or`, `$and`, `$nor` query operators. Set to `-1` to disable. Default is `-1`. :DEFAULT: -1 */ queryDepth: ?number; - /* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `50`. + /* Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`. :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH - :DEFAULT: 50 */ + :DEFAULT: -1 */ graphQLDepth: ?number; - /* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `200`. + /* Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`. :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS - :DEFAULT: 200 */ + :DEFAULT: -1 */ graphQLFields: ?number; } From 2b28587aa8fb25cb0c81baf602e0aacbc0266846 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 15 Mar 2026 03:34:12 +0000 Subject: [PATCH 076/156] chore(release): 9.6.0-alpha.22 [skip ci] # [9.6.0-alpha.22](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.21...9.6.0-alpha.22) (2026-03-15) ### Bug Fixes * Revert accidental breaking default values for query complexity limits ([#10205](https://github.com/parse-community/parse-server/issues/10205)) ([ab8dd54](https://github.com/parse-community/parse-server/commit/ab8dd54d8bcfea996aa60f0b9fac67dedb79d0e6)) --- 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 e274ae6e7a..17c6dc9b45 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.22](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.21...9.6.0-alpha.22) (2026-03-15) + + +### Bug Fixes + +* Revert accidental breaking default values for query complexity limits ([#10205](https://github.com/parse-community/parse-server/issues/10205)) ([ab8dd54](https://github.com/parse-community/parse-server/commit/ab8dd54d8bcfea996aa60f0b9fac67dedb79d0e6)) + # [9.6.0-alpha.21](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.20...9.6.0-alpha.21) (2026-03-15) diff --git a/package-lock.json b/package-lock.json index 350ae44f7f..f6678fe2fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.21", + "version": "9.6.0-alpha.22", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.21", + "version": "9.6.0-alpha.22", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 73744fba70..787e23e5e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.21", + "version": "9.6.0-alpha.22", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From f403131cc54ecb08fe76d857be530415f760072c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 05:10:21 +0100 Subject: [PATCH 077/156] refactor: Deprecate disabled default values for query complexity limits (#10207) --- DEPRECATIONS.md | 1 + spec/Deprecator.spec.js | 51 ++++++++++++++++++++++++++++++++++ src/Deprecator/Deprecations.js | 30 ++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 7e6cbfdb49..3cb972d569 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -21,6 +21,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS15 | Config option `readOnlyMasterKeyIps` defaults to `['127.0.0.1', '::1']` | [#10115](https://github.com/parse-community/parse-server/pull/10115) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS16 | Remove config option `mountPlayground` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_change]: ## "The version and date of the planned change." diff --git a/spec/Deprecator.spec.js b/spec/Deprecator.spec.js index 7fe925c3fc..021a56903b 100644 --- a/spec/Deprecator.spec.js +++ b/spec/Deprecator.spec.js @@ -149,4 +149,55 @@ describe('Deprecator', () => { }) ); }); + + it('logs deprecation for requestComplexity limits when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + const keys = [ + 'requestComplexity.includeDepth', + 'requestComplexity.includeCount', + 'requestComplexity.subqueryDepth', + 'requestComplexity.queryDepth', + 'requestComplexity.graphQLDepth', + 'requestComplexity.graphQLFields', + ]; + for (const key of keys) { + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: key, + }) + ); + } + }); + + it('does not log deprecation for requestComplexity limits when explicitly set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ + requestComplexity: { + includeDepth: 10, + includeCount: 100, + subqueryDepth: 10, + queryDepth: 10, + graphQLDepth: 20, + graphQLFields: 200, + }, + }); + const keys = [ + 'requestComplexity.includeDepth', + 'requestComplexity.includeCount', + 'requestComplexity.subqueryDepth', + 'requestComplexity.queryDepth', + 'requestComplexity.graphQLDepth', + 'requestComplexity.graphQLFields', + ]; + for (const key of keys) { + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: key, + }) + ); + } + }); }); diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 60e37e6efb..ad8f3ba805 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -41,4 +41,34 @@ module.exports = [ changeNewKey: '', solution: "Use Parse Dashboard as GraphQL IDE or configure a third-party GraphQL client such as Apollo Sandbox, GraphiQL, or Insomnia with custom request headers.", }, + { + optionKey: 'requestComplexity.includeDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.includeDepth' to a positive integer appropriate for your app to limit include pointer chain depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.includeCount', + changeNewDefault: '100', + solution: "Set 'requestComplexity.includeCount' to a positive integer appropriate for your app to limit the number of include paths per query, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.subqueryDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.subqueryDepth' to a positive integer appropriate for your app to limit subquery nesting depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.queryDepth', + changeNewDefault: '10', + solution: "Set 'requestComplexity.queryDepth' to a positive integer appropriate for your app to limit query condition nesting depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.graphQLDepth', + changeNewDefault: '20', + solution: "Set 'requestComplexity.graphQLDepth' to a positive integer appropriate for your app to limit GraphQL field selection depth, or to '-1' to disable.", + }, + { + optionKey: 'requestComplexity.graphQLFields', + changeNewDefault: '200', + solution: "Set 'requestComplexity.graphQLFields' to a positive integer appropriate for your app to limit the number of GraphQL field selections, or to '-1' to disable.", + }, ]; From ad463d2e422565befd084b725ac94cc6a8df2fd2 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:48:02 +0100 Subject: [PATCH 078/156] test: LiveQuery operator type confusion (GHSA-fjxm-vhvc-gcmj) (#10208) --- spec/vulnerabilities.spec.js | 100 +++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 8fa3436537..135edf694e 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2769,3 +2769,103 @@ describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested q ); }); }); + +describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { + const matchesQuery = require('../lib/LiveQuery/QueryTools').matchesQuery; + + // Unit tests: matchesQuery receives the raw where clause (not {className, where}) + // just as _matchesSubscription passes subscription.query (the where clause) + describe('matchesQuery with type-confused operators', () => { + it('$in with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$nin with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $nin: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$containedBy with object instead of array throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: ['abc'] }; + const where = { name: { $containedBy: { x: 1 } } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$containedBy with missing field throws', () => { + const object = { className: 'TestObject', objectId: 'obj1' }; + const where = { name: { $containedBy: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$all with object field value throws', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: { x: 1 } }; + const where = { name: { $all: ['abc'] } }; + expect(() => matchesQuery(object, where)).toThrow(); + }); + + it('$in with valid array does not throw', () => { + const object = { className: 'TestObject', objectId: 'obj1', name: 'abc' }; + const where = { name: { $in: ['abc', 'xyz'] } }; + expect(() => matchesQuery(object, where)).not.toThrow(); + expect(matchesQuery(object, where)).toBe(true); + }); + }); + + // Integration test: verify that a LiveQuery subscription with type-confused + // operators does not crash the server and other subscriptions continue working + describe('LiveQuery integration', () => { + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestObject'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('server does not crash and other subscriptions work when type-confused subscription exists', async () => { + // First subscribe with a malformed query via manual client + const malClient = new Parse.LiveQueryClient({ + applicationId: 'test', + serverURL: 'ws://localhost:1337', + javascriptKey: 'test', + }); + malClient.open(); + const malformedQuery = new Parse.Query('TestObject'); + malformedQuery._where = { name: { $in: { x: 1 } } }; + await malClient.subscribe(malformedQuery); + + // Then subscribe with a valid query using the default client + const validQuery = new Parse.Query('TestObject'); + validQuery.equalTo('name', 'test'); + const validSubscription = await validQuery.subscribe(); + + try { + const createPromise = new Promise(resolve => { + validSubscription.on('create', object => { + expect(object.get('name')).toBe('test'); + resolve(); + }); + }); + + const obj = new Parse.Object('TestObject'); + obj.set('name', 'test'); + await obj.save(); + await createPromise; + } finally { + malClient.close(); + } + }); + }); +}); From df690463f8066dcde17a2e90e53dfbd7e86ff0bd Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:41:02 +0100 Subject: [PATCH 079/156] fix: Validate body field types in request middleware (#10209) --- spec/Middlewares.spec.js | 97 +++++++++++++++++++++++++++++++ spec/RegexVulnerabilities.spec.js | 3 +- src/middlewares.js | 22 ++++++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 8e1e04f8e5..29c0cc8c0b 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -430,6 +430,103 @@ describe('middlewares', () => { expect(middlewares.checkIp(localhostV62, ['127.0.0.1'], new Map())).toBe(true); }); + describe('body field type validation', () => { + beforeEach(() => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKeyIps: ['0.0.0.0/0'], + }); + }); + + it('should reject non-string _SessionToken in body', async () => { + fakeReq.body._SessionToken = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _ClientVersion in body', async () => { + fakeReq.body._ClientVersion = { toLowerCase: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _InstallationId in body', async () => { + fakeReq.body._InstallationId = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string _ContentType in body', async () => { + fakeReq.body._ContentType = { toString: 'evil' }; + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should reject non-string base64 in file-via-JSON upload', async () => { + fakeReq.body = Buffer.from( + JSON.stringify({ + _ApplicationId: 'FakeAppId', + base64: { toString: 'evil' }, + }) + ); + await middlewares.handleParseHeaders(fakeReq, fakeRes); + expect(fakeRes.status).toHaveBeenCalledWith(403); + }); + + it('should not crash the server process on non-string body fields', async () => { + // Verify that type confusion in body fields does not crash the Node.js process. + // Each request should be handled independently without affecting server stability. + const payloads = [ + { _SessionToken: { toString: 'evil' } }, + { _ClientVersion: { toLowerCase: 'evil' } }, + { _InstallationId: [1, 2, 3] }, + { _ContentType: { toString: 'evil' } }, + ]; + for (const payload of payloads) { + const req = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId', ...payload }, + headers: {}, + get: key => req.headers[key.toLowerCase()], + }; + const res = jasmine.createSpyObj('res', ['end', 'status']); + await middlewares.handleParseHeaders(req, res); + expect(res.status).toHaveBeenCalledWith(403); + } + // Server process is still alive — a subsequent valid request works + const validReq = { + ip: '127.0.0.1', + originalUrl: 'http://example.com/parse/', + url: 'http://example.com/', + body: { _ApplicationId: 'FakeAppId' }, + headers: {}, + get: key => validReq.headers[key.toLowerCase()], + }; + const validRes = jasmine.createSpyObj('validRes', ['end', 'status']); + let nextCalled = false; + await middlewares.handleParseHeaders(validReq, validRes, () => { + nextCalled = true; + }); + expect(nextCalled).toBe(true); + expect(validRes.status).not.toHaveBeenCalled(); + }); + + it('should still accept valid string body fields', done => { + fakeReq.body._SessionToken = 'r:validtoken'; + fakeReq.body._ClientVersion = 'js1.0.0'; + fakeReq.body._InstallationId = 'install123'; + fakeReq.body._ContentType = 'application/json'; + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual('r:validtoken'); + expect(fakeReq.info.clientVersion).toEqual('js1.0.0'); + expect(fakeReq.info.installationId).toEqual('install123'); + expect(fakeReq.headers['content-type']).toEqual('application/json'); + done(); + }); + }); + }); + it('should match address with cache', () => { const ipv6 = '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; const cache1 = new Map(); diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js index 0686fd1fab..f22ef868a8 100644 --- a/spec/RegexVulnerabilities.spec.js +++ b/spec/RegexVulnerabilities.spec.js @@ -65,8 +65,7 @@ describe('Regex Vulnerabilities', () => { }); fail('should not work'); } catch (e) { - expect(e.data.code).toEqual(209); - expect(e.data.error).toEqual('Invalid session token'); + expect(e.data.error).toEqual('unauthorized'); } }); diff --git a/src/middlewares.js b/src/middlewares.js index 9c5eb68242..779b0fafdf 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -150,18 +150,30 @@ export async function handleParseHeaders(req, res, next) { // TODO: test that the REST API formats generated by the other // SDKs are handled ok if (req.body._ClientVersion) { + if (typeof req.body._ClientVersion !== 'string') { + return invalidRequest(req, res); + } info.clientVersion = req.body._ClientVersion; delete req.body._ClientVersion; } if (req.body._InstallationId) { + if (typeof req.body._InstallationId !== 'string') { + return invalidRequest(req, res); + } info.installationId = req.body._InstallationId; delete req.body._InstallationId; } if (req.body._SessionToken) { + if (typeof req.body._SessionToken !== 'string') { + return invalidRequest(req, res); + } info.sessionToken = req.body._SessionToken; delete req.body._SessionToken; } if (req.body._MasterKey) { + if (typeof req.body._MasterKey !== 'string') { + return invalidRequest(req, res); + } info.masterKey = req.body._MasterKey; delete req.body._MasterKey; } @@ -181,6 +193,9 @@ export async function handleParseHeaders(req, res, next) { delete req.body._context; } if (req.body._ContentType) { + if (typeof req.body._ContentType !== 'string') { + return invalidRequest(req, res); + } req.headers['content-type'] = req.body._ContentType; delete req.body._ContentType; } @@ -190,14 +205,17 @@ export async function handleParseHeaders(req, res, next) { } if (info.sessionToken && typeof info.sessionToken !== 'string') { - info.sessionToken = info.sessionToken.toString(); + return invalidRequest(req, res); } - if (info.clientVersion) { + if (info.clientVersion && typeof info.clientVersion === 'string') { info.clientSDK = ClientSDK.fromString(info.clientVersion); } if (fileViaJSON && req.body) { + if (req.body.base64 && typeof req.body.base64 !== 'string') { + return invalidRequest(req, res); + } req.fileData = req.body.fileData; // We need to repopulate req.body with a buffer var base64 = req.body.base64; From 96d8703cd418f1fecc8d89138d768f3e559899ea Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 15 Mar 2026 15:42:04 +0000 Subject: [PATCH 080/156] chore(release): 9.6.0-alpha.23 [skip ci] # [9.6.0-alpha.23](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.22...9.6.0-alpha.23) (2026-03-15) ### Bug Fixes * Validate body field types in request middleware ([#10209](https://github.com/parse-community/parse-server/issues/10209)) ([df69046](https://github.com/parse-community/parse-server/commit/df690463f8066dcde17a2e90e53dfbd7e86ff0bd)) --- 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 17c6dc9b45..26e557cce6 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.23](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.22...9.6.0-alpha.23) (2026-03-15) + + +### Bug Fixes + +* Validate body field types in request middleware ([#10209](https://github.com/parse-community/parse-server/issues/10209)) ([df69046](https://github.com/parse-community/parse-server/commit/df690463f8066dcde17a2e90e53dfbd7e86ff0bd)) + # [9.6.0-alpha.22](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.21...9.6.0-alpha.22) (2026-03-15) diff --git a/package-lock.json b/package-lock.json index f6678fe2fc..10a25b27aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.22", + "version": "9.6.0-alpha.23", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.22", + "version": "9.6.0-alpha.23", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 787e23e5e0..744c1d7adb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.22", + "version": "9.6.0-alpha.23", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 286373dddfef5ef90505be5d954297daed32458c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:13:30 +0100 Subject: [PATCH 081/156] fix: Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) (#10210) --- spec/vulnerabilities.spec.js | 65 ++++++++++++++++++++++++++++++++++++ src/triggers.js | 6 ++++ 2 files changed, 71 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 135edf694e..79cfc9b772 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -251,6 +251,71 @@ describe('Vulnerabilities', () => { }); }); + describe('(GHSA-4263-jgmp-7pf4) Cloud function prototype chain dispatch via registered function', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + beforeEach(() => { + Parse.Cloud.define('legitimateFunction', () => 'ok'); + }); + + it('rejects prototype chain traversal from a registered function name', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction.__proto__.__proto__.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 prototype chain traversal via single __proto__ from a registered function', async () => { + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction.__proto__.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('does not crash the server when prototype chain traversal is attempted', async () => { + const maliciousNames = [ + 'legitimateFunction.__proto__.__proto__.constructor', + 'legitimateFunction.__proto__.constructor', + 'legitimateFunction.constructor', + 'legitimateFunction.__proto__', + ]; + for (const name of maliciousNames) { + const response = await request({ + headers, + method: 'POST', + url: `http://localhost:8378/1/functions/${encodeURIComponent(name)}`, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBe(400); + } + // Verify server is still responsive after all attempts + const healthResponse = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/functions/legitimateFunction', + body: JSON.stringify({}), + }); + expect(healthResponse.status).toBe(200); + expect(JSON.parse(healthResponse.text).result).toBe('ok'); + }); + }); + describe('(GHSA-3v4q-4q9g-x83q) Prototype pollution via application ID in trigger store', () => { const prototypeProperties = ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__']; diff --git a/src/triggers.js b/src/triggers.js index b35b5f9804..7558aa6c2d 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -109,6 +109,9 @@ function getStore(category, name, applicationId) { _triggerStore[applicationId] = _triggerStore[applicationId] || baseStore(); let store = _triggerStore[applicationId][category]; for (const component of path) { + if (!Object.prototype.hasOwnProperty.call(store, component)) { + return createStore(); + } store = store[component]; if (!store) { return createStore(); @@ -137,6 +140,9 @@ function remove(category, name, applicationId) { function get(category, name, applicationId) { const lastComponent = name.split('.').splice(-1); const store = getStore(category, name, applicationId); + if (!Object.prototype.hasOwnProperty.call(store, lastComponent)) { + return undefined; + } return store[lastComponent]; } From 9c48765124e7d2b7c8a3e634299b754e1f17ec11 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 15 Mar 2026 17:14:24 +0000 Subject: [PATCH 082/156] chore(release): 9.6.0-alpha.24 [skip ci] # [9.6.0-alpha.24](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.23...9.6.0-alpha.24) (2026-03-15) ### Bug Fixes * Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10210](https://github.com/parse-community/parse-server/issues/10210)) ([286373d](https://github.com/parse-community/parse-server/commit/286373dddfef5ef90505be5d954297daed32458c)) --- 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 26e557cce6..3fa9c5dca1 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.24](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.23...9.6.0-alpha.24) (2026-03-15) + + +### Bug Fixes + +* Cloud function dispatch crashes server via prototype chain traversal ([GHSA-4263-jgmp-7pf4](https://github.com/parse-community/parse-server/security/advisories/GHSA-4263-jgmp-7pf4)) ([#10210](https://github.com/parse-community/parse-server/issues/10210)) ([286373d](https://github.com/parse-community/parse-server/commit/286373dddfef5ef90505be5d954297daed32458c)) + # [9.6.0-alpha.23](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.22...9.6.0-alpha.23) (2026-03-15) diff --git a/package-lock.json b/package-lock.json index 10a25b27aa..9a05658b40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.23", + "version": "9.6.0-alpha.24", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.23", + "version": "9.6.0-alpha.24", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 744c1d7adb..9e3eb7679f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.23", + "version": "9.6.0-alpha.24", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 386a989bd2d5b9a48e4830a87ecb01f8ef22d903 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:43:23 +0100 Subject: [PATCH 083/156] fix: Validate token type in PagesRouter to prevent type confusion errors (#10212) --- spec/PagesRouter.spec.js | 50 +++++++++++++++++++++++++++++++ spec/RegexVulnerabilities.spec.js | 11 ++++--- src/Routers/PagesRouter.js | 8 ++--- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index e20be40bfd..f25035ec2c 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -990,6 +990,56 @@ describe('Pages Router', () => { await fs.rm(baseDir, { recursive: true, force: true }); } }); + + it('rejects non-string token in verifyEmail', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/verify_email?token[toString]=abc`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in requestResetPassword', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/request_password_reset?token[toString]=abc`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in resetPassword via POST', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/request_password_reset`; + const response = await request({ + method: 'POST', + url: url, + headers: { + 'Content-Type': 'application/json', + }, + body: { token: { toString: 'abc' }, new_password: 'newpass123' }, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); + + it('rejects non-string token in resendVerificationEmail via POST', async () => { + await reconfigureServer(config); + const url = `${config.publicServerURL}/apps/test/resend_verification_email`; + const response = await request({ + method: 'POST', + url: url, + headers: { + 'Content-Type': 'application/json', + }, + body: { token: { toString: 'abc' } }, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); }); describe('custom route', () => { diff --git a/spec/RegexVulnerabilities.spec.js b/spec/RegexVulnerabilities.spec.js index f22ef868a8..297be93599 100644 --- a/spec/RegexVulnerabilities.spec.js +++ b/spec/RegexVulnerabilities.spec.js @@ -345,10 +345,10 @@ describe('Regex Vulnerabilities', () => { describe('on resend verification email', () => { // The PagesRouter uses express.urlencoded({ extended: false }) which does not parse // nested objects (e.g. token[$regex]=^.), so the HTTP layer already blocks object injection. - // The toString() guard in resendVerificationEmail() is defense-in-depth in case the - // body parser configuration changes. These tests verify the guard works correctly + // Non-string tokens are rejected (treated as undefined) to prevent both NoSQL injection + // and type confusion errors. These tests verify the guard works correctly // by directly testing the PagesRouter method. - it('should sanitize non-string token to string via toString()', async () => { + it('should reject non-string token as undefined', async () => { const { PagesRouter } = require('../lib/Routers/PagesRouter'); const router = new PagesRouter(); const goToPage = spyOn(router, 'goToPage').and.returnValue(Promise.resolve()); @@ -363,10 +363,9 @@ describe('Regex Vulnerabilities', () => { }, }; await router.resendVerificationEmail(req); - // The token passed to userController.resendVerificationEmail should be a string + // Non-string token should be treated as undefined const passedToken = resendSpy.calls.first().args[2]; - expect(typeof passedToken).toEqual('string'); - expect(passedToken).toEqual('[object Object]'); + expect(passedToken).toBeUndefined(); }); it('should pass through valid string token unchanged', async () => { diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index 59c4abf631..12d4e7253c 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -84,7 +84,7 @@ export class PagesRouter extends PromiseRouter { verifyEmail(req) { const config = req.config; const { token: rawToken } = req.query; - const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const token = typeof rawToken === 'string' ? rawToken : undefined; if (!config) { this.invalidRequest(); @@ -109,7 +109,7 @@ export class PagesRouter extends PromiseRouter { const config = req.config; const username = req.body?.username; const rawToken = req.body?.token; - const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const token = typeof rawToken === 'string' ? rawToken : undefined; if (!config) { this.invalidRequest(); @@ -151,7 +151,7 @@ export class PagesRouter extends PromiseRouter { } const { token: rawToken } = req.query; - const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const token = typeof rawToken === 'string' ? rawToken : undefined; if (!token) { return this.goToPage(req, pages.passwordResetLinkInvalid); @@ -180,7 +180,7 @@ export class PagesRouter extends PromiseRouter { } const { new_password, token: rawToken } = req.body || {}; - const token = rawToken && typeof rawToken !== 'string' ? rawToken.toString() : rawToken; + const token = typeof rawToken === 'string' ? rawToken : undefined; if ((!token || !new_password) && req.xhr === false) { return this.goToPage(req, pages.passwordResetLinkInvalid); From d826dc76354b9a0c3dddd4c91fcb38e761f088dc Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 15 Mar 2026 18:44:20 +0000 Subject: [PATCH 084/156] chore(release): 9.6.0-alpha.25 [skip ci] # [9.6.0-alpha.25](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.24...9.6.0-alpha.25) (2026-03-15) ### Bug Fixes * Validate token type in PagesRouter to prevent type confusion errors ([#10212](https://github.com/parse-community/parse-server/issues/10212)) ([386a989](https://github.com/parse-community/parse-server/commit/386a989bd2d5b9a48e4830a87ecb01f8ef22d903)) --- 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 3fa9c5dca1..698a5203ce 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.25](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.24...9.6.0-alpha.25) (2026-03-15) + + +### Bug Fixes + +* Validate token type in PagesRouter to prevent type confusion errors ([#10212](https://github.com/parse-community/parse-server/issues/10212)) ([386a989](https://github.com/parse-community/parse-server/commit/386a989bd2d5b9a48e4830a87ecb01f8ef22d903)) + # [9.6.0-alpha.24](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.23...9.6.0-alpha.24) (2026-03-15) diff --git a/package-lock.json b/package-lock.json index 9a05658b40..956e1719a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.24", + "version": "9.6.0-alpha.25", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.24", + "version": "9.6.0-alpha.25", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 9e3eb7679f..afdca7d91e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.24", + "version": "9.6.0-alpha.25", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 2a9fdab3672e702ce296fc83c99902da37e53e29 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:51:40 +0100 Subject: [PATCH 085/156] fix: Validate session in middleware for non-GET requests to `/sessions/me` (#10213) --- spec/ParseSession.spec.js | 64 +++++++++++++++++++++++++++++++++++++++ src/middlewares.js | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/spec/ParseSession.spec.js b/spec/ParseSession.spec.js index 1352fae06a..7f4dfaddc0 100644 --- a/spec/ParseSession.spec.js +++ b/spec/ParseSession.spec.js @@ -256,4 +256,68 @@ describe('Parse.Session', () => { expect(newSession.createdWith.action).toBe('create'); expect(newSession.createdWith.authProvider).toBeUndefined(); }); + + describe('PUT /sessions/me', () => { + it('should return error with invalid session token', async () => { + const response = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': 'r:invalid-session-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).not.toBe(500); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + }); + + it('should return error without session token', async () => { + const response = await request({ + method: 'PUT', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }).catch(e => e); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + expect(response.data?.code).toBeDefined(); + }); + }); + + describe('DELETE /sessions/me', () => { + it('should return error with invalid session token', async () => { + const response = await request({ + method: 'DELETE', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': 'r:invalid-session-token', + }, + }).catch(e => e); + expect(response.status).not.toBe(500); + expect(response.data.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); + }); + + it('should return error without session token', async () => { + const response = await request({ + method: 'DELETE', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + expect(response.data?.code).toBeDefined(); + }); + }); }); diff --git a/src/middlewares.js b/src/middlewares.js index 779b0fafdf..a67da4e90a 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -377,7 +377,7 @@ const handleRateLimit = async (req, res, next) => { export const handleParseSession = async (req, res, next) => { try { const info = req.info; - if (req.auth || req.url === '/sessions/me') { + if (req.auth || (req.url === '/sessions/me' && req.method === 'GET')) { next(); return; } From 90f254ddc55ff9544605388af6d02ef8b7096e59 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 15 Mar 2026 18:53:20 +0000 Subject: [PATCH 086/156] chore(release): 9.6.0-alpha.26 [skip ci] # [9.6.0-alpha.26](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.25...9.6.0-alpha.26) (2026-03-15) ### Bug Fixes * Validate session in middleware for non-GET requests to `/sessions/me` ([#10213](https://github.com/parse-community/parse-server/issues/10213)) ([2a9fdab](https://github.com/parse-community/parse-server/commit/2a9fdab3672e702ce296fc83c99902da37e53e29)) --- 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 698a5203ce..5d797102a5 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.26](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.25...9.6.0-alpha.26) (2026-03-15) + + +### Bug Fixes + +* Validate session in middleware for non-GET requests to `/sessions/me` ([#10213](https://github.com/parse-community/parse-server/issues/10213)) ([2a9fdab](https://github.com/parse-community/parse-server/commit/2a9fdab3672e702ce296fc83c99902da37e53e29)) + # [9.6.0-alpha.25](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.24...9.6.0-alpha.25) (2026-03-15) diff --git a/package-lock.json b/package-lock.json index 956e1719a2..ee057a038a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.25", + "version": "9.6.0-alpha.26", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.25", + "version": "9.6.0-alpha.26", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index afdca7d91e..5147ed285e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.25", + "version": "9.6.0-alpha.26", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 434ecbec702e74fe8d151fbfc5ec0779f77a25f2 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:50:40 +0100 Subject: [PATCH 087/156] fix: Rate limit user zone key fallback and batch request bypass (#10214) --- spec/RateLimit.spec.js | 155 +++++++++++++++++++++++++++++++++++++++++ src/batch.js | 67 ++++++++---------- src/middlewares.js | 2 +- 3 files changed, 185 insertions(+), 39 deletions(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index f482f7a700..516ea2fa08 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -434,6 +434,73 @@ describe('rate limit', () => { new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') ); }); + + it('should rate limit per user independently with user zone', async () => { + await reconfigureServer({ + rateLimit: { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + zone: Parse.Server.RateLimitZone.user, + }, + }); + Parse.Cloud.define('test', () => 'Abc'); + // Sign up two different users using REST API to avoid destroying sessions + const res1 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user1', password: 'password' }), + }); + const sessionToken1 = res1.data.sessionToken; + const res2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/users', + body: JSON.stringify({ username: 'user2', password: 'password' }), + }); + const sessionToken2 = res2.data.sessionToken; + // User 1 makes a request — should succeed + const result1 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }); + expect(result1.data.result).toBe('Abc'); + // User 2 makes a request — should also succeed (independent rate limit per user) + const result2 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }); + expect(result2.data.result).toBe('Abc'); + // User 1 makes another request — should be rate limited + const result3 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken1 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }).catch(e => e); + expect(result3.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + // User 2 makes another request — should also be rate limited + const result4 = await request({ + method: 'POST', + headers: { ...headers, 'X-Parse-Session-Token': sessionToken2 }, + url: 'http://localhost:8378/1/functions/test', + body: JSON.stringify({}), + }).catch(e => e); + expect(result4.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); }); it('can validate rateLimit', async () => { @@ -679,6 +746,94 @@ describe('rate limit', () => { }); }); + it('should enforce rate limit across direct requests and batch sub-requests', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // First direct request — should succeed (count: 1) + const obj = new Parse.Object('MyObject'); + await obj.save(); + // Batch with 1 sub-request — should succeed (count: 2) + const response1 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + ], + }), + }); + expect(response1.data.length).toBe(1); + expect(response1.data[0].success).toBeDefined(); + // Another batch with 1 sub-request — should be rate limited (count would be 3) + const response2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + ], + }), + }).catch(e => e); + expect(response2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should enforce rate limit for multiple batch requests in same window', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 2, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // First batch with 2 sub-requests — should succeed (count: 2) + const response1 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } }, + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } }, + ], + }), + }); + expect(response1.data.length).toBe(2); + expect(response1.data[0].success).toBeDefined(); + // Second batch with 1 sub-request — should be rate limited (count would be 3) + const response2 = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } }, + ], + }), + }).catch(e => e); + expect(response2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + it('should not reject batch when sub-requests target non-rate-limited paths', async () => { await reconfigureServer({ rateLimit: [ diff --git a/src/batch.js b/src/batch.js index b5acfee139..98648e915b 100644 --- a/src/batch.js +++ b/src/batch.js @@ -63,7 +63,7 @@ function makeBatchRoutingPathFunction(originalUrl, serverURL, publicServerURL) { // Returns a promise for a {response} object. // TODO: pass along auth correctly -function handleBatch(router, req) { +async function handleBatch(router, req) { if (!Array.isArray(req.body?.requests)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array'); } @@ -83,47 +83,38 @@ function handleBatch(router, req) { req.config.publicServerURL ); - // Check if batch sub-requests would exceed any configured rate limits. - // Count how many sub-requests target each rate-limited path and reject - // the entire batch if any path's count exceeds its requestCount. + // Enforce rate limits for each batch sub-request by invoking the + // rate limit handler. This ensures sub-requests consume tokens from + // the same window state as direct requests. const rateLimits = req.config.rateLimits || []; - for (const limit of rateLimits) { - // Skip rate limit if master key is used and includeMasterKey is not set - if (req.auth?.isMaster && !limit.includeMasterKey) { - continue; - } - // Skip rate limit for internal requests if includeInternalRequests is not set - if (req.config.ip === '127.0.0.1' && !limit.includeInternalRequests) { - continue; - } - const pathExp = limit.path.regexp || limit.path; - let matchCount = 0; - for (const restRequest of req.body.requests) { - // Check if sub-request method matches the rate limit's requestMethods filter - if (limit.requestMethods) { - const method = restRequest.method?.toUpperCase(); - if (Array.isArray(limit.requestMethods)) { - if (!limit.requestMethods.includes(method)) { - continue; - } - } else { - const regExp = new RegExp(limit.requestMethods); - if (!regExp.test(method)) { - continue; - } - } + for (const restRequest of req.body.requests) { + const routablePath = makeRoutablePath(restRequest.path); + for (const limit of rateLimits) { + const pathExp = limit.path.regexp || limit.path; + if (!pathExp.test(routablePath)) { + continue; } - const routablePath = makeRoutablePath(restRequest.path); - if (pathExp.test(routablePath)) { - matchCount++; + const fakeReq = { + ip: req.ip || req.config?.ip || '127.0.0.1', + method: (restRequest.method || 'GET').toUpperCase(), + config: req.config, + auth: req.auth, + info: req.info, + }; + const fakeRes = { setHeader() {} }; + try { + await limit.handler(fakeReq, fakeRes, err => { + if (err) { + throw err; + } + }); + } catch { + throw new Parse.Error( + Parse.Error.CONNECTION_FAILED, + limit.errorResponseMessage || 'Too many requests' + ); } } - if (matchCount > limit.requestCount) { - throw new Parse.Error( - Parse.Error.CONNECTION_FAILED, - limit.errorResponseMessage || 'Batch request exceeds rate limit for endpoint' - ); - } } const batch = transactionRetries => { diff --git a/src/middlewares.js b/src/middlewares.js index a67da4e90a..fe87166d3b 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -650,7 +650,7 @@ export const addRateLimit = (route, config, cloud) => { if (!request.auth) { await new Promise(resolve => handleParseSession(request, null, resolve)); } - if (request.auth?.user?.id && request.zone === 'user') { + if (request.auth?.user?.id && route.zone === 'user') { return request.auth.user.id; } } From 8a0bea12ed82bccf1e54a4d575ae8cb4f6458bcd Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 15 Mar 2026 20:51:37 +0000 Subject: [PATCH 088/156] chore(release): 9.6.0-alpha.27 [skip ci] # [9.6.0-alpha.27](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.26...9.6.0-alpha.27) (2026-03-15) ### Bug Fixes * Rate limit user zone key fallback and batch request bypass ([#10214](https://github.com/parse-community/parse-server/issues/10214)) ([434ecbe](https://github.com/parse-community/parse-server/commit/434ecbec702e74fe8d151fbfc5ec0779f77a25f2)) --- 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 5d797102a5..3fbf9ae377 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.27](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.26...9.6.0-alpha.27) (2026-03-15) + + +### Bug Fixes + +* Rate limit user zone key fallback and batch request bypass ([#10214](https://github.com/parse-community/parse-server/issues/10214)) ([434ecbe](https://github.com/parse-community/parse-server/commit/434ecbec702e74fe8d151fbfc5ec0779f77a25f2)) + # [9.6.0-alpha.26](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.25...9.6.0-alpha.26) (2026-03-15) diff --git a/package-lock.json b/package-lock.json index ee057a038a..650ec49d42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.26", + "version": "9.6.0-alpha.27", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.26", + "version": "9.6.0-alpha.27", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 5147ed285e..843575dca7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.26", + "version": "9.6.0-alpha.27", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 12c46089da0a1e1901c659c950df5d445f840255 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 00:50:53 +0100 Subject: [PATCH 089/156] test: Request complexity controls correctly enforce subquery depth limits (GHSA-rqxg-pcc9-f79w) (#10215) --- spec/GraphQLQueryComplexity.spec.js | 34 +++++++++++ spec/RequestComplexity.spec.js | 87 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/spec/GraphQLQueryComplexity.spec.js b/spec/GraphQLQueryComplexity.spec.js index 976cc761f4..8b6ba98800 100644 --- a/spec/GraphQLQueryComplexity.spec.js +++ b/spec/GraphQLQueryComplexity.spec.js @@ -178,4 +178,38 @@ describe('graphql query complexity', () => { expect(result.errors).toBeUndefined(); }); }); + + describe('where argument breadth', () => { + it('should enforce depth and field limits regardless of where argument breadth', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 3, graphQLFields: 200, subqueryDepth: 1 }, + }); + // The GraphQL where argument may contain many OR branches, but the + // complexity check correctly measures the selection set depth/fields, + // not the where variable content. A query exceeding graphQLDepth is + // rejected even when the where argument is simple. + const result = await graphqlRequest(buildDeepQuery()); + expect(result.errors).toBeDefined(); + expect(result.errors[0].message).toMatch( + /GraphQL query depth of \d+ exceeds maximum allowed depth of 3/ + ); + }); + + it('should allow query with wide where argument when selection set is within limits', async () => { + await setupGraphQL({ + requestComplexity: { graphQLDepth: 10, graphQLFields: 200, subqueryDepth: 1 }, + }); + + const obj = new Parse.Object('TestItem'); + obj.set('name', 'test'); + await obj.save(); + + // Wide where with many OR branches — complexity check measures selection + // set depth and field count, not where argument structure + const orBranches = Array.from({ length: 20 }, (_, i) => `{ name: { equalTo: "test${i}" } }`).join(', '); + const query = `{ testItems(where: { OR: [${orBranches}] }) { edges { node { objectId } } } }`; + const result = await graphqlRequest(query); + expect(result.errors).toBeUndefined(); + }); + }); }); diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index 2b9ec60390..aa33a593fa 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -242,6 +242,93 @@ describe('request complexity', () => { rest.find(config, auth.nobody(config), '_User', where) ).toBeResolved(); }); + + it('should allow multiple sibling $inQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + // Multiple sibling $inQuery operators in $or, each at depth 1 — within the limit + const where = { + $or: [ + { username: { $inQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should reject sibling $inQuery when nested beyond depth limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + // Each sibling contains a nested $inQuery at depth 2 — exceeds limit + const where = { + $or: [ + { + username: { + $inQuery: { + className: '_User', + where: { username: { $inQuery: { className: '_User', where: {} } } }, + }, + }, + }, + { + username: { + $inQuery: { + className: '_User', + where: { username: { $inQuery: { className: '_User', where: {} } } }, + }, + }, + }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Subquery nesting depth exceeds maximum allowed depth of 1/), + }) + ); + }); + + it('should allow multiple sibling $notInQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + const where = { + $or: [ + { username: { $notInQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); + + it('should allow mixed sibling $inQuery and $notInQuery at same depth within limit', async () => { + await reconfigureServer({ + requestComplexity: { subqueryDepth: 1 }, + }); + config = Config.get('test'); + const where = { + $or: [ + { username: { $inQuery: { className: '_User', where: { username: 'a' } } } }, + { username: { $notInQuery: { className: '_User', where: { username: 'b' } } } }, + { username: { $inQuery: { className: '_User', where: { username: 'c' } } } }, + ], + }; + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeResolved(); + }); }); describe('query depth', () => { From 84db0a083bf7cc5ab8e0b56515d9305c4af55d5b Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:59:21 +0000 Subject: [PATCH 090/156] fix: Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) (#10216) --- spec/vulnerabilities.spec.js | 82 +++++++++++++++++++++++++++++++ src/Controllers/UserController.js | 12 ++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 79cfc9b772..f645b95628 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2933,4 +2933,86 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { } }); }); + + describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { + let sendPasswordResetEmail; + + beforeAll(async () => { + sendPasswordResetEmail = jasmine.createSpy('sendPasswordResetEmail'); + await reconfigureServer({ + appName: 'test', + publicServerURL: 'http://localhost:8378/1', + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail, + sendMail: () => {}, + }, + }); + }); + + it('rejects concurrent password resets using the same token', async () => { + const user = new Parse.User(); + user.setUsername('resetuser'); + user.setPassword('originalPass1!'); + user.setEmail('resetuser@example.com'); + await user.signUp(); + + await Parse.User.requestPasswordReset('resetuser@example.com'); + + // Get the perishable token directly from the database + const config = Config.get('test'); + const results = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + const token = results[0]._perishable_token; + expect(token).toBeDefined(); + + // Send two concurrent password reset requests with different passwords + const resetRequest = password => + request({ + method: 'POST', + url: 'http://localhost:8378/1/apps/test/request_password_reset', + body: `new_password=${encodeURIComponent(password)}&token=${encodeURIComponent(token)}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + }, + followRedirects: false, + }); + + const [resultA, resultB] = await Promise.allSettled([ + resetRequest('PasswordA1!'), + resetRequest('PasswordB1!'), + ]); + + // Exactly one request should succeed and one should fail + const succeeded = [resultA, resultB].filter(r => r.status === 'fulfilled'); + const failed = [resultA, resultB].filter(r => r.status === 'rejected'); + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(1); + + // The failed request should indicate invalid token + expect(failed[0].reason.text).toContain( + 'Failed to reset password: username / email / token is invalid' + ); + + // The token should be consumed + const afterResults = await config.database.adapter.find( + '_User', + { fields: {} }, + { username: 'resetuser' }, + { limit: 1 } + ); + expect(afterResults[0]._perishable_token).toBeUndefined(); + + // Verify login works with the winning password + const winningPassword = + succeeded[0] === resultA ? 'PasswordA1!' : 'PasswordB1!'; + const loggedIn = await Parse.User.logIn('resetuser', winningPassword); + expect(loggedIn.getUsername()).toBe('resetuser'); + }); + }); }); diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 296b7f6868..c8b74d2ab4 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -301,7 +301,15 @@ export class UserController extends AdaptableController { async updatePassword(token, password) { try { const rawUser = await this.checkResetTokenValidity(token); - const user = await updateUserPassword(rawUser, password, this.config); + let user; + try { + user = await updateUserPassword(rawUser, password, this.config); + } catch (error) { + if (error && error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw 'Failed to reset password: username / email / token is invalid'; + } + throw error; + } const accountLockoutPolicy = new AccountLockout(user, this.config); return await accountLockoutPolicy.unlockAccount(); @@ -353,7 +361,7 @@ function updateUserPassword(user, password, config) { config, Auth.master(config), '_User', - { objectId: user.objectId }, + { objectId: user.objectId, _perishable_token: user._perishable_token }, { password: password, } From 6476c578fa3eb164c7770854a008c1fb4ae99339 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 16 Mar 2026 03:00:18 +0000 Subject: [PATCH 091/156] chore(release): 9.6.0-alpha.28 [skip ci] # [9.6.0-alpha.28](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.27...9.6.0-alpha.28) (2026-03-16) ### Bug Fixes * Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10216](https://github.com/parse-community/parse-server/issues/10216)) ([84db0a0](https://github.com/parse-community/parse-server/commit/84db0a083bf7cc5ab8e0b56515d9305c4af55d5b)) --- 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 3fbf9ae377..42b49b9c91 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.28](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.27...9.6.0-alpha.28) (2026-03-16) + + +### Bug Fixes + +* Password reset token single-use bypass via concurrent requests ([GHSA-r3xq-68wh-gwvh](https://github.com/parse-community/parse-server/security/advisories/GHSA-r3xq-68wh-gwvh)) ([#10216](https://github.com/parse-community/parse-server/issues/10216)) ([84db0a0](https://github.com/parse-community/parse-server/commit/84db0a083bf7cc5ab8e0b56515d9305c4af55d5b)) + # [9.6.0-alpha.27](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.26...9.6.0-alpha.27) (2026-03-15) diff --git a/package-lock.json b/package-lock.json index 650ec49d42..edc5f9254d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.27", + "version": "9.6.0-alpha.28", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.27", + "version": "9.6.0-alpha.28", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 843575dca7..8e27e8cecf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.27", + "version": "9.6.0-alpha.28", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 5dcbf41249f1b67c72296934bc4f8538f3b1d821 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:31:20 +0000 Subject: [PATCH 092/156] fix: Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) (#10219) --- spec/vulnerabilities.spec.js | 76 ++++++++++++++++++++++++++++++++++++ src/RestWrite.js | 33 +++++++++------- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index f645b95628..a8567a3718 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2934,6 +2934,82 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { }); }); + describe('(GHSA-wjqw-r9x4-j59v) Empty authData session issuance bypass', () => { + const signupHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('rejects signup with empty authData and no credentials', async () => { + await reconfigureServer({ enableAnonymousUsers: false }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with empty authData and no credentials when anonymous users enabled', async () => { + await reconfigureServer({ enableAnonymousUsers: true }); + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: {} }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with authData containing only empty provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: {} } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with authData containing null provider data and no credentials', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ authData: { bogus: null } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.USERNAME_MISSING); + }); + + it('rejects signup with non-object authData provider value even when credentials are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'bogusauth', password: 'pass1234', authData: { bogus: 'x' } }), + }).catch(e => e); + expect(res.status).toBe(400); + expect(res.data.code).toBe(Parse.Error.UNSUPPORTED_SERVICE); + }); + + it('allows signup with empty authData when username and password are provided', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: signupHeaders, + body: JSON.stringify({ username: 'emptyauth', password: 'pass1234', authData: {} }), + }); + expect(res.data.objectId).toBeDefined(); + expect(res.data.sessionToken).toBeDefined(); + }); + }); + describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { let sendPasswordResetEmail; diff --git a/src/RestWrite.js b/src/RestWrite.js index 937f8644e9..e986a35c67 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -452,8 +452,14 @@ RestWrite.prototype.validateAuthData = function () { const authData = this.data.authData; const hasUsernameAndPassword = typeof this.data.username === 'string' && typeof this.data.password === 'string'; + const hasAuthData = + authData && + Object.keys(authData).some(provider => { + const providerData = authData[provider]; + return providerData && typeof providerData === 'object' && Object.keys(providerData).length; + }); - if (!this.query && !authData) { + if (!this.query && !hasAuthData) { if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) { throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); } @@ -462,13 +468,10 @@ RestWrite.prototype.validateAuthData = function () { } } - if ( - (authData && !Object.keys(authData).length) || - !Object.prototype.hasOwnProperty.call(this.data, 'authData') - ) { + if (!Object.prototype.hasOwnProperty.call(this.data, 'authData')) { // Nothing to validate here return; - } else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) { + } else if (!this.data.authData) { // Handle saving authData to null throw new Parse.Error( Parse.Error.UNSUPPORTED_SERVICE, @@ -477,14 +480,16 @@ RestWrite.prototype.validateAuthData = function () { } var providers = Object.keys(authData); - if (providers.length > 0) { - const canHandleAuthData = providers.some(provider => { - const providerAuthData = authData[provider] || {}; - return !!Object.keys(providerAuthData).length; - }); - if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { - return this.handleAuthData(authData); - } + if (!providers.length) { + // Empty authData object, nothing to validate + return; + } + const canHandleAuthData = providers.some(provider => { + const providerAuthData = authData[provider] || {}; + return !!Object.keys(providerAuthData).length; + }); + if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { + return this.handleAuthData(authData); } throw new Parse.Error( Parse.Error.UNSUPPORTED_SERVICE, From 9667fd787f4a97e6a56770e999c355132ae0130a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 16 Mar 2026 15:06:29 +0000 Subject: [PATCH 093/156] chore(release): 9.6.0-alpha.29 [skip ci] # [9.6.0-alpha.29](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.28...9.6.0-alpha.29) (2026-03-16) ### Bug Fixes * Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10219](https://github.com/parse-community/parse-server/issues/10219)) ([5dcbf41](https://github.com/parse-community/parse-server/commit/5dcbf41249f1b67c72296934bc4f8538f3b1d821)) --- 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 42b49b9c91..b8236eb700 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.29](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.28...9.6.0-alpha.29) (2026-03-16) + + +### Bug Fixes + +* Empty authData bypasses credential requirement on signup ([GHSA-wjqw-r9x4-j59v](https://github.com/parse-community/parse-server/security/advisories/GHSA-wjqw-r9x4-j59v)) ([#10219](https://github.com/parse-community/parse-server/issues/10219)) ([5dcbf41](https://github.com/parse-community/parse-server/commit/5dcbf41249f1b67c72296934bc4f8538f3b1d821)) + # [9.6.0-alpha.28](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.27...9.6.0-alpha.28) (2026-03-16) diff --git a/package-lock.json b/package-lock.json index edc5f9254d..c28abd7969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.28", + "version": "9.6.0-alpha.29", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.28", + "version": "9.6.0-alpha.29", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 8e27e8cecf..7da059d5c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.28", + "version": "9.6.0-alpha.29", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 12c24c6c6c578219703aaea186625f8f36c0d020 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:56:16 +0000 Subject: [PATCH 094/156] fix: Block dot-notation updates to authData sub-fields and harden login provider checks (#10223) --- spec/vulnerabilities.spec.js | 50 +++++++++++++++++++++++++++ src/Auth.js | 13 ++++--- src/Controllers/DatabaseController.js | 2 +- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index a8567a3718..3f97d247e3 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3010,6 +3010,56 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { }); }); + describe('authData dot-notation injection and login crash', () => { + it('rejects dotted update key that targets authData sub-field', async () => { + const user = new Parse.User(); + user.setUsername('dotuser'); + user.setPassword('pass1234'); + await user.signUp(); + + const res = await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ 'authData.anonymous".id': 'injected' }), + }).catch(e => e); + expect(res.status).toBe(400); + }); + + it('login does not crash when stored authData has unknown provider', async () => { + const user = new Parse.User(); + user.setUsername('dotuser2'); + user.setPassword('pass1234'); + await user.signUp(); + await Parse.User.logOut(); + + // Inject unknown provider directly in database to simulate corrupted data + const config = Config.get('test'); + await config.database.update( + '_User', + { objectId: user.id }, + { authData: { unknown_provider: { id: 'bad' } } } + ); + + // Login should not crash with 500 + const login = await request({ + method: 'GET', + url: `http://localhost:8378/1/login?username=dotuser2&password=pass1234`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(login.status).toBe(200); + expect(login.data.sessionToken).toBeDefined(); + }); + }); + describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { let sendPasswordResetEmail; diff --git a/src/Auth.js b/src/Auth.js index 7dd8660ad7..9610cbe019 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -523,10 +523,15 @@ const checkIfUserHasProvidedConfiguredProvidersForLogin = ( userAuthData = {}, config ) => { - const savedUserProviders = Object.keys(userAuthData).map(provider => ({ - name: provider, - adapter: config.authDataManager.getValidatorForProvider(provider).adapter, - })); + const savedUserProviders = Object.keys(userAuthData) + .map(provider => { + const validator = config.authDataManager.getValidatorForProvider(provider); + if (!validator || !validator.adapter) { + return null; + } + return { name: provider, adapter: validator.adapter }; + }) + .filter(Boolean); const hasProvidedASoloProvider = savedUserProviders.some( provider => diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 59e3b089be..f8ba7543e7 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -603,7 +603,7 @@ class DatabaseController { }) .then(schema => { Object.keys(update).forEach(fieldName => { - if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + if (fieldName.match(/^authData\./)) { throw new Parse.Error( Parse.Error.INVALID_KEY_NAME, `Invalid field name for update: ${fieldName}` From 5a58583e7d64e87ca023ef85d1a412dda68b1072 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 16 Mar 2026 18:57:21 +0000 Subject: [PATCH 095/156] chore(release): 9.6.0-alpha.30 [skip ci] # [9.6.0-alpha.30](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.29...9.6.0-alpha.30) (2026-03-16) ### Bug Fixes * Block dot-notation updates to authData sub-fields and harden login provider checks ([#10223](https://github.com/parse-community/parse-server/issues/10223)) ([12c24c6](https://github.com/parse-community/parse-server/commit/12c24c6c6c578219703aaea186625f8f36c0d020)) --- 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 b8236eb700..c70362eafe 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.30](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.29...9.6.0-alpha.30) (2026-03-16) + + +### Bug Fixes + +* Block dot-notation updates to authData sub-fields and harden login provider checks ([#10223](https://github.com/parse-community/parse-server/issues/10223)) ([12c24c6](https://github.com/parse-community/parse-server/commit/12c24c6c6c578219703aaea186625f8f36c0d020)) + # [9.6.0-alpha.29](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.28...9.6.0-alpha.29) (2026-03-16) diff --git a/package-lock.json b/package-lock.json index c28abd7969..86d30bd52c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.29", + "version": "9.6.0-alpha.30", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.29", + "version": "9.6.0-alpha.30", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 7da059d5c0..c27e0b7ba3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.29", + "version": "9.6.0-alpha.30", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From e5e1f5bbc008c869614a13ab540f72af57adda8f Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:22:45 +0000 Subject: [PATCH 096/156] fix: Validate authData provider values in challenge endpoint (#10224) --- spec/AuthenticationAdaptersV2.spec.js | 89 +++++++++++++++++++++++++++ spec/vulnerabilities.spec.js | 50 --------------- src/Routers/UsersRouter.js | 13 +++- 3 files changed, 100 insertions(+), 52 deletions(-) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index d8c646382c..cafb309f0b 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1,5 +1,6 @@ const request = require('../lib/request'); const Auth = require('../lib/Auth'); +const Config = require('../lib/Config'); const requestWithExpectedError = async params => { try { return await request(params); @@ -1613,4 +1614,92 @@ describe('Auth Adapter features', () => { expect(authData.simpleAdapter && authData.simpleAdapter.id).toBe('simple1'); expect(authData.codeBasedAdapter && authData.codeBasedAdapter.id).toBe('user1'); }); + + describe('authData dot-notation injection and login crash', () => { + it('rejects dotted update key that targets authData sub-field', async () => { + const user = new Parse.User(); + user.setUsername('dotuser'); + user.setPassword('pass1234'); + await user.signUp(); + + const res = await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ 'authData.anonymous".id': 'injected' }), + }).catch(e => e); + expect(res.status).toBe(400); + }); + + it('login does not crash when stored authData has unknown provider', async () => { + const user = new Parse.User(); + user.setUsername('dotuser2'); + user.setPassword('pass1234'); + await user.signUp(); + await Parse.User.logOut(); + + // Inject unknown provider directly in database to simulate corrupted data + const config = Config.get('test'); + await config.database.update( + '_User', + { objectId: user.id }, + { authData: { unknown_provider: { id: 'bad' } } } + ); + + // Login should not crash with 500 + const login = await request({ + method: 'GET', + url: `http://localhost:8378/1/login?username=dotuser2&password=pass1234`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }).catch(e => e); + expect(login.status).toBe(200); + expect(login.data.sessionToken).toBeDefined(); + }); + }); + + describe('challenge endpoint authData provider value validation', () => { + it('rejects challenge request with null provider value without 500', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/challenge', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { anonymous: null }, + challengeData: { anonymous: { token: '123456' } }, + }), + }).catch(e => e); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('rejects challenge request with non-object provider value without 500', async () => { + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/challenge', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { anonymous: 'string_value' }, + challengeData: { anonymous: { token: '123456' } }, + }), + }).catch(e => e); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + }); }); diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 3f97d247e3..a8567a3718 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3010,56 +3010,6 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { }); }); - describe('authData dot-notation injection and login crash', () => { - it('rejects dotted update key that targets authData sub-field', async () => { - const user = new Parse.User(); - user.setUsername('dotuser'); - user.setPassword('pass1234'); - await user.signUp(); - - const res = await request({ - method: 'PUT', - url: `http://localhost:8378/1/users/${user.id}`, - headers: { - 'Content-Type': 'application/json', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Session-Token': user.getSessionToken(), - }, - body: JSON.stringify({ 'authData.anonymous".id': 'injected' }), - }).catch(e => e); - expect(res.status).toBe(400); - }); - - it('login does not crash when stored authData has unknown provider', async () => { - const user = new Parse.User(); - user.setUsername('dotuser2'); - user.setPassword('pass1234'); - await user.signUp(); - await Parse.User.logOut(); - - // Inject unknown provider directly in database to simulate corrupted data - const config = Config.get('test'); - await config.database.update( - '_User', - { objectId: user.id }, - { authData: { unknown_provider: { id: 'bad' } } } - ); - - // Login should not crash with 500 - const login = await request({ - method: 'GET', - url: `http://localhost:8378/1/login?username=dotuser2&password=pass1234`, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, - }).catch(e => e); - expect(login.status).toBe(200); - expect(login.data.sessionToken).toBeDefined(); - }); - }); - describe('(GHSA-r3xq-68wh-gwvh) Password reset single-use token bypass via concurrent requests', () => { let sendPasswordResetEmail; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 7d8f98a538..beaa08f6f2 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -614,7 +614,16 @@ export class UsersRouter extends ClassesRouter { ); } - if (Object.keys(authData).filter(key => authData[key].id).length > 1) { + for (const key of Object.keys(authData)) { + if (authData[key] !== null && (typeof authData[key] !== 'object' || Array.isArray(authData[key]))) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `authData.${key} should be an object.` + ); + } + } + + if (Object.keys(authData).filter(key => authData[key] && authData[key].id).length > 1) { throw new Parse.Error( Parse.Error.OTHER_CAUSE, 'You cannot provide more than one authData provider with an id.' @@ -628,7 +637,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'User not found.'); } // Find the provider used to find the user - const provider = Object.keys(authData).find(key => authData[key].id); + const provider = Object.keys(authData).find(key => authData[key] && authData[key].id); parseUser = Parse.User.fromJSON({ className: '_User', ...results[0] }); request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); From cdc255156a83f3bfb90f42602af09e0e738b46ec Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 16 Mar 2026 19:24:18 +0000 Subject: [PATCH 097/156] chore(release): 9.6.0-alpha.31 [skip ci] # [9.6.0-alpha.31](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.30...9.6.0-alpha.31) (2026-03-16) ### Bug Fixes * Validate authData provider values in challenge endpoint ([#10224](https://github.com/parse-community/parse-server/issues/10224)) ([e5e1f5b](https://github.com/parse-community/parse-server/commit/e5e1f5bbc008c869614a13ab540f72af57adda8f)) --- 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 c70362eafe..4d94fb0150 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.31](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.30...9.6.0-alpha.31) (2026-03-16) + + +### Bug Fixes + +* Validate authData provider values in challenge endpoint ([#10224](https://github.com/parse-community/parse-server/issues/10224)) ([e5e1f5b](https://github.com/parse-community/parse-server/commit/e5e1f5bbc008c869614a13ab540f72af57adda8f)) + # [9.6.0-alpha.30](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.29...9.6.0-alpha.30) (2026-03-16) diff --git a/package-lock.json b/package-lock.json index 86d30bd52c..802dc29804 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.30", + "version": "9.6.0-alpha.31", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.30", + "version": "9.6.0-alpha.31", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index c27e0b7ba3..70e00d6c8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.30", + "version": "9.6.0-alpha.31", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 51efb1efb9fa3f2d578de63f61b20c6a4fbcbd9a Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 21:30:09 +0000 Subject: [PATCH 098/156] fix: Instance comparison with `instanceof` is not realm-safe (#10225) --- eslint.config.js | 41 ++++- spec/AdapterLoader.spec.js | 5 +- spec/CloudCode.spec.js | 11 +- spec/GridFSBucketStorageAdapter.spec.js | 20 +-- spec/MongoStorageAdapter.spec.js | 31 ++-- spec/MongoTransform.spec.js | 4 +- spec/ParseAPI.spec.js | 9 +- spec/ParseGraphQLServer.spec.js | 11 +- spec/ParseQuery.spec.js | 7 +- spec/ParseUser.spec.js | 9 +- spec/PushController.spec.js | 5 +- spec/Utils.spec.js | 160 ++++++++++++++++++ spec/eslint.config.js | 39 +++++ spec/helper.js | 2 +- spec/rest.spec.js | 2 +- .../Storage/Mongo/MongoStorageAdapter.js | 4 +- src/Adapters/Storage/Mongo/MongoTransform.js | 52 +++--- .../Postgres/PostgresStorageAdapter.js | 14 +- src/Config.js | 9 +- src/Controllers/DatabaseController.js | 12 +- src/Controllers/FilesController.js | 2 +- src/Controllers/LiveQueryController.js | 2 +- src/Controllers/ParseGraphQLController.js | 5 +- src/Controllers/SchemaController.js | 2 +- src/GraphQL/loaders/defaultGraphQLTypes.js | 9 +- src/GraphQL/transformers/query.js | 2 +- src/RestQuery.js | 6 +- src/Routers/FilesRouter.js | 2 +- src/Routers/PagesRouter.js | 2 +- src/Security/Check.js | 2 +- src/StatusHandler.js | 3 +- src/Utils.js | 78 ++++++++- src/middlewares.js | 5 +- src/triggers.js | 3 +- 34 files changed, 443 insertions(+), 127 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 3c5bd6806e..f9e0977938 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,46 @@ module.exports = [ curly: ["error", "all"], "block-spacing": ["error", "always"], "no-unused-vars": "off", - "no-console": "warn" + "no-console": "warn", + "no-restricted-syntax": [ + "error", + { + selector: "BinaryExpression[operator='instanceof'][right.name='Date']", + message: "Use Utils.isDate() instead of instanceof Date (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='RegExp']", + message: "Use Utils.isRegExp() instead of instanceof RegExp (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Error']", + message: "Use Utils.isNativeError() instead of instanceof Error (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Promise']", + message: "Use Utils.isPromise() instead of instanceof Promise (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Map']", + message: "Use Utils.isMap() instead of instanceof Map (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Object']", + message: "Use Utils.isObject() instead of instanceof Object (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Set']", + message: "Use Utils.isSet() instead of instanceof Set (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Buffer']", + message: "Use Buffer.isBuffer() instead of instanceof Buffer (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Array']", + message: "Use Array.isArray() instead of instanceof Array (cross-realm safe).", + }, + ] }, }, ]; diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index a71e373f5b..8d33bf2094 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -2,6 +2,7 @@ const { loadAdapter, loadModule } = require('../lib/Adapters/AdapterLoader'); const FilesAdapter = require('@parse/fs-files-adapter').default; const MockFilesAdapter = require('mock-files-adapter'); const Config = require('../lib/Config'); +const Utils = require('../lib/Utils'); describe('AdapterLoader', () => { it('should instantiate an adapter from string in object', done => { @@ -15,7 +16,7 @@ describe('AdapterLoader', () => { }, }); - expect(adapter instanceof Object).toBe(true); + expect(Utils.isObject(adapter)).toBe(true); expect(adapter.options.key).toBe('value'); expect(adapter.options.foo).toBe('bar'); done(); @@ -25,7 +26,7 @@ describe('AdapterLoader', () => { const adapterPath = require('path').resolve('./spec/support/MockAdapter'); const adapter = loadAdapter(adapterPath); - expect(adapter instanceof Object).toBe(true); + expect(Utils.isObject(adapter)).toBe(true); done(); }); diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 86085a3faf..45f3461bc4 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -5,6 +5,7 @@ const ParseServer = require('../lib/index').ParseServer; const request = require('../lib/request'); const InMemoryCacheAdapter = require('../lib/Adapters/Cache/InMemoryCacheAdapter') .InMemoryCacheAdapter; +const Utils = require('../lib/Utils'); const mockAdapter = { createFile: async filename => ({ @@ -1272,15 +1273,15 @@ describe('Cloud Code', () => { it('test cloud function request params types', function (done) { Parse.Cloud.define('params', function (req) { - expect(req.params.date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.date)).toBe(true); expect(req.params.date.getTime()).toBe(1463907600000); - expect(req.params.dateList[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.dateList[0])).toBe(true); expect(req.params.dateList[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.date[0])).toBe(true); expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate.date[0])).toBe(true); expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate2[0].date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate2[0].date)).toBe(true); expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); // Regression for #2294 expect(req.params.file instanceof Parse.File).toBe(true); diff --git a/spec/GridFSBucketStorageAdapter.spec.js b/spec/GridFSBucketStorageAdapter.spec.js index 033292063c..48fbd09115 100644 --- a/spec/GridFSBucketStorageAdapter.spec.js +++ b/spec/GridFSBucketStorageAdapter.spec.js @@ -103,12 +103,12 @@ describe_only_db('mongo')('GridFSBucket', () => { ).toEqual(1); expect(notRotated.length).toEqual(0); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); const encryptedData1 = await unencryptedAdapter.getFileData(fileName1); expect(encryptedData1.toString('utf-8')).not.toEqual(unencryptedResult1); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); const encryptedData2 = await unencryptedAdapter.getFileData(fileName2); expect(encryptedData2.toString('utf-8')).not.toEqual(unencryptedResult2); @@ -146,7 +146,7 @@ describe_only_db('mongo')('GridFSBucket', () => { ).toEqual(1); expect(notRotated.length).toEqual(0); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -158,7 +158,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; @@ -203,7 +203,7 @@ describe_only_db('mongo')('GridFSBucket', () => { ).toEqual(1); expect(notRotated.length).toEqual(0); let result = await unEncryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -215,7 +215,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await unEncryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; @@ -271,7 +271,7 @@ describe_only_db('mongo')('GridFSBucket', () => { }).length ).toEqual(0); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -283,7 +283,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; @@ -338,7 +338,7 @@ describe_only_db('mongo')('GridFSBucket', () => { }).length ).toEqual(1); let result = await encryptedAdapter.getFileData(fileName1); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data1); let decryptionError1; let encryptedData1; @@ -350,7 +350,7 @@ describe_only_db('mongo')('GridFSBucket', () => { expect(decryptionError1).toMatch('Error'); expect(encryptedData1).toBeUndefined(); result = await encryptedAdapter.getFileData(fileName2); - expect(result instanceof Buffer).toBe(true); + expect(Buffer.isBuffer(result)).toBe(true); expect(result.toString('utf-8')).toEqual(data2); let decryptionError2; let encryptedData2; diff --git a/spec/MongoStorageAdapter.spec.js b/spec/MongoStorageAdapter.spec.js index ffaaf94c98..a607fbc4ea 100644 --- a/spec/MongoStorageAdapter.spec.js +++ b/spec/MongoStorageAdapter.spec.js @@ -6,6 +6,7 @@ const databaseURI = 'mongodb://localhost:27017/parseServerMongoAdapterTestDataba const request = require('../lib/request'); const Config = require('../lib/Config'); const TestUtils = require('../lib/TestUtils'); +const Utils = require('../lib/Utils'); const fakeClient = { s: { options: { dbName: null } }, @@ -243,15 +244,15 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); - expect(mob.date instanceof Date).toBe(true); + expect(Utils.isDate(mob.date)).toBe(true); return adapter.find('MyClass', schema, {}, {}); }) .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date.__type).toBe('Date'); expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z'); @@ -278,9 +279,9 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }).save(); const adapter = Config.get(Parse.applicationId).database.adapter; const [object] = await adapter._rawFind('MyClass', {}); - expect(object.date instanceof Date).toBeTrue(); - expect(object.bar.date instanceof Date).toBeTrue(); - expect(object.foo.test.date instanceof Date).toBeTrue(); + expect(Utils.isDate(object.date)).toBeTrue(); + expect(Utils.isDate(object.bar.date)).toBeTrue(); + expect(Utils.isDate(object.foo.test.date)).toBeTrue(); }); it('handles nested dates in array ', async () => { @@ -297,13 +298,13 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }).save(); const adapter = Config.get(Parse.applicationId).database.adapter; const [object] = await adapter._rawFind('MyClass', {}); - expect(object.date[0] instanceof Date).toBeTrue(); - expect(object.bar.date[0] instanceof Date).toBeTrue(); - expect(object.foo.test.date[0] instanceof Date).toBeTrue(); + expect(Utils.isDate(object.date[0])).toBeTrue(); + expect(Utils.isDate(object.bar.date[0])).toBeTrue(); + expect(Utils.isDate(object.foo.test.date[0])).toBeTrue(); const obj = await new Parse.Query('MyClass').first({ useMasterKey: true }); - expect(obj.get('date')[0] instanceof Date).toBeTrue(); - expect(obj.get('bar').date[0] instanceof Date).toBeTrue(); - expect(obj.get('foo').test.date[0] instanceof Date).toBeTrue(); + expect(Utils.isDate(obj.get('date')[0])).toBeTrue(); + expect(Utils.isDate(obj.get('bar').date[0])).toBeTrue(); + expect(Utils.isDate(obj.get('foo').test.date[0])).toBeTrue(); }); it('upserts with $setOnInsert', async () => { @@ -376,7 +377,7 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { }) .then(results => { const mob = results; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date.__type).toBe('Date'); expect(mob.date.iso).toBe('2016-05-26T20:55:01.154Z'); @@ -385,9 +386,9 @@ describe_only_db('mongo')('MongoStorageAdapter', () => { .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); - expect(mob.date instanceof Date).toBe(true); + expect(Utils.isDate(mob.date)).toBe(true); done(); }) .catch(error => { diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 5e27b1757e..a3c61214a8 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -33,8 +33,8 @@ describe('parseObjectToMongoObjectForCreate', () => { const output = transform.parseObjectToMongoObjectForCreate(null, input, { fields: {}, }); - expect(output._created_at instanceof Date).toBe(true); - expect(output._updated_at instanceof Date).toBe(true); + expect(Utils.isDate(output._created_at)).toBe(true); + expect(Utils.isDate(output._updated_at)).toBe(true); done(); }); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 8cfe9ef03f..cd0221e3ad 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -7,6 +7,7 @@ const Parse = require('parse/node'); const Config = require('../lib/Config'); const SchemaController = require('../lib/Controllers/SchemaController'); const { destroyAllDataPermanently } = require('../lib/TestUtils'); +const Utils = require('../lib/Utils'); const userSchema = SchemaController.convertSchemaToAdapterSchema({ className: '_User', @@ -327,10 +328,10 @@ describe('miscellaneous', () => { return obj2.fetch(); }) .then(obj2 => { - expect(obj2.get('date') instanceof Date).toBe(true); - expect(obj2.get('array') instanceof Array).toBe(true); - expect(obj2.get('object') instanceof Array).toBe(false); - expect(obj2.get('object') instanceof Object).toBe(true); + expect(Utils.isDate(obj2.get('date'))).toBe(true); + expect(Array.isArray(obj2.get('array'))).toBe(true); + expect(Array.isArray(obj2.get('object'))).toBe(false); + expect(Utils.isObject(obj2.get('object'))).toBe(true); done(); }); }); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 1d99b93183..4ed104a013 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -5,6 +5,7 @@ const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fet const FormData = require('form-data'); require('./helper'); const { updateCLP } = require('./support/dev'); +const Utils = require('../lib/Utils'); const pluralize = require('pluralize'); const createUploadLink = (...args) => import('apollo-upload-client/createUploadLink.mjs').then(({ default: fn }) => fn(...args)); @@ -8476,15 +8477,15 @@ describe('ParseGraphQLServer', () => { it('should accept different params', done => { Parse.Cloud.define('hello', async req => { - expect(req.params.date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.date)).toBe(true); expect(req.params.date.getTime()).toBe(1463907600000); - expect(req.params.dateList[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.dateList[0])).toBe(true); expect(req.params.dateList[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.date[0])).toBe(true); expect(req.params.complexStructure.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate.date[0] instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate.date[0])).toBe(true); expect(req.params.complexStructure.deepDate.date[0].getTime()).toBe(1463907600000); - expect(req.params.complexStructure.deepDate2[0].date instanceof Date).toBe(true); + expect(Utils.isDate(req.params.complexStructure.deepDate2[0].date)).toBe(true); expect(req.params.complexStructure.deepDate2[0].date.getTime()).toBe(1463907600000); // Regression for #2294 expect(req.params.file instanceof Parse.File).toBe(true); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 4dd7eaecea..35b34c5a26 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -9,6 +9,7 @@ const request = require('../lib/request'); const ParseServerRESTController = require('../lib/ParseServerRESTController').ParseServerRESTController; const ParseServer = require('../lib/ParseServer').default; const Deprecator = require('../lib/Deprecator/Deprecator').default; +const Utils = require('../lib/Utils'); const masterKeyHeaders = { 'X-Parse-Application-Id': 'test', @@ -1452,8 +1453,8 @@ describe('Parse.Query testing', () => { ok(result); equal(result.id, objectId); equal(result.get('foo'), 'bar'); - ok(result.createdAt instanceof Date); - ok(result.updatedAt instanceof Date); + ok(Utils.isDate(result.createdAt)); + ok(Utils.isDate(result.updatedAt)); done(); }); }); @@ -3902,7 +3903,7 @@ describe('Parse.Query testing', () => { objs => { expect(objs.length).toBe(1); expect(objs[0].get('child').get('hello')).toEqual('world'); - expect(objs[0].createdAt instanceof Date).toBe(true); + expect(Utils.isDate(objs[0].createdAt)).toBe(true); done(); }, () => { diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index cc102ec294..e83ef720ec 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -12,6 +12,7 @@ const request = require('../lib/request'); const passwordCrypto = require('../lib/password'); const Config = require('../lib/Config'); const cryptoUtils = require('../lib/cryptoUtils'); +const Utils = require('../lib/Utils'); describe('allowExpiredAuthDataToken option', () => { @@ -1128,9 +1129,9 @@ describe('Parse.User testing', () => { equal(userInMemory.id, id, 'id should be set'); - expect(userInMemory.updatedAt instanceof Date).toBe(true); + expect(Utils.isDate(userInMemory.updatedAt)).toBe(true); - ok(userInMemory.createdAt instanceof Date); + ok(Utils.isDate(userInMemory.createdAt)); ok(userInMemory.getSessionToken(), 'user should have a sessionToken after saving'); @@ -1167,9 +1168,9 @@ describe('Parse.User testing', () => { equal(userFromDisk.id, id, 'id should be set on userFromDisk'); - ok(userFromDisk.updatedAt instanceof Date); + ok(Utils.isDate(userFromDisk.updatedAt)); - ok(userFromDisk.createdAt instanceof Date); + ok(Utils.isDate(userFromDisk.createdAt)); ok(userFromDisk.getSessionToken(), 'userFromDisk should have a sessionToken'); diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index aed0e0ab89..fe04b334b4 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -3,6 +3,7 @@ const PushController = require('../lib/Controllers/PushController').PushControll const StatusHandler = require('../lib/StatusHandler'); const Config = require('../lib/Config'); const validatePushType = require('../lib/Push/utils').validatePushType; +const Utils = require('../lib/Utils'); const successfulTransmissions = function (body, installations) { const promises = installations.map(device => { @@ -454,8 +455,8 @@ describe('PushController', () => { const pushStatusId = await sendPush(payload, {}, config, auth); await pushCompleted(pushStatusId); const result = await Parse.Push.getPushStatus(pushStatusId); - expect(result.createdAt instanceof Date).toBe(true); - expect(result.updatedAt instanceof Date).toBe(true); + expect(Utils.isDate(result.createdAt)).toBe(true); + expect(Utils.isDate(result.updatedAt)).toBe(true); expect(result.id.length).toBe(10); expect(result.get('source')).toEqual('rest'); expect(result.get('query')).toEqual(JSON.stringify({})); diff --git a/spec/Utils.spec.js b/spec/Utils.spec.js index 853ddc9b3c..f6d24b55f9 100644 --- a/spec/Utils.spec.js +++ b/spec/Utils.spec.js @@ -1,5 +1,6 @@ const Utils = require('../lib/Utils'); const { createSanitizedError, createSanitizedHttpError } = require("../lib/Error") +const vm = require('vm'); describe('Utils', () => { describe('encodeForUrl', () => { @@ -287,4 +288,163 @@ describe('Utils', () => { expect(error.message).toBe('Detailed error message'); }); }); + + describe('isDate', () => { + it('should return true for a Date', () => { + expect(Utils.isDate(new Date())).toBe(true); + }); + it('should return true for a cross-realm Date', () => { + const crossRealmDate = vm.runInNewContext('new Date()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmDate instanceof Date).toBe(false); + expect(Utils.isDate(crossRealmDate)).toBe(true); + }); + it('should return false for non-Date values', () => { + expect(Utils.isDate(null)).toBe(false); + expect(Utils.isDate(undefined)).toBe(false); + expect(Utils.isDate('2021-01-01')).toBe(false); + expect(Utils.isDate(123)).toBe(false); + expect(Utils.isDate({})).toBe(false); + }); + }); + + describe('isRegExp', () => { + it('should return true for a RegExp', () => { + expect(Utils.isRegExp(/test/)).toBe(true); + expect(Utils.isRegExp(new RegExp('test'))).toBe(true); + }); + it('should return true for a cross-realm RegExp', () => { + const crossRealmRegExp = vm.runInNewContext('/test/'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmRegExp instanceof RegExp).toBe(false); + expect(Utils.isRegExp(crossRealmRegExp)).toBe(true); + }); + it('should return false for non-RegExp values', () => { + expect(Utils.isRegExp(null)).toBe(false); + expect(Utils.isRegExp(undefined)).toBe(false); + expect(Utils.isRegExp('/test/')).toBe(false); + expect(Utils.isRegExp({})).toBe(false); + }); + }); + + describe('isMap', () => { + it('should return true for a Map', () => { + expect(Utils.isMap(new Map())).toBe(true); + }); + it('should return true for a cross-realm Map', () => { + const crossRealmMap = vm.runInNewContext('new Map()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmMap instanceof Map).toBe(false); + expect(Utils.isMap(crossRealmMap)).toBe(true); + }); + it('should return false for non-Map values', () => { + expect(Utils.isMap(null)).toBe(false); + expect(Utils.isMap(undefined)).toBe(false); + expect(Utils.isMap({})).toBe(false); + expect(Utils.isMap(new Set())).toBe(false); + }); + }); + + describe('isSet', () => { + it('should return true for a Set', () => { + expect(Utils.isSet(new Set())).toBe(true); + }); + it('should return true for a cross-realm Set', () => { + const crossRealmSet = vm.runInNewContext('new Set()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmSet instanceof Set).toBe(false); + expect(Utils.isSet(crossRealmSet)).toBe(true); + }); + it('should return false for non-Set values', () => { + expect(Utils.isSet(null)).toBe(false); + expect(Utils.isSet(undefined)).toBe(false); + expect(Utils.isSet({})).toBe(false); + expect(Utils.isSet(new Map())).toBe(false); + }); + }); + + describe('isNativeError', () => { + it('should return true for an Error', () => { + expect(Utils.isNativeError(new Error('test'))).toBe(true); + }); + it('should return true for Error subclasses', () => { + expect(Utils.isNativeError(new TypeError('test'))).toBe(true); + expect(Utils.isNativeError(new RangeError('test'))).toBe(true); + }); + it('should return true for a cross-realm Error', () => { + const crossRealmError = vm.runInNewContext('new Error("test")'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmError instanceof Error).toBe(false); + expect(Utils.isNativeError(crossRealmError)).toBe(true); + }); + it('should return false for non-Error values', () => { + expect(Utils.isNativeError(null)).toBe(false); + expect(Utils.isNativeError(undefined)).toBe(false); + expect(Utils.isNativeError({ message: 'fake' })).toBe(false); + expect(Utils.isNativeError('error')).toBe(false); + }); + }); + + describe('isPromise', () => { + it('should return true for a Promise', () => { + expect(Utils.isPromise(Promise.resolve())).toBe(true); + }); + it('should return true for a cross-realm Promise', () => { + const crossRealmPromise = vm.runInNewContext('Promise.resolve()'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmPromise instanceof Promise).toBe(false); + expect(Utils.isPromise(crossRealmPromise)).toBe(true); + }); + it('should return true for a thenable', () => { + expect(Utils.isPromise({ then: () => {} })).toBe(true); + }); + it('should return false for non-Promise values', () => { + expect(Utils.isPromise(null)).toBe(false); + expect(Utils.isPromise(undefined)).toBe(false); + expect(Utils.isPromise({})).toBe(false); + expect(Utils.isPromise(42)).toBe(false); + }); + it('should return false for plain objects when Object.prototype.then is polluted', () => { + Object.prototype.then = () => {}; + try { + expect(Utils.isPromise({})).toBe(false); + expect(Utils.isPromise({ a: 1 })).toBe(false); + } finally { + delete Object.prototype.then; + } + }); + it('should return true for real thenables even when Object.prototype.then is polluted', () => { + Object.prototype.then = () => {}; + try { + expect(Utils.isPromise({ then: () => {} })).toBe(true); + expect(Utils.isPromise(Promise.resolve())).toBe(true); + } finally { + delete Object.prototype.then; + } + }); + }); + + describe('isObject', () => { + it('should return true for plain objects', () => { + expect(Utils.isObject({})).toBe(true); + expect(Utils.isObject({ a: 1 })).toBe(true); + }); + it('should return true for a cross-realm object', () => { + const crossRealmObj = vm.runInNewContext('({ a: 1 })'); + // eslint-disable-next-line no-restricted-syntax -- intentional: proving instanceof fails cross-realm + expect(crossRealmObj instanceof Object).toBe(false); + expect(Utils.isObject(crossRealmObj)).toBe(true); + }); + it('should return true for arrays and other objects', () => { + expect(Utils.isObject([])).toBe(true); + expect(Utils.isObject(new Date())).toBe(true); + }); + it('should return false for non-object values', () => { + expect(Utils.isObject(null)).toBe(false); + expect(Utils.isObject(undefined)).toBe(false); + expect(Utils.isObject(42)).toBe(false); + expect(Utils.isObject('string')).toBe(false); + expect(Utils.isObject(true)).toBe(false); + }); + }); }); diff --git a/spec/eslint.config.js b/spec/eslint.config.js index 4d23d3b649..211996ca21 100644 --- a/spec/eslint.config.js +++ b/spec/eslint.config.js @@ -63,6 +63,45 @@ module.exports = [ curly: ["error", "all"], "block-spacing": ["error", "always"], "no-unused-vars": "off", + "no-restricted-syntax": [ + "error", + { + selector: "BinaryExpression[operator='instanceof'][right.name='Date']", + message: "Use Utils.isDate() instead of instanceof Date (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='RegExp']", + message: "Use Utils.isRegExp() instead of instanceof RegExp (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Error']", + message: "Use Utils.isNativeError() instead of instanceof Error (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Promise']", + message: "Use Utils.isPromise() instead of instanceof Promise (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Map']", + message: "Use Utils.isMap() instead of instanceof Map (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Object']", + message: "Use Utils.isObject() instead of instanceof Object (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Set']", + message: "Use Utils.isSet() instead of instanceof Set (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Buffer']", + message: "Use Buffer.isBuffer() instead of instanceof Buffer (cross-realm safe).", + }, + { + selector: "BinaryExpression[operator='instanceof'][right.name='Array']", + message: "Use Array.isArray() instead of instanceof Array (cross-realm safe).", + }, + ], }, }, ]; diff --git a/spec/helper.js b/spec/helper.js index d6df39e851..032bdd1dcb 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -337,7 +337,7 @@ function normalize(obj) { if (obj === null || typeof obj !== 'object') { return JSON.stringify(obj); } - if (obj instanceof Array) { + if (Array.isArray(obj)) { return '[' + obj.map(normalize).join(', ') + ']'; } let answer = '{'; diff --git a/spec/rest.spec.js b/spec/rest.spec.js index a2afa5387c..063693b2bc 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -278,7 +278,7 @@ describe('rest create', () => { .then(results => { expect(results.length).toEqual(1); const mob = results[0]; - expect(mob.array instanceof Array).toBe(true); + expect(Array.isArray(mob.array)).toBe(true); expect(typeof mob.object).toBe('object'); expect(mob.date.__type).toBe('Date'); expect(new Date(mob.date.iso).getTime()).toBe(now.getTime()); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 054c714278..3b4cd14058 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -2,6 +2,7 @@ import { format as formatUrl, parse as parseUrl } from '../../../vendor/mongodbUrl'; import type { QueryOptions, QueryType, SchemaType, StorageClass } from '../StorageAdapter'; import { StorageAdapter } from '../StorageAdapter'; +import Utils from '../../../Utils'; import MongoCollection from './MongoCollection'; import MongoSchemaCollection from './MongoSchemaCollection'; import { @@ -18,7 +19,6 @@ import Parse from 'parse/node'; import _ from 'lodash'; import defaults, { ParseServerDatabaseOptions } from '../../../defaults'; import logger from '../../../logger'; -import Utils from '../../../Utils'; // @flow-disable-next const mongodb = require('mongodb'); @@ -1110,7 +1110,7 @@ export class MongoStorageAdapter implements StorageAdapter { * @returns {any} The original value if not convertible to Date, or a Date object if it is. */ _convertToDate(value: any): any { - if (value instanceof Date) { + if (Utils.isDate(value)) { return value; } if (typeof value === 'string') { diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 34481a090b..bbaefdd55a 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -121,7 +121,7 @@ const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSc } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { value = restValue.map(transformInteriorValue); return { key, value }; } @@ -137,7 +137,7 @@ const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSc }; const isRegex = value => { - return value && value instanceof RegExp; + return value && Utils.isRegExp(value); }; const isStartsWithRegex = value => { @@ -189,10 +189,10 @@ const transformInteriorValue = restValue => { var value = transformInteriorAtom(restValue); if (value !== CannotTransform) { if (value && typeof value === 'object') { - if (value instanceof Date) { + if (Utils.isDate(value)) { return value; } - if (value instanceof Array) { + if (Array.isArray(value)) { value = value.map(transformInteriorValue); } else { value = mapValues(value, transformInteriorValue); @@ -202,7 +202,7 @@ const transformInteriorValue = restValue => { } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { return restValue.map(transformInteriorValue); } @@ -218,7 +218,7 @@ const transformInteriorValue = restValue => { const valueAsDate = value => { if (typeof value === 'string') { return new Date(value); - } else if (value instanceof Date) { + } else if (Utils.isDate(value)) { return value; } return false; @@ -338,7 +338,7 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { return { key, value: transformedConstraint }; } - if (expectedTypeIsArray && !(value instanceof Array)) { + if (expectedTypeIsArray && !Array.isArray(value)) { return { key, value: { $all: [transformInteriorAtom(value)] } }; } @@ -444,7 +444,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) => } // Handle arrays - if (restValue instanceof Array) { + if (Array.isArray(restValue)) { value = restValue.map(transformInteriorValue); return { key: restKey, value: value }; } @@ -565,7 +565,7 @@ function CannotTransform() {} const transformInteriorAtom = atom => { // TODO: check validity harder for the __type-defined types - if (typeof atom === 'object' && atom && !(atom instanceof Date) && atom.__type === 'Pointer') { + if (typeof atom === 'object' && atom && !Utils.isDate(atom) && atom.__type === 'Pointer') { return { __type: 'Pointer', className: atom.className, @@ -606,7 +606,7 @@ function transformTopLevelAtom(atom, field) { case 'function': throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); case 'object': - if (atom instanceof Date) { + if (Utils.isDate(atom)) { // Technically dates are not rest format, but, it seems pretty // clear what they should be transformed to, so let's just do it. return atom; @@ -721,7 +721,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { case '$in': case '$nin': { const arr = constraint[key]; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } answer[key] = _.flatMap(arr, value => { @@ -737,7 +737,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { } case '$all': { const arr = constraint[key]; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } answer[key] = arr.map(transformInteriorAtom); @@ -762,7 +762,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { case '$containedBy': { const arr = constraint[key]; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); } answer.$elemMatch = { @@ -872,7 +872,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { ); } points = polygon.coordinates; - } else if (polygon instanceof Array) { + } else if (Array.isArray(polygon)) { if (polygon.length < 3) { throw new Parse.Error( Parse.Error.INVALID_JSON, @@ -887,7 +887,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { ); } points = points.map(point => { - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { Parse.GeoPoint._validate(point[1], point[0]); return point; } @@ -902,7 +902,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { $polygon: points, }; } else if (centerSphere !== undefined) { - if (!(centerSphere instanceof Array) || centerSphere.length < 2) { + if (!Array.isArray(centerSphere) || centerSphere.length < 2) { throw new Parse.Error( Parse.Error.INVALID_JSON, 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' @@ -910,7 +910,7 @@ function transformConstraint(constraint, field, queryKey, count = false) { } // Get point, convert to geo point if necessary and validate let point = centerSphere[0]; - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { point = new Parse.GeoPoint(point[1], point[0]); } else if (!GeoPointCoder.isValidJSON(point)) { throw new Parse.Error( @@ -999,7 +999,7 @@ function transformUpdateOperator({ __op, amount, objects }, flatten) { case 'Add': case 'AddUnique': - if (!(objects instanceof Array)) { + if (!Array.isArray(objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } var toAdd = objects.map(transformInteriorAtom); @@ -1014,7 +1014,7 @@ function transformUpdateOperator({ __op, amount, objects }, flatten) { } case 'Remove': - if (!(objects instanceof Array)) { + if (!Array.isArray(objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to remove must be an array'); } var toRemove = objects.map(transformInteriorAtom); @@ -1053,11 +1053,11 @@ const nestedMongoObjectToNestedParseObject = mongoObject => { if (mongoObject === null) { return null; } - if (mongoObject instanceof Array) { + if (Array.isArray(mongoObject)) { return mongoObject.map(nestedMongoObjectToNestedParseObject); } - if (mongoObject instanceof Date) { + if (Utils.isDate(mongoObject)) { return Parse._encode(mongoObject); } @@ -1076,7 +1076,7 @@ const nestedMongoObjectToNestedParseObject = mongoObject => { if ( Object.prototype.hasOwnProperty.call(mongoObject, '__type') && mongoObject.__type == 'Date' && - mongoObject.iso instanceof Date + Utils.isDate(mongoObject.iso) ) { mongoObject.iso = mongoObject.iso.toJSON(); return mongoObject; @@ -1116,11 +1116,11 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => { if (mongoObject === null) { return null; } - if (mongoObject instanceof Array) { + if (Array.isArray(mongoObject)) { return mongoObject.map(nestedMongoObjectToNestedParseObject); } - if (mongoObject instanceof Date) { + if (Utils.isDate(mongoObject)) { return Parse._encode(mongoObject); } @@ -1347,7 +1347,7 @@ var GeoPointCoder = { }, isValidDatabaseObject(object) { - return object instanceof Array && object.length == 2; + return Array.isArray(object) && object.length == 2; }, JSONToDatabase(json) { @@ -1373,7 +1373,7 @@ var PolygonCoder = { isValidDatabaseObject(object) { const coords = object.coordinates[0]; - if (object.type !== 'Polygon' || !(coords instanceof Array)) { + if (object.type !== 'Polygon' || !Array.isArray(coords)) { return false; } for (let i = 0; i < coords.length; i++) { diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 3dd4c6e5d1..8d70c506e6 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -572,7 +572,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus if (fieldValue.$containedBy) { const arr = fieldValue.$containedBy; - if (!(arr instanceof Array)) { + if (!Array.isArray(arr)) { throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $containedBy: should be an array`); } @@ -654,7 +654,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus if (fieldValue.$geoWithin && fieldValue.$geoWithin.$centerSphere) { const centerSphere = fieldValue.$geoWithin.$centerSphere; - if (!(centerSphere instanceof Array) || centerSphere.length < 2) { + if (!Array.isArray(centerSphere) || centerSphere.length < 2) { throw new Parse.Error( Parse.Error.INVALID_JSON, 'bad $geoWithin value; $centerSphere should be an array of Parse.GeoPoint and distance' @@ -662,7 +662,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus } // Get point, convert to geo point if necessary and validate let point = centerSphere[0]; - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { point = new Parse.GeoPoint(point[1], point[0]); } else if (!GeoPointCoder.isValidJSON(point)) { throw new Parse.Error( @@ -699,7 +699,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus ); } points = polygon.coordinates; - } else if (polygon instanceof Array) { + } else if (Array.isArray(polygon)) { if (polygon.length < 3) { throw new Parse.Error( Parse.Error.INVALID_JSON, @@ -715,7 +715,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus } points = points .map(point => { - if (point instanceof Array && point.length === 2) { + if (Array.isArray(point) && point.length === 2) { Parse.GeoPoint._validate(point[1], point[0]); return `(${point[0]}, ${point[1]})`; } @@ -1696,7 +1696,7 @@ export class PostgresStorageAdapter implements StorageAdapter { updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, toPostgresValue(fieldValue)); index += 2; - } else if (fieldValue instanceof Date) { + } else if (Utils.isDate(fieldValue)) { updatePatterns.push(`$${index}:name = $${index + 1}`); values.push(fieldName, fieldValue); index += 2; @@ -2057,7 +2057,7 @@ export class PostgresStorageAdapter implements StorageAdapter { if (object[fieldName] === null) { delete object[fieldName]; } - if (object[fieldName] instanceof Date) { + if (Utils.isDate(object[fieldName])) { object[fieldName] = { __type: 'Date', iso: object[fieldName].toISOString(), diff --git a/src/Config.js b/src/Config.js index c164594457..924fce3ee8 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,6 +24,7 @@ import { } from './Options/Definitions'; import ParseServer from './cloud-code/Parse.Server'; import Deprecator from './Deprecator/Deprecator'; +import Utils from './Utils'; function removeTrailingSlash(str) { if (!str) { @@ -346,7 +347,7 @@ export class Config { } if (pages.customRoutes === undefined) { pages.customRoutes = PagesOptions.customRoutes.default; - } else if (!(pages.customRoutes instanceof Array)) { + } else if (!Array.isArray(pages.customRoutes)) { throw 'Parse Server option pages.customRoutes must be an array.'; } if (pages.encodePageParamHeaders === undefined) { @@ -369,7 +370,7 @@ export class Config { } if (!idempotencyOptions.paths) { idempotencyOptions.paths = IdempotencyOptions.paths.default; - } else if (!(idempotencyOptions.paths instanceof Array)) { + } else if (!Array.isArray(idempotencyOptions.paths)) { throw 'idempotency paths must be of an array of strings'; } } @@ -420,7 +421,7 @@ export class Config { if (passwordPolicy.validatorPattern) { if (typeof passwordPolicy.validatorPattern === 'string') { passwordPolicy.validatorPattern = new RegExp(passwordPolicy.validatorPattern); - } else if (!(passwordPolicy.validatorPattern instanceof RegExp)) { + } else if (!Utils.isRegExp(passwordPolicy.validatorPattern)) { throw 'passwordPolicy.validatorPattern must be a regex string or RegExp object.'; } } @@ -536,7 +537,7 @@ export class Config { static validateFileUploadOptions(fileUpload) { try { - if (fileUpload == null || typeof fileUpload !== 'object' || fileUpload instanceof Array) { + if (fileUpload == null || typeof fileUpload !== 'object' || Array.isArray(fileUpload)) { throw 'fileUpload must be an object value.'; } } catch (e) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index f8ba7543e7..5f6ece21cb 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -132,7 +132,7 @@ const validateQuery = ( } if (query.$or) { - if (query.$or instanceof Array) { + if (Array.isArray(query.$or)) { query.$or.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $or format - use an array value.'); @@ -140,7 +140,7 @@ const validateQuery = ( } if (query.$and) { - if (query.$and instanceof Array) { + if (Array.isArray(query.$and)) { query.$and.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Bad $and format - use an array value.'); @@ -148,7 +148,7 @@ const validateQuery = ( } if (query.$nor) { - if (query.$nor instanceof Array && query.$nor.length > 0) { + if (Array.isArray(query.$nor) && query.$nor.length > 0) { query.$nor.forEach(value => validateQuery(value, isMaster, isMaintenance, update, options, _depth + 1)); } else { throw new Parse.Error( @@ -323,19 +323,19 @@ const flattenUpdateOperatorsForCreate = object => { object[key] = object[key].amount; break; case 'Add': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = object[key].objects; break; case 'AddUnique': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = object[key].objects; break; case 'Remove': - if (!(object[key].objects instanceof Array)) { + if (!Array.isArray(object[key].objects)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } object[key] = []; diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 28604363a9..94c14d4cc3 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -70,7 +70,7 @@ export class FilesController extends AdaptableController { * Object may be a single object or list of REST-format objects. */ async expandFilesInObject(config, object) { - if (object instanceof Array) { + if (Array.isArray(object)) { const promises = object.map(obj => this.expandFilesInObject(config, obj)); await Promise.all(promises); return; diff --git a/src/Controllers/LiveQueryController.js b/src/Controllers/LiveQueryController.js index b3ee7fcf65..5d11a5e877 100644 --- a/src/Controllers/LiveQueryController.js +++ b/src/Controllers/LiveQueryController.js @@ -9,7 +9,7 @@ export class LiveQueryController { // If config is empty, we just assume no classs needs to be registered as LiveQuery if (!config || !config.classNames) { this.classNames = new Set(); - } else if (config.classNames instanceof Array) { + } else if (Array.isArray(config.classNames)) { const classNames = config.classNames.map(name => { const _name = getClassName(name); return new RegExp(`^${_name}$`); diff --git a/src/Controllers/ParseGraphQLController.js b/src/Controllers/ParseGraphQLController.js index 78a5bea53a..4445f4763c 100644 --- a/src/Controllers/ParseGraphQLController.js +++ b/src/Controllers/ParseGraphQLController.js @@ -1,4 +1,5 @@ import requiredParameter from '../../lib/requiredParameter'; +import Utils from '../Utils'; import DatabaseController from './DatabaseController'; import CacheController from './CacheController'; @@ -306,8 +307,8 @@ const isValidSimpleObject = function (obj): boolean { typeof obj === 'object' && !Array.isArray(obj) && obj !== null && - obj instanceof Date !== true && - obj instanceof Promise !== true + Utils.isDate(obj) !== true && + Utils.isPromise(obj) !== true ); }; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index e112480e65..8ad7352647 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -1579,7 +1579,7 @@ function getType(obj: any): ?(SchemaField | string) { // also gets the appropriate type for $ operators. // Returns null if the type is unknown. function getObjectType(obj): ?(SchemaField | string) { - if (obj instanceof Array) { + if (Array.isArray(obj)) { return 'Array'; } if (obj.__type) { diff --git a/src/GraphQL/loaders/defaultGraphQLTypes.js b/src/GraphQL/loaders/defaultGraphQLTypes.js index a7dd523ba5..d24047329c 100644 --- a/src/GraphQL/loaders/defaultGraphQLTypes.js +++ b/src/GraphQL/loaders/defaultGraphQLTypes.js @@ -16,6 +16,7 @@ import { } from 'graphql'; import { toGlobalId } from 'graphql-relay'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'; +import Utils from '../../Utils'; class TypeValidationError extends Error { constructor(value, type) { @@ -149,7 +150,7 @@ const parseDateIsoValue = value => { if (!isNaN(date)) { return date; } - } else if (value instanceof Date) { + } else if (Utils.isDate(value)) { return value; } @@ -160,7 +161,7 @@ const serializeDateIso = value => { if (typeof value === 'string') { return value; } - if (value instanceof Date) { + if (Utils.isDate(value)) { return value.toISOString(); } @@ -179,7 +180,7 @@ const DATE = new GraphQLScalarType({ name: 'Date', description: 'The Date scalar type is used in operations and types that involve dates.', parseValue(value) { - if (typeof value === 'string' || value instanceof Date) { + if (typeof value === 'string' || Utils.isDate(value)) { return { __type: 'Date', iso: parseDateIsoValue(value), @@ -194,7 +195,7 @@ const DATE = new GraphQLScalarType({ throw new TypeValidationError(value, 'Date'); }, serialize(value) { - if (typeof value === 'string' || value instanceof Date) { + if (typeof value === 'string' || Utils.isDate(value)) { return serializeDateIso(value); } else if (typeof value === 'object' && value.__type === 'Date' && value.iso) { return serializeDateIso(value.iso); diff --git a/src/GraphQL/transformers/query.js b/src/GraphQL/transformers/query.js index bf4946f125..cff565491a 100644 --- a/src/GraphQL/transformers/query.js +++ b/src/GraphQL/transformers/query.js @@ -200,7 +200,7 @@ const transformQueryConstraintInputToParse = ( } break; case 'polygon': - if (fieldValue instanceof Array) { + if (Array.isArray(fieldValue)) { fieldValue.forEach(geoPoint => { if (typeof geoPoint === 'object' && !geoPoint.__type) { geoPoint.__type = 'GeoPoint'; diff --git a/src/RestQuery.js b/src/RestQuery.js index d1a2696421..f6cdc54886 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -1223,7 +1223,7 @@ function includePath(config, auth, response, path, context, restOptions = {}) { // Path is a list of fields to search into. // Returns a list of pointers in REST format. function findPointers(object, path) { - if (object instanceof Array) { + if (Array.isArray(object)) { return object.map(x => findPointers(x, path)).flat(); } @@ -1252,7 +1252,7 @@ function findPointers(object, path) { // Returns something analogous to object, but with the appropriate // pointers inflated. function replacePointers(object, path, replace) { - if (object instanceof Array) { + if (Array.isArray(object)) { return object .map(obj => replacePointers(obj, path, replace)) .filter(obj => typeof obj !== 'undefined'); @@ -1291,7 +1291,7 @@ function findObjectWithKey(root, key) { if (typeof root !== 'object') { return; } - if (root instanceof Array) { + if (Array.isArray(root)) { for (var item of root) { const answer = findObjectWithKey(item, key); if (answer) { diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index e7d180c179..6125c3e8db 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -434,7 +434,7 @@ export class FilesRouter { } // Dispatch to the appropriate handler based on whether the body was buffered - if (req.body instanceof Buffer) { + if (Buffer.isBuffer(req.body)) { return this._handleBufferedUpload(req, res, next); } return this._handleStreamUpload(req, res, next); diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index 12d4e7253c..7b88026ed7 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -444,7 +444,7 @@ export class PagesRouter extends PromiseRouter { : Object.prototype.toString.call(this.pagesConfig.placeholders) === '[object Object]' ? this.pagesConfig.placeholders : {}; - if (configPlaceholders instanceof Promise) { + if (Utils.isPromise(configPlaceholders)) { configPlaceholders = await configPlaceholders; } diff --git a/src/Security/Check.js b/src/Security/Check.js index c31480e73d..c7c3aba5e4 100644 --- a/src/Security/Check.js +++ b/src/Security/Check.js @@ -42,7 +42,7 @@ class Check { async run() { // Get check as synchronous or asynchronous function - const check = this.check instanceof Promise ? await this.check : this.check; + const check = Utils.isPromise(this.check) ? await this.check : this.check; // Run check try { diff --git a/src/StatusHandler.js b/src/StatusHandler.js index 314b07bbc2..05c4d09e75 100644 --- a/src/StatusHandler.js +++ b/src/StatusHandler.js @@ -1,4 +1,5 @@ import { md5Hash, newObjectId } from './cryptoUtils'; +import Utils from './Utils'; import { KeyPromiseQueue } from './KeyPromiseQueue'; import { logger } from './logger'; import rest from './rest'; @@ -113,7 +114,7 @@ export function jobStatusHandler(config) { if (message && typeof message === 'string') { update.message = message; } - if (message instanceof Error && typeof message.message === 'string') { + if (Utils.isNativeError(message) && typeof message.message === 'string') { update.message = message.message; } return handler.update({ objectId }, update); diff --git a/src/Utils.js b/src/Utils.js index 11c5d14399..1c6077231d 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -6,6 +6,7 @@ const path = require('path'); const fs = require('fs').promises; +const { types } = require('util'); /** * The general purpose utilities. @@ -120,12 +121,75 @@ class Utils { } /** - * Determines whether an object is a Promise. - * @param {any} object The object to validate. - * @returns {Boolean} Returns true if the object is a promise. + * Realm-safe check for Date. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Date. */ - static isPromise(object) { - return object instanceof Promise; + static isDate(value) { + return types.isDate(value); + } + + /** + * Realm-safe check for RegExp. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a RegExp. + */ + static isRegExp(value) { + return types.isRegExp(value); + } + + /** + * Realm-safe check for Map. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Map. + */ + static isMap(value) { + return types.isMap(value); + } + + /** + * Realm-safe check for Set. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Set. + */ + static isSet(value) { + return types.isSet(value); + } + + /** + * Realm-safe check for native Error. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a native Error. + */ + static isNativeError(value) { + return types.isNativeError(value); + } + + /** + * Realm-safe check for Promise (duck-typed as thenable). + * Guards against Object.prototype pollution by ensuring `then` is not + * inherited solely from Object.prototype. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a Promise or thenable. + */ + static isPromise(value) { + if (value == null || typeof value.then !== 'function') { + return false; + } + return Object.getPrototypeOf(value) !== Object.prototype || Object.prototype.hasOwnProperty.call(value, 'then'); + } + + /** + * Realm-safe check for object type. Uses `typeof` instead of `instanceof Object` + * which fails across realms. Returns true for any non-null value where + * `typeof` is `'object'`, including plain objects, arrays, dates, maps, sets, + * regex, and boxed primitives (e.g. `new String()`). Returns false for `null`, + * `undefined`, unboxed primitives, and functions. + * @param {any} value The value to check. + * @returns {Boolean} Returns true if the value is a non-null object type. + */ + static isObject(value) { + return typeof value === 'object' && value !== null; } /** @@ -438,10 +502,10 @@ class Utils { static getCircularReplacer() { const seen = new WeakSet(); return (key, value) => { - if (value instanceof Map) { + if (Utils.isMap(value)) { return Object.fromEntries(value); } - if (value instanceof Set) { + if (Utils.isSet(value)) { return Array.from(value); } if (typeof value === 'object' && value !== null) { diff --git a/src/middlewares.js b/src/middlewares.js index fe87166d3b..f12fdd0ed1 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -1,4 +1,5 @@ import AppCache from './cache'; +import Utils from './Utils'; import Parse from 'parse/node'; import auth from './Auth'; import Config from './Config'; @@ -119,7 +120,7 @@ export async function handleParseHeaders(req, res, next) { if (!info.appId || !AppCache.get(info.appId)) { // See if we can find the app id on the body. - if (req.body instanceof Buffer) { + if (Buffer.isBuffer(req.body)) { // The only chance to find the app id is if this is a file // upload that actually is a JSON body. So try to parse it. // https://github.com/parse-community/parse-server/issues/6589 @@ -178,7 +179,7 @@ export async function handleParseHeaders(req, res, next) { delete req.body._MasterKey; } if (req.body._context) { - if (req.body._context instanceof Object) { + if (Utils.isObject(req.body._context)) { info.context = req.body._context; } else { try { diff --git a/src/triggers.js b/src/triggers.js index 7558aa6c2d..963382a007 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1,6 +1,7 @@ // triggers.js import Parse from 'parse/node'; import { logger } from './logger'; +import Utils from './Utils'; export const Types = { beforeLogin: 'beforeLogin', @@ -712,7 +713,7 @@ export function resolveError(message, defaultOpts) { return new Parse.Error(code, message); } const error = new Parse.Error(code, message.message || message); - if (message instanceof Error) { + if (Utils.isNativeError(message)) { error.stack = message.stack; } return error; From eecd9530eb10f79665aaa54922a9daf4a1192ae9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 16 Mar 2026 21:31:03 +0000 Subject: [PATCH 099/156] chore(release): 9.6.0-alpha.32 [skip ci] # [9.6.0-alpha.32](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.31...9.6.0-alpha.32) (2026-03-16) ### Bug Fixes * Instance comparison with `instanceof` is not realm-safe ([#10225](https://github.com/parse-community/parse-server/issues/10225)) ([51efb1e](https://github.com/parse-community/parse-server/commit/51efb1efb9fa3f2d578de63f61b20c6a4fbcbd9a)) --- 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 4d94fb0150..dd2fad57a1 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.32](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.31...9.6.0-alpha.32) (2026-03-16) + + +### Bug Fixes + +* Instance comparison with `instanceof` is not realm-safe ([#10225](https://github.com/parse-community/parse-server/issues/10225)) ([51efb1e](https://github.com/parse-community/parse-server/commit/51efb1efb9fa3f2d578de63f61b20c6a4fbcbd9a)) + # [9.6.0-alpha.31](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.30...9.6.0-alpha.31) (2026-03-16) diff --git a/package-lock.json b/package-lock.json index 802dc29804..e2d20eb2df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.31", + "version": "9.6.0-alpha.32", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.31", + "version": "9.6.0-alpha.32", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 70e00d6c8d..0e775cd3d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.31", + "version": "9.6.0-alpha.32", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From eba26560a350f966f98d60d13cb9891cb4215a5c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:06:42 +0000 Subject: [PATCH 100/156] test: LiveQuery regex timeout and context isolation are not exploitable (#10226) --- spec/QueryTools.spec.js | 114 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/spec/QueryTools.spec.js b/spec/QueryTools.spec.js index 0ea1b560e7..109c2a209b 100644 --- a/spec/QueryTools.spec.js +++ b/spec/QueryTools.spec.js @@ -998,4 +998,118 @@ describe('matchesQuery', function () { expect(matchesQuery(obj2, q)).toBe(true); expect(matchesQuery(obj3, q)).toBe(false); }); + + it('terminates catastrophic backtracking regex within regexTimeout (GHSA-qxh4-6wmx-rhg9)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + const object = { + id: new Id('Post', 'P1'), + title: 'aaaaaaaaaaaaaaaaaaaaaaaaaab', + }; + + // (a+)+$ is a classic catastrophic backtracking pattern + const q = new Parse.Query('Post'); + q._where = { title: { $regex: '(a+)+$' } }; + + const start = Date.now(); + // With timeout protection, the regex should be terminated and return false + const result = matchesQuery(object, q); + const elapsed = Date.now() - start; + + expect(result).toBe(false); + // Should complete within a reasonable time (timeout + overhead), not hang + expect(elapsed).toBeLessThan(5000); + } finally { + setRegexTimeout(0); + } + }); + + it('applies default regexTimeout of 100ms protecting against ReDoS (GHSA-qxh4-6wmx-rhg9)', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['Post'] }, + }); + const Config = require('../lib/Config'); + const config = Config.get('test'); + // Default regexTimeout is 100ms, providing ReDoS protection out-of-the-box + expect(config.liveQuery.regexTimeout).toBe(100); + expect(config.liveQuery.regexTimeout).toBeGreaterThan(0); + }); + + it('does not leak regex context between sequential evaluations with shared vmContext (GHSA-v88r-ghm9-267f)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + // Simulate the scenario from the advisory: + // Client A subscribes to { secretField: { $regex: "^admin" } } + // Client B subscribes to { publicField: { $regex: ".*" } } + + // Object with a secretField that should only match Client A's subscription + const object = { + id: new Id('Data', 'D1'), + secretField: 'admin_secret_data', + publicField: 'public_data', + }; + + // Client A's query: should match because secretField starts with "admin" + const queryA = new Parse.Query('Data'); + queryA._where = { secretField: { $regex: '^admin' } }; + + // Client B's query: should match because publicField matches .* + const queryB = new Parse.Query('Data'); + queryB._where = { publicField: { $regex: '.*' } }; + + // Evaluate both queries sequentially (as the LiveQuery server does) + const resultA = matchesQuery(object, queryA); + const resultB = matchesQuery(object, queryB); + + // Both should match correctly — no cross-contamination + expect(resultA).toBe(true); + expect(resultB).toBe(true); + + // Now test the inverse: object that should NOT match Client A + const object2 = { + id: new Id('Data', 'D2'), + secretField: 'user_regular_data', + publicField: 'public_data', + }; + + const resultA2 = matchesQuery(object2, queryA); + const resultB2 = matchesQuery(object2, queryB); + + // Client A should NOT match (secretField doesn't start with "admin") + // Client B should still match + expect(resultA2).toBe(false); + expect(resultB2).toBe(true); + } finally { + setRegexTimeout(0); + } + }); + + it('does not cross-contaminate regex results across different field evaluations with regexTimeout (GHSA-v88r-ghm9-267f)', function () { + const { setRegexTimeout } = require('../lib/LiveQuery/QueryTools'); + setRegexTimeout(100); + try { + // Multiple subscriptions with different regex patterns evaluated against + // different objects in rapid succession — the advisory claims the shared + // vmContext causes pattern/input from one call to leak into another + const subscriptions = [ + { where: { field: { $regex: '^secret' } }, object: { id: new Id('X', '1'), field: 'secret_value' }, expected: true }, + { where: { field: { $regex: '^public' } }, object: { id: new Id('X', '2'), field: 'public_value' }, expected: true }, + { where: { field: { $regex: '^secret' } }, object: { id: new Id('X', '3'), field: 'public_value' }, expected: false }, + { where: { field: { $regex: '^public' } }, object: { id: new Id('X', '4'), field: 'secret_value' }, expected: false }, + { where: { field: { $regex: '^admin' } }, object: { id: new Id('X', '5'), field: 'admin_panel' }, expected: true }, + { where: { field: { $regex: '^admin' } }, object: { id: new Id('X', '6'), field: 'user_panel' }, expected: false }, + ]; + + for (const sub of subscriptions) { + const q = new Parse.Query('X'); + q._where = sub.where; + const result = matchesQuery(sub.object, q); + expect(result).toBe(sub.expected); + } + } finally { + setRegexTimeout(0); + } + }); }); From 1551faaf5683b59f2c21741af4d63456b57bd16b Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:18:00 +0000 Subject: [PATCH 101/156] docs: Add documentation to `safeRegexTest` (#10227) --- src/LiveQuery/QueryTools.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 95159ed8b9..215f3301eb 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -5,6 +5,12 @@ var vm = require('vm'); var logger = require('../logger').default; var regexTimeout = 0; +// IMPORTANT: vmContext is shared across all calls for performance (vm.createContext() is expensive). +// This is safe because safeRegexTest is synchronous — setting the context properties and calling +// runInContext happen in the same event loop tick with no interruption possible. Do NOT add any +// asynchronous operations (await, callbacks, promises) between setting vmContext properties and +// calling script.runInContext, as this would allow other calls to overwrite the context values +// and cause cross-contamination between regex evaluations. var vmContext = vm.createContext(Object.create(null)); var scriptCache = new Map(); var SCRIPT_CACHE_MAX = 1000; @@ -13,6 +19,7 @@ function setRegexTimeout(ms) { regexTimeout = ms; } +// IMPORTANT: This function must remain synchronous. See vmContext comment above. function safeRegexTest(pattern, flags, input) { try { if (!regexTimeout) { From 622ee85dc27a4ef721c1d4f61d3ed881a064da0b Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:26:31 +0000 Subject: [PATCH 102/156] feat: Add `enableProductPurchaseLegacyApi` option to disable legacy IAP validation (#10228) --- spec/Deprecator.spec.js | 23 +++++++++++++++++++++++ spec/PurchaseValidation.spec.js | 25 +++++++++++++++++++++++++ src/Deprecator/Deprecations.js | 5 +++++ src/Options/Definitions.js | 6 ++++++ src/Options/docs.js | 1 + src/Options/index.js | 4 ++++ src/ParseServer.ts | 9 ++++++--- 7 files changed, 70 insertions(+), 3 deletions(-) diff --git a/spec/Deprecator.spec.js b/spec/Deprecator.spec.js index 021a56903b..34d7f01321 100644 --- a/spec/Deprecator.spec.js +++ b/spec/Deprecator.spec.js @@ -171,6 +171,29 @@ describe('Deprecator', () => { } }); + it('logs deprecation for enableProductPurchaseLegacyApi when set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer({ enableProductPurchaseLegacyApi: true }); + expect(logSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'enableProductPurchaseLegacyApi', + changeNewKey: '', + }) + ); + }); + + it('does not log deprecation for enableProductPurchaseLegacyApi when not set', async () => { + const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); + + await reconfigureServer(); + expect(logSpy).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + optionKey: 'enableProductPurchaseLegacyApi', + }) + ); + }); + it('does not log deprecation for requestComplexity limits when explicitly set', async () => { const logSpy = spyOn(Deprecator, '_logOption').and.callFake(() => {}); diff --git a/spec/PurchaseValidation.spec.js b/spec/PurchaseValidation.spec.js index 478b81260e..231198e8dc 100644 --- a/spec/PurchaseValidation.spec.js +++ b/spec/PurchaseValidation.spec.js @@ -186,6 +186,31 @@ describe('test validate_receipt endpoint', () => { }); }); + it('should disable validate_purchase endpoint when enableProductPurchaseLegacyApi is false', async () => { + await reconfigureServer({ enableProductPurchaseLegacyApi: false }); + const ParseServer = require('../lib/ParseServer').default; + const routers = ParseServer.promiseRouter({ + appId: 'test', + options: { enableProductPurchaseLegacyApi: false }, + }); + const hasValidatePurchase = routers.routes.some( + r => r.path === '/validate_purchase' && r.method === 'POST' + ); + expect(hasValidatePurchase).toBe(false); + }); + + it('should enable validate_purchase endpoint by default', async () => { + const ParseServer = require('../lib/ParseServer').default; + const routers = ParseServer.promiseRouter({ + appId: 'test', + options: {}, + }); + const hasValidatePurchase = routers.routes.some( + r => r.path === '/validate_purchase' && r.method === 'POST' + ); + expect(hasValidatePurchase).toBe(true); + }); + it('should not be able to remove a require key in a _Product', done => { const query = new Parse.Query('_Product'); query diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index ad8f3ba805..18baa00927 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -71,4 +71,9 @@ module.exports = [ changeNewDefault: '200', solution: "Set 'requestComplexity.graphQLFields' to a positive integer appropriate for your app to limit the number of GraphQL field selections, or to '-1' to disable.", }, + { + optionKey: 'enableProductPurchaseLegacyApi', + changeNewKey: '', + solution: "The product purchase API is an undocumented, unmaintained legacy feature that may not function as expected and will be removed in a future major version. We strongly advise against using it. Set 'enableProductPurchaseLegacyApi' to 'false' to disable it, or remove the option to accept the future removal.", + }, ]; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index e502ba6db5..e9f8227317 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -238,6 +238,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + enableProductPurchaseLegacyApi: { + env: 'PARSE_SERVER_ENABLE_PRODUCT_PURCHASE_LEGACY_API', + help: 'Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version.', + action: parsers.booleanParser, + default: true, + }, enableSanitizedErrorResponse: { env: 'PARSE_SERVER_ENABLE_SANITIZED_ERROR_RESPONSE', help: 'If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 1e19bfa703..5845629022 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -46,6 +46,7 @@ * @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`. * @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors * @property {Boolean} enableInsecureAuthAdapters Optional. Enables insecure authentication adapters. Insecure auth adapters are deprecated and will be removed in a future version. Defaults to `false`. + * @property {Boolean} enableProductPurchaseLegacyApi Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version. * @property {Boolean} enableSanitizedErrorResponse If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`. * @property {String} encryptionKey Key for encrypting your files * @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access. diff --git a/src/Options/index.js b/src/Options/index.js index 43cc58b287..d9f447cf19 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -305,6 +305,10 @@ export interface ParseServerOptions { /* Enables the default express error handler for all errors :DEFAULT: false */ enableExpressErrorHandler: ?boolean; + /* Deprecated. Enables the legacy product purchase API including the `_Product` class and the `/validate_purchase` endpoint. This is an undocumented, unmaintained legacy feature inherited from the original Parse platform that may not function as expected. We strongly advise against using it. It will be removed in a future major version. + :ENV: PARSE_SERVER_ENABLE_PRODUCT_PURCHASE_LEGACY_API + :DEFAULT: true */ + enableProductPurchaseLegacyApi: ?boolean; /* Sets the number of characters in generated object id's, default 10 :DEFAULT: 10 */ objectIdSize: ?number; diff --git a/src/ParseServer.ts b/src/ParseServer.ts index 2681fe0d84..b1f03c5863 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -345,7 +345,7 @@ class ParseServer { } api.use(middlewares.handleParseSession); this.applyRequestContextMiddleware(api, options); - const appRouter = ParseServer.promiseRouter({ appId }); + const appRouter = ParseServer.promiseRouter({ appId, options }); api.use(appRouter.expressRouter()); api.use(middlewares.handleParseErrors); @@ -378,7 +378,7 @@ class ParseServer { return api; } - static promiseRouter({ appId }) { + static promiseRouter({ appId, options }) { const routers = [ new ClassesRouter(), new UsersRouter(), @@ -390,7 +390,6 @@ class ParseServer { new SchemasRouter(), new PushRouter(), new LogsRouter(), - new IAPValidationRouter(), new FeaturesRouter(), new GlobalConfigRouter(), new GraphQLRouter(), @@ -402,6 +401,10 @@ class ParseServer { new SecurityRouter(), ]; + if (options?.enableProductPurchaseLegacyApi !== false) { + routers.push(new IAPValidationRouter()); + } + const routes = routers.reduce((memo, router) => { return memo.concat(router.routes); }, []); From 9054b6218c5000a2778696a704b4647eb162b3eb Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 17 Mar 2026 00:27:30 +0000 Subject: [PATCH 103/156] chore(release): 9.6.0-alpha.33 [skip ci] # [9.6.0-alpha.33](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.32...9.6.0-alpha.33) (2026-03-17) ### Features * Add `enableProductPurchaseLegacyApi` option to disable legacy IAP validation ([#10228](https://github.com/parse-community/parse-server/issues/10228)) ([622ee85](https://github.com/parse-community/parse-server/commit/622ee85dc27a4ef721c1d4f61d3ed881a064da0b)) --- 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 dd2fad57a1..4d7cbcd46b 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.33](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.32...9.6.0-alpha.33) (2026-03-17) + + +### Features + +* Add `enableProductPurchaseLegacyApi` option to disable legacy IAP validation ([#10228](https://github.com/parse-community/parse-server/issues/10228)) ([622ee85](https://github.com/parse-community/parse-server/commit/622ee85dc27a4ef721c1d4f61d3ed881a064da0b)) + # [9.6.0-alpha.32](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.31...9.6.0-alpha.32) (2026-03-16) diff --git a/package-lock.json b/package-lock.json index e2d20eb2df..a7dba4ccaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.32", + "version": "9.6.0-alpha.33", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.32", + "version": "9.6.0-alpha.33", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 0e775cd3d9..12a2d31b35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.32", + "version": "9.6.0-alpha.33", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 206e8b6d9321eaf4af32db3582caa3700f4e9916 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:29:57 +0000 Subject: [PATCH 104/156] docs: Add DEPPS19 for `enableProductPurchaseLegacyApi` deprecation (#10229) --- DEPRECATIONS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 3cb972d569..cd001e7162 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -22,6 +22,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS16 | Remove config option `mountPlayground` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_change]: ## "The version and date of the planned change." From a6289118d268d5dd5c453a22e99a48d36dcc81da Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:17:57 +0000 Subject: [PATCH 105/156] fix: Input type validation for query operators and batch path (#10230) --- spec/ParseQuery.spec.js | 184 ++++++++++++++++++ spec/batch.spec.js | 89 +++++++++ .../Postgres/PostgresStorageAdapter.js | 12 ++ src/Controllers/DatabaseController.js | 6 + src/Routers/AggregateRouter.js | 6 +- src/Routers/ClassesRouter.js | 6 +- src/batch.js | 5 + 7 files changed, 306 insertions(+), 2 deletions(-) diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 35b34c5a26..b7c3fe02d7 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -5565,4 +5565,188 @@ describe('Parse.Query testing', () => { } ); }); + + describe('query input type validation', () => { + const restHeaders = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + describe('malformed where parameter', () => { + it('rejects invalid JSON in where parameter with proper error instead of 500', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/classes/TestClass?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for roles endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/roles?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for users endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/users?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects invalid JSON in where parameter for sessions endpoint', async () => { + await expectAsync( + request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions?where=%7Bbad-json', + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid JSON in where parameter', async () => { + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(JSON.stringify({}))}`, + headers: restHeaders, + }); + expect(result.data.results).toEqual([]); + }); + }); + + describe('$regex type validation', () => { + it('rejects object $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: { a: 1 } } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects numeric $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: 123 } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects array $regex in query', async () => { + const where = JSON.stringify({ field: { $regex: ['test'] } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid string $regex in query', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('field', 'hello'); + await obj.save(); + const where = JSON.stringify({ field: { $regex: '^hello$' } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); + }); + }); + + describe('$options type validation', () => { + it('rejects numeric $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: 123 } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects object $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: { a: 1 } } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('rejects boolean $options in query', async () => { + const where = JSON.stringify({ field: { $regex: 'test', $options: true } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts valid string $options in query', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('field', 'hello'); + await obj.save(); + const where = JSON.stringify({ field: { $regex: 'hello', $options: 'i' } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); + }); + }); + + describe('$in type validation on text fields', () => { + it_only_db('postgres')('rejects non-matching type in $in for text field with proper error instead of 500', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('textField', 'hello'); + await obj.save(); + const where = JSON.stringify({ textField: { $in: [1, 2, 3] } }); + await expectAsync( + request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }) + ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); + }); + + it('still accepts matching type in $in for text field', async () => { + const obj = new Parse.Object('TestClass'); + obj.set('textField', 'hello'); + await obj.save(); + const where = JSON.stringify({ textField: { $in: ['hello', 'world'] } }); + const result = await request({ + method: 'GET', + url: `http://localhost:8378/1/classes/TestClass?where=${encodeURIComponent(where)}`, + headers: restHeaders, + }); + expect(result.data.results.length).toBe(1); + }); + }); + }); }); diff --git a/spec/batch.spec.js b/spec/batch.spec.js index 9fc9ccdb48..43490df61f 100644 --- a/spec/batch.spec.js +++ b/spec/batch.spec.js @@ -593,4 +593,93 @@ describe('batch', () => { }); }); } + + describe('subrequest path type validation', () => { + it('rejects object path in batch subrequest with proper error instead of 500', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: { invalid: true } }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects numeric path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: 123 }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects array path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: ['/1/classes/Test'] }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects null path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: null }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('rejects boolean path in batch subrequest', async () => { + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: true }], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ status: 400 }) + ); + }); + + it('still accepts valid string path in batch subrequest', async () => { + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [{ method: 'GET', path: '/1/classes/TestClass' }], + }), + }); + expect(result.data).toEqual(jasmine.any(Array)); + }); + }); }); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 8d70c506e6..cd9e51fde3 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -486,6 +486,18 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus if (fieldName.indexOf('.') >= 0) { return; } + const fieldType = schema.fields[fieldName]?.type; + if (fieldType === 'String') { + const operatorName = notIn ? '$nin' : '$in'; + for (const elem of baseArray) { + if (elem != null && typeof elem !== 'string') { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `${operatorName} element type mismatch: expected string for field "${fieldName}"` + ); + } + } + } const inPatterns = []; values.push(fieldName); baseArray.forEach((listElem, listIndex) => { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 5f6ece21cb..da710649bb 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -160,6 +160,12 @@ const validateQuery = ( Object.keys(query).forEach(key => { if (query && query[key] && query[key].$regex) { + if (typeof query[key].$regex !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$regex value must be a string'); + } + if (query[key].$options !== undefined && typeof query[key].$options !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, '$options value must be a string'); + } if (typeof query[key].$options === 'string') { if (!query[key].$options.match(/^[imxsu]+$/)) { throw new Parse.Error( diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index cf9b5cd190..88c5795b82 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -29,7 +29,11 @@ export class AggregateRouter extends ClassesRouter { } options.pipeline = AggregateRouter.getPipeline(body); if (typeof body.where === 'string') { - body.where = JSON.parse(body.where); + try { + body.where = JSON.parse(body.where); + } catch { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'where parameter is not valid JSON'); + } } try { const response = await rest.find( diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 83db6dbab0..234c216103 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -30,7 +30,11 @@ export class ClassesRouter extends PromiseRouter { options.redirectClassNameForKey = String(body.redirectClassNameForKey); } if (typeof body.where === 'string') { - body.where = JSON.parse(body.where); + try { + body.where = JSON.parse(body.where); + } catch { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'where parameter is not valid JSON'); + } } return rest .find( diff --git a/src/batch.js b/src/batch.js index 98648e915b..85e3afb15d 100644 --- a/src/batch.js +++ b/src/batch.js @@ -67,6 +67,11 @@ async function handleBatch(router, req) { if (!Array.isArray(req.body?.requests)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array'); } + for (const restRequest of req.body.requests) { + if (!restRequest || typeof restRequest !== 'object' || typeof restRequest.path !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'batch request path must be a string'); + } + } // The batch paths are all from the root of our domain. // That means they include the API prefix, that the API is mounted From 0a6c872c2c3cfcaf077bab8c8929ad9ad4c00f8f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 17 Mar 2026 15:19:52 +0000 Subject: [PATCH 106/156] chore(release): 9.6.0-alpha.34 [skip ci] # [9.6.0-alpha.34](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.33...9.6.0-alpha.34) (2026-03-17) ### Bug Fixes * Input type validation for query operators and batch path ([#10230](https://github.com/parse-community/parse-server/issues/10230)) ([a628911](https://github.com/parse-community/parse-server/commit/a6289118d268d5dd5c453a22e99a48d36dcc81da)) --- 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 4d7cbcd46b..6faab70ccb 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.34](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.33...9.6.0-alpha.34) (2026-03-17) + + +### Bug Fixes + +* Input type validation for query operators and batch path ([#10230](https://github.com/parse-community/parse-server/issues/10230)) ([a628911](https://github.com/parse-community/parse-server/commit/a6289118d268d5dd5c453a22e99a48d36dcc81da)) + # [9.6.0-alpha.33](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.32...9.6.0-alpha.33) (2026-03-17) diff --git a/package-lock.json b/package-lock.json index a7dba4ccaf..f585df43e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.33", + "version": "9.6.0-alpha.34", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.33", + "version": "9.6.0-alpha.34", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 12a2d31b35..7bb1ff81ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.33", + "version": "9.6.0-alpha.34", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 6648500428f33fb8ba336757702644d94ca0796a Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:34:30 +0000 Subject: [PATCH 107/156] fix: Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) (#10232) --- spec/vulnerabilities.spec.js | 130 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 6 ++ 2 files changed, 136 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index a8567a3718..5b82e2bb9c 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3092,3 +3092,133 @@ describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { }); }); }); + +describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent trigger', () => { + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('SecretClass', () => {}); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretField: { type: 'String' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretField'] }, + } + ); + obj = new Parse.Object('SecretClass'); + obj.set('secretField', 'SENSITIVE_DATA'); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should not leak protected fields on update event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on create event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('create', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('new'); + resolve(); + }); + }), + new Parse.Object('SecretClass').save( + { secretField: 'SECRET', publicField: 'new' }, + { useMasterKey: true } + ), + ]); + }); + + it('should not leak protected fields on delete event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('delete', object => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.destroy({ useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on enter event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'match'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('enter', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('match'); + expect(original.get('secretField')).toBeUndefined(); + resolve(); + }); + }), + obj.save({ publicField: 'match' }, { useMasterKey: true }), + ]); + }); + + it('should not leak protected fields on leave event when afterEvent trigger is registered', async () => { + const query = new Parse.Query('SecretClass'); + query.equalTo('publicField', 'visible'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('leave', (object, original) => { + expect(object.get('secretField')).toBeUndefined(); + expect(object.get('publicField')).toBe('changed'); + expect(original.get('secretField')).toBeUndefined(); + expect(original.get('publicField')).toBe('visible'); + resolve(); + }); + }), + obj.save({ publicField: 'changed' }, { useMasterKey: true }), + ]); + }); +}); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index a83b8e1a62..c530db4641 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -249,6 +249,7 @@ class ParseLiveQueryServer { if (res.object && typeof res.object.toJSON === 'function') { deletedParseObject = toJSONwithObjects(res.object, res.object.className || className); } + res.object = deletedParseObject; await this._filterSensitiveData( classLevelPermissions, res, @@ -257,6 +258,7 @@ class ParseLiveQueryServer { op, subscription.query ); + deletedParseObject = res.object; client.pushDelete(requestId, deletedParseObject); } catch (e) { const error = resolveError(e); @@ -414,6 +416,8 @@ class ParseLiveQueryServer { res.original.className || className ); } + res.object = currentParseObject; + res.original = originalParseObject; await this._filterSensitiveData( classLevelPermissions, res, @@ -422,6 +426,8 @@ class ParseLiveQueryServer { op, subscription.query ); + currentParseObject = res.object; + originalParseObject = res.original ?? null; const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1); if (client[functionName]) { client[functionName](requestId, currentParseObject, originalParseObject); From e71c125fbaca79ac7fb8151c77959347fe271e9f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 17 Mar 2026 18:35:44 +0000 Subject: [PATCH 108/156] chore(release): 9.6.0-alpha.35 [skip ci] # [9.6.0-alpha.35](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.34...9.6.0-alpha.35) (2026-03-17) ### Bug Fixes * Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10232](https://github.com/parse-community/parse-server/issues/10232)) ([6648500](https://github.com/parse-community/parse-server/commit/6648500428f33fb8ba336757702644d94ca0796a)) --- 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 6faab70ccb..43d833572b 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.35](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.34...9.6.0-alpha.35) (2026-03-17) + + +### Bug Fixes + +* Protected fields leak via LiveQuery afterEvent trigger ([GHSA-5hmj-jcgp-6hff](https://github.com/parse-community/parse-server/security/advisories/GHSA-5hmj-jcgp-6hff)) ([#10232](https://github.com/parse-community/parse-server/issues/10232)) ([6648500](https://github.com/parse-community/parse-server/commit/6648500428f33fb8ba336757702644d94ca0796a)) + # [9.6.0-alpha.34](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.33...9.6.0-alpha.34) (2026-03-17) diff --git a/package-lock.json b/package-lock.json index f585df43e4..333906747a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.34", + "version": "9.6.0-alpha.35", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.34", + "version": "9.6.0-alpha.35", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 7bb1ff81ab..4177018012 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.34", + "version": "9.6.0-alpha.35", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 7d72d264c03b63b463664d545c8c57f4851e4287 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:07:02 +0000 Subject: [PATCH 109/156] fix: Rate limit bypass via HTTP method override and batch method spoofing (#10234) --- spec/RateLimit.spec.js | 163 ++++++++++++++++++++++++++++++++++ src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- src/batch.js | 1 + src/cloud-code/Parse.Cloud.js | 2 +- src/middlewares.js | 14 ++- 7 files changed, 178 insertions(+), 8 deletions(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 516ea2fa08..d1c912e309 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -863,6 +863,169 @@ describe('rate limit', () => { }); }); + describe('method override bypass', () => { + it('should enforce rate limit when _method override attempts to change POST to GET', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + // First login via POST — should succeed + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + // Second login via POST with _method:GET — should still be rate limited + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow _method override with PUT', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'PUT', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + // Update via POST with _method:PUT — should succeed and count toward rate limit + await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj.id}`, + body: JSON.stringify({ _method: 'PUT', key: 'value1' }), + }); + // Second update via POST with _method:PUT — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj.id}`, + body: JSON.stringify({ _method: 'PUT', key: 'value2' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should allow _method override with DELETE', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'DELETE', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj1 = new Parse.Object('Test'); + await obj1.save(); + const obj2 = new Parse.Object('Test'); + await obj2.save(); + // Delete via POST with _method:DELETE — should succeed + await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj1.id}`, + body: JSON.stringify({ _method: 'DELETE' }), + }); + // Second delete via POST with _method:DELETE — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: `http://localhost:8378/1/classes/Test/${obj2.id}`, + body: JSON.stringify({ _method: 'DELETE' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + + it('should ignore _method override with non-string type', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*path', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'POST', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + // POST with _method as number — should be ignored and treated as POST + const obj = new Parse.Object('Test'); + await obj.save(); + const res = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/classes/Test', + body: JSON.stringify({ _method: 123, key: 'value' }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + }); + + describe('batch method bypass', () => { + it('should enforce POST rate limit on batch sub-requests using GET method for login', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + // Batch with 2 login sub-requests using GET — should be rate limited + const res = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/batch', + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + { method: 'GET', path: '/1/login', body: { username: 'testuser', password: 'password' } }, + ], + }), + }).catch(e => e); + expect(res.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + }); + describe_only(() => { return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; })('with RedisCache', function () { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index e9f8227317..af9279d712 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -489,7 +489,7 @@ module.exports.ParseServerOptions = { }, rateLimit: { env: 'PARSE_SERVER_RATE_LIMIT', - help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.", + help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.", action: parsers.arrayParser, type: 'RateLimitOptions[]', default: [], diff --git a/src/Options/docs.js b/src/Options/docs.js index 5845629022..20d74fb770 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -91,7 +91,7 @@ * @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details. * @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`. * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications - * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. + * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes * @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.

This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.

Special scenarios:
- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.
- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.

Considerations:
- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.
- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.
- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.
- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.

Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`. * @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit. diff --git a/src/Options/index.js b/src/Options/index.js index d9f447cf19..459494cdab 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -385,7 +385,7 @@ export interface ParseServerOptions { /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ requestKeywordDenylist: ?(RequestKeywordDenylist[]); - /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. + /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. :DEFAULT: [] */ rateLimit: ?(RateLimitOptions[]); /* Options to customize the request context using inversion of control/dependency injection.*/ diff --git a/src/batch.js b/src/batch.js index 85e3afb15d..6cb4a5bbfe 100644 --- a/src/batch.js +++ b/src/batch.js @@ -102,6 +102,7 @@ async function handleBatch(router, req) { const fakeReq = { ip: req.ip || req.config?.ip || '127.0.0.1', method: (restRequest.method || 'GET').toUpperCase(), + _batchOriginalMethod: 'POST', config: req.config, auth: req.auth, info: req.info, diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 7f2aded387..16f541cfeb 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -310,7 +310,7 @@ ParseCloud.beforeLogin = function (handler, validationHandler) { triggers.addTrigger(triggers.Types.beforeLogin, className, handler, Parse.applicationId); if (validationHandler && validationHandler.rateLimit) { addRateLimit( - { requestPath: `/login`, requestMethods: 'POST', ...validationHandler.rateLimit }, + { requestPath: `/login`, requestMethods: ['POST', 'GET'], ...validationHandler.rateLimit }, Parse.applicationId, true ); diff --git a/src/middlewares.js b/src/middlewares.js index f12fdd0ed1..c3ad4975e5 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -482,8 +482,10 @@ export function allowCrossDomain(appId) { export function allowMethodOverride(req, res, next) { if (req.method === 'POST' && req.body?._method) { - req.originalMethod = req.method; - req.method = req.body._method; + if (typeof req.body._method === 'string') { + req.originalMethod = req.method; + req.method = req.body._method; + } delete req.body._method; } next(); @@ -626,13 +628,17 @@ export const addRateLimit = (route, config, cloud) => { return false; } if (route.requestMethods) { + const methodsToCheck = new Set([request.method]); + if (request._batchOriginalMethod) { + methodsToCheck.add(request._batchOriginalMethod); + } if (Array.isArray(route.requestMethods)) { - if (!route.requestMethods.includes(request.method)) { + if (!route.requestMethods.some(m => methodsToCheck.has(m))) { return true; } } else { const regExp = new RegExp(route.requestMethods); - if (!regExp.test(request.method)) { + if (![...methodsToCheck].some(m => regExp.test(m))) { return true; } } From 80db912c6c99f3ea3afab26e740ccd6a998aba7c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Mar 2026 00:07:57 +0000 Subject: [PATCH 110/156] chore(release): 9.6.0-alpha.36 [skip ci] # [9.6.0-alpha.36](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.35...9.6.0-alpha.36) (2026-03-18) ### Bug Fixes * Rate limit bypass via HTTP method override and batch method spoofing ([#10234](https://github.com/parse-community/parse-server/issues/10234)) ([7d72d26](https://github.com/parse-community/parse-server/commit/7d72d264c03b63b463664d545c8c57f4851e4287)) --- 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 43d833572b..69fafae527 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.36](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.35...9.6.0-alpha.36) (2026-03-18) + + +### Bug Fixes + +* Rate limit bypass via HTTP method override and batch method spoofing ([#10234](https://github.com/parse-community/parse-server/issues/10234)) ([7d72d26](https://github.com/parse-community/parse-server/commit/7d72d264c03b63b463664d545c8c57f4851e4287)) + # [9.6.0-alpha.35](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.34...9.6.0-alpha.35) (2026-03-17) diff --git a/package-lock.json b/package-lock.json index 333906747a..53e0c62ab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.35", + "version": "9.6.0-alpha.36", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.35", + "version": "9.6.0-alpha.36", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 4177018012..333504a118 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.35", + "version": "9.6.0-alpha.36", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From f521576143336334aad2cbac82c3f368afe8f706 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:40:07 +0000 Subject: [PATCH 111/156] fix: Security fix fast-xml-parser from 5.5.5 to 5.5.6 (#10235) --- package-lock.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 53e0c62ab0..c0e6f22137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10467,9 +10467,9 @@ "dev": true }, "node_modules/fast-xml-builder": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", - "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", @@ -10482,9 +10482,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", - "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "funding": [ { "type": "github", @@ -10493,7 +10493,7 @@ ], "optional": true, "dependencies": { - "fast-xml-builder": "^1.1.3", + "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, @@ -30061,21 +30061,21 @@ "dev": true }, "fast-xml-builder": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", - "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "optional": true, "requires": { "path-expression-matcher": "^1.1.3" } }, "fast-xml-parser": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", - "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "optional": true, "requires": { - "fast-xml-builder": "^1.1.3", + "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" } From c77c8847713acfc94421922876f7c7e169033a43 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Mar 2026 02:41:05 +0000 Subject: [PATCH 112/156] chore(release): 9.6.0-alpha.37 [skip ci] # [9.6.0-alpha.37](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.36...9.6.0-alpha.37) (2026-03-18) ### Bug Fixes * Security fix fast-xml-parser from 5.5.5 to 5.5.6 ([#10235](https://github.com/parse-community/parse-server/issues/10235)) ([f521576](https://github.com/parse-community/parse-server/commit/f521576143336334aad2cbac82c3f368afe8f706)) --- 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 69fafae527..4577437e2f 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.37](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.36...9.6.0-alpha.37) (2026-03-18) + + +### Bug Fixes + +* Security fix fast-xml-parser from 5.5.5 to 5.5.6 ([#10235](https://github.com/parse-community/parse-server/issues/10235)) ([f521576](https://github.com/parse-community/parse-server/commit/f521576143336334aad2cbac82c3f368afe8f706)) + # [9.6.0-alpha.36](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.35...9.6.0-alpha.36) (2026-03-18) diff --git a/package-lock.json b/package-lock.json index c0e6f22137..a615f893df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.36", + "version": "9.6.0-alpha.37", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.36", + "version": "9.6.0-alpha.37", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 333504a118..3dea8ec02b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.36", + "version": "9.6.0-alpha.37", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 337ffd65ccf94495a54cd883c5e8fa7a3892606c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:48:43 +0000 Subject: [PATCH 113/156] fix: Sanitize control characters in page parameter response headers (#10237) --- spec/PagesRouter.spec.js | 26 +++++++++++++++++++++++++ src/Routers/PagesRouter.js | 39 ++++++++++++++++++++++++-------------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index f25035ec2c..b97e7b5bfe 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -1040,6 +1040,32 @@ describe('Pages Router', () => { }).catch(e => e); expect(response.status).not.toBe(500); }); + + it('does not return 500 when page parameter contains CRLF characters', async () => { + await reconfigureServer(config); + const crlf = 'abc\r\nX-Injected: 1'; + const url = `${config.publicServerURL}/apps/choose_password?appId=test&token=${encodeURIComponent(crlf)}&username=testuser`; + const response = await request({ + url: url, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + expect(response.status).toBe(200); + }); + + it('does not return 500 when page parameter contains CRLF characters in redirect response', async () => { + await reconfigureServer(config); + const crlf = 'abc\r\nX-Injected: 1'; + const url = `${config.publicServerURL}/apps/test/resend_verification_email`; + const response = await request({ + method: 'POST', + url: url, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `username=${encodeURIComponent(crlf)}`, + followRedirects: false, + }).catch(e => e); + expect(response.status).not.toBe(500); + }); }); describe('custom route', () => { diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index 7b88026ed7..30095ceb1d 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -455,13 +455,7 @@ export class PagesRouter extends PromiseRouter { // Add placeholders in header to allow parsing for programmatic use // of response, instead of having to parse the HTML content. - const encode = this.pagesConfig.encodePageParamHeaders; - const headers = Object.entries(params).reduce((m, p) => { - if (p[1] !== undefined) { - m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = encode ? encodeURIComponent(p[1]) : p[1]; - } - return m; - }, {}); + const headers = this.composePageParamHeaders(params); return { text: data, headers: headers }; } @@ -555,6 +549,29 @@ export class PagesRouter extends PromiseRouter { return locale; } + /** + * Composes page parameter headers from the given parameters. Control + * characters are always stripped from header values to prevent + * ERR_INVALID_CHAR errors. Values are URI-encoded if the + * `encodePageParamHeaders` option is enabled. + * @param {Object} params The parameters to include in the headers. + * @returns {Object} The headers object. + */ + composePageParamHeaders(params) { + const encode = this.pagesConfig.encodePageParamHeaders; + return Object.entries(params).reduce((m, p) => { + if (p[1] !== undefined) { + let value = encode ? encodeURIComponent(p[1]) : p[1]; + if (typeof value === 'string') { + // eslint-disable-next-line no-control-regex + value = value.replace(/[\x00-\x1f\x7f]/g, ''); + } + m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = value; + } + return m; + }, {}); + } + /** * Creates a response with http redirect. * @param {Object} req The express request. @@ -578,13 +595,7 @@ export class PagesRouter extends PromiseRouter { // Add parameters to header to allow parsing for programmatic use // of response, instead of having to parse the HTML content. - const encode = this.pagesConfig.encodePageParamHeaders; - const headers = Object.entries(params).reduce((m, p) => { - if (p[1] !== undefined) { - m[`${pageParamHeaderPrefix}${p[0].toLowerCase()}`] = encode ? encodeURIComponent(p[1]) : p[1]; - } - return m; - }, {}); + const headers = this.composePageParamHeaders(params); return { status: 303, From cd0ec813f040040c1d54fd63f9d4e2e68f7cc88d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Mar 2026 20:50:31 +0000 Subject: [PATCH 114/156] chore(release): 9.6.0-alpha.38 [skip ci] # [9.6.0-alpha.38](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.37...9.6.0-alpha.38) (2026-03-18) ### Bug Fixes * Sanitize control characters in page parameter response headers ([#10237](https://github.com/parse-community/parse-server/issues/10237)) ([337ffd6](https://github.com/parse-community/parse-server/commit/337ffd65ccf94495a54cd883c5e8fa7a3892606c)) --- 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 4577437e2f..7a42aa25ff 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.38](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.37...9.6.0-alpha.38) (2026-03-18) + + +### Bug Fixes + +* Sanitize control characters in page parameter response headers ([#10237](https://github.com/parse-community/parse-server/issues/10237)) ([337ffd6](https://github.com/parse-community/parse-server/commit/337ffd65ccf94495a54cd883c5e8fa7a3892606c)) + # [9.6.0-alpha.37](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.36...9.6.0-alpha.37) (2026-03-18) diff --git a/package-lock.json b/package-lock.json index a615f893df..7c4ebf4b97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.37", + "version": "9.6.0-alpha.38", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.37", + "version": "9.6.0-alpha.38", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 3dea8ec02b..40fc7e7b35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.37", + "version": "9.6.0-alpha.38", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 01fb6a972cf2437ba965dff590afec50184cf6e1 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:25:57 +0000 Subject: [PATCH 115/156] fix: Locale parameter path traversal in pages router (#10242) --- spec/PagesRouter.spec.js | 62 ++++++++++++++++++++++++++++++++++++-- src/Routers/PagesRouter.js | 9 ++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index b97e7b5bfe..80d05ce34e 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -1041,6 +1041,62 @@ describe('Pages Router', () => { expect(response.status).not.toBe(500); }); + it('rejects locale parameter with path traversal sequences', async () => { + const pagesDir = path.join(__dirname, 'tmp-pages-locale-test'); + const targetDir = path.join(__dirname, 'tmp-pages-locale-target'); + + try { + await fs.mkdir(pagesDir, { recursive: true }); + await fs.mkdir(targetDir, { recursive: true }); + + // Copy required HTML files to pagesDir + const publicDir = path.resolve(__dirname, '../public'); + for (const file of ['password_reset_link_invalid.html', 'password_reset.html']) { + const content = await fs.readFile(path.join(publicDir, file), 'utf-8'); + await fs.writeFile(path.join(pagesDir, file), content); + } + + // Place a probe file in target directory + await fs.writeFile( + path.join(targetDir, 'password_reset_link_invalid.html'), + 'secret' + ); + + const traversalLocale = path.relative(pagesDir, targetDir); + await reconfigureServer({ + ...config, + pages: { + enableLocalization: true, + pagesPath: pagesDir, + }, + }); + + // Without fix: file exists at traversed path → 404 (oracle) + // Without fix: file doesn't exist at traversed path → 200 (oracle) + // With fix: traversal locale is rejected, always returns default page → 200 + const response = await request({ + url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`, + followRedirects: false, + }).catch(e => e); + + // Should serve the default page (200), not a 404 from bounds check + expect(response.status).toBe(200); + + // Now remove the probe file and try again — response should be the same + await fs.rm(path.join(targetDir, 'password_reset_link_invalid.html')); + const response2 = await request({ + url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`, + followRedirects: false, + }).catch(e => e); + + // Should also be 200 — no difference reveals file existence + expect(response2.status).toBe(200); + } finally { + await fs.rm(pagesDir, { recursive: true, force: true }); + await fs.rm(targetDir, { recursive: true, force: true }); + } + }); + it('does not return 500 when page parameter contains CRLF characters', async () => { await reconfigureServer(config); const crlf = 'abc\r\nX-Injected: 1'; @@ -1444,15 +1500,17 @@ describe('Pages Router', () => { expect(response.text).toContain('<img'); }); - it('should escape XSS in locale parameter', async () => { + it('should reject XSS payload in locale parameter', async () => { const xssLocale = '">'; const response = await request({ url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`, }); expect(response.status).toBe(200); + // Invalid locale is rejected by format validation, so the XSS + // payload never reaches the page content expect(response.text).not.toContain(''); - expect(response.text).toContain('"><svg'); + expect(response.text).not.toContain('"><svg'); }); it('should handle legitimate usernames with quotes correctly', async () => { diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index 30095ceb1d..d2d6135551 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -546,6 +546,15 @@ export class PagesRouter extends PromiseRouter { (req.body || {})[pageParams.locale] || (req.params || {})[pageParams.locale] || (req.headers || {})[pageParamHeaderPrefix + pageParams.locale]; + + // Validate locale format to prevent path traversal; only allow + // standard locale patterns like "en", "en-US", "de-AT", "zh-Hans-CN" + if (locale !== undefined && typeof locale !== 'string') { + return undefined; + } + if (typeof locale === 'string' && !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(locale)) { + return undefined; + } return locale; } From e941b15ea518368907cd220c104c07de92477274 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 18 Mar 2026 23:26:55 +0000 Subject: [PATCH 116/156] chore(release): 9.6.0-alpha.39 [skip ci] # [9.6.0-alpha.39](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.38...9.6.0-alpha.39) (2026-03-18) ### Bug Fixes * Locale parameter path traversal in pages router ([#10242](https://github.com/parse-community/parse-server/issues/10242)) ([01fb6a9](https://github.com/parse-community/parse-server/commit/01fb6a972cf2437ba965dff590afec50184cf6e1)) --- 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 7a42aa25ff..98c205d5a2 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.39](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.38...9.6.0-alpha.39) (2026-03-18) + + +### Bug Fixes + +* Locale parameter path traversal in pages router ([#10242](https://github.com/parse-community/parse-server/issues/10242)) ([01fb6a9](https://github.com/parse-community/parse-server/commit/01fb6a972cf2437ba965dff590afec50184cf6e1)) + # [9.6.0-alpha.38](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.37...9.6.0-alpha.38) (2026-03-18) diff --git a/package-lock.json b/package-lock.json index 7c4ebf4b97..7ceef328ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.38", + "version": "9.6.0-alpha.39", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.38", + "version": "9.6.0-alpha.39", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 40fc7e7b35..7254b16611 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.38", + "version": "9.6.0-alpha.39", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From fbda4cb0c5cbc8fad08a216823b6b64d4ae289c3 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Thu, 19 Mar 2026 02:09:59 +0000 Subject: [PATCH 117/156] fix: Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) (#10238) --- spec/PagesRouter.spec.js | 192 +++++++++++++++-------- spec/ValidationAndPasswordsReset.spec.js | 31 +++- src/Routers/PagesRouter.js | 4 + 3 files changed, 158 insertions(+), 69 deletions(-) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index 80d05ce34e..b9db1fb715 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -840,6 +840,69 @@ describe('Pages Router', () => { followRedirects: false, }); expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page always redirects to the success page to prevent user enumeration + expect(formResponse.text).toContain( + `/${locale}/${pages.emailVerificationSendSuccess.defaultFile}` + ); + }); + + it('localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled', async () => { + config.emailVerifySuccessOnInvalidEmail = false; + await reconfigureServer(config); + const sendVerificationEmail = spyOn( + config.emailAdapter, + 'sendVerificationEmail' + ).and.callThrough(); + const user = new Parse.User(); + user.setUsername('exampleUsername'); + user.setPassword('examplePassword'); + user.set('email', 'mail@example.com'); + await user.signUp(); + await jasmine.timeout(); + + const link = sendVerificationEmail.calls.all()[0].args[0].link; + const linkWithLocale = new URL(link); + linkWithLocale.searchParams.append(pageParams.locale, exampleLocale); + linkWithLocale.searchParams.set(pageParams.token, 'invalidToken'); + + const linkResponse = await request({ + url: linkWithLocale.toString(), + followRedirects: false, + }); + expect(linkResponse.status).toBe(200); + + const appId = linkResponse.headers['x-parse-page-param-appid']; + const locale = linkResponse.headers['x-parse-page-param-locale']; + const publicServerUrl = linkResponse.headers['x-parse-page-param-publicserverurl']; + await jasmine.timeout(); + + const invalidVerificationPagePath = pageResponse.calls.all()[0].args[0]; + expect(appId).toBeDefined(); + expect(locale).toBe(exampleLocale); + expect(publicServerUrl).toBeDefined(); + expect(invalidVerificationPagePath).toMatch( + new RegExp(`\/${exampleLocale}\/${pages.emailVerificationLinkInvalid.defaultFile}`) + ); + + spyOn(UserController.prototype, 'resendVerificationEmail').and.callFake(() => + Promise.reject('failed to resend verification email') + ); + + const formUrl = `${publicServerUrl}/apps/${appId}/resend_verification_email`; + const formResponse = await request({ + url: formUrl, + method: 'POST', + body: { + locale, + username: 'exampleUsername', + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + followRedirects: false, + }); + expect(formResponse.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: false, the resend page + // redirects to the fail page expect(formResponse.text).toContain( `/${locale}/${pages.emailVerificationSendFail.defaultFile}` ); @@ -1041,86 +1104,79 @@ describe('Pages Router', () => { expect(response.status).not.toBe(500); }); - it('rejects locale parameter with path traversal sequences', async () => { - const pagesDir = path.join(__dirname, 'tmp-pages-locale-test'); - const targetDir = path.join(__dirname, 'tmp-pages-locale-target'); - - try { - await fs.mkdir(pagesDir, { recursive: true }); - await fs.mkdir(targetDir, { recursive: true }); - - // Copy required HTML files to pagesDir - const publicDir = path.resolve(__dirname, '../public'); - for (const file of ['password_reset_link_invalid.html', 'password_reset.html']) { - const content = await fs.readFile(path.join(publicDir, file), 'utf-8'); - await fs.writeFile(path.join(pagesDir, file), content); - } - - // Place a probe file in target directory - await fs.writeFile( - path.join(targetDir, 'password_reset_link_invalid.html'), - 'secret' - ); - - const traversalLocale = path.relative(pagesDir, targetDir); - await reconfigureServer({ - ...config, - pages: { - enableLocalization: true, - pagesPath: pagesDir, - }, - }); - - // Without fix: file exists at traversed path → 404 (oracle) - // Without fix: file doesn't exist at traversed path → 200 (oracle) - // With fix: traversal locale is rejected, always returns default page → 200 - const response = await request({ - url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`, - followRedirects: false, - }).catch(e => e); + it('does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: true, + emailAdapter, + }); - // Should serve the default page (200), not a 404 from bounds check - expect(response.status).toBe(200); + // Create a user with unverified email + const user = new Parse.User(); + user.setUsername('realuser'); + user.setPassword('password123'); + user.setEmail('real@example.com'); + await user.signUp(); - // Now remove the probe file and try again — response should be the same - await fs.rm(path.join(targetDir, 'password_reset_link_invalid.html')); - const response2 = await request({ - url: `${config.publicServerURL}/apps/test/request_password_reset?token=x&locale=${encodeURIComponent(traversalLocale)}`, - followRedirects: false, - }).catch(e => e); + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; - // Should also be 200 — no difference reveals file existence - expect(response2.status).toBe(200); - } finally { - await fs.rm(pagesDir, { recursive: true, force: true }); - await fs.rm(targetDir, { recursive: true, force: true }); - } - }); + // Resend for existing unverified user + const existingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=realuser', + followRedirects: false, + }).catch(e => e); - it('does not return 500 when page parameter contains CRLF characters', async () => { - await reconfigureServer(config); - const crlf = 'abc\r\nX-Injected: 1'; - const url = `${config.publicServerURL}/apps/choose_password?appId=test&token=${encodeURIComponent(crlf)}&username=testuser`; - const response = await request({ - url: url, + // Resend for non-existing user + const nonExistingResponse = await request({ + method: 'POST', + url: formUrl, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=fakeuser', followRedirects: false, }).catch(e => e); - expect(response.status).not.toBe(500); - expect(response.status).toBe(200); + + // Both should redirect to the same page (success) to prevent enumeration + expect(existingResponse.status).toBe(303); + expect(nonExistingResponse.status).toBe(303); + expect(existingResponse.headers.location).toContain('email_verification_send_success'); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_success'); }); - it('does not return 500 when page parameter contains CRLF characters in redirect response', async () => { - await reconfigureServer(config); - const crlf = 'abc\r\nX-Injected: 1'; - const url = `${config.publicServerURL}/apps/test/resend_verification_email`; - const response = await request({ + it('does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false', async () => { + const emailAdapter = { + sendVerificationEmail: () => {}, + sendPasswordResetEmail: () => {}, + sendMail: () => {}, + }; + await reconfigureServer({ + ...config, + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter, + }); + + const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`; + + // Resend for non-existing user should redirect to fail page + const nonExistingResponse = await request({ method: 'POST', - url: url, + url: formUrl, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `username=${encodeURIComponent(crlf)}`, + body: 'username=fakeuser', followRedirects: false, }).catch(e => e); - expect(response.status).not.toBe(500); + + expect(nonExistingResponse.status).toBe(303); + expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail'); }); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 62d00275e7..851013c1b7 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -740,7 +740,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => { }); }); - it('redirects you to link send fail page if you try to resend a link for a nonexistant user', done => { + it('redirects you to link send success page if you try to resend a link for a nonexistent user', done => { reconfigureServer({ appName: 'emailing app', verifyUserEmails: true, @@ -750,6 +750,35 @@ describe('Custom Pages, Email Verification, Password Reset', () => { sendMail: () => {}, }, publicServerURL: 'http://localhost:8378/1', + }).then(() => { + request({ + url: 'http://localhost:8378/1/apps/test/resend_verification_email', + method: 'POST', + followRedirects: false, + body: { + username: 'sadfasga', + }, + }).then(response => { + expect(response.status).toEqual(303); + // With emailVerifySuccessOnInvalidEmail: true (default), the resend + // page redirects to success to prevent user enumeration + expect(response.text).toContain('email_verification_send_success.html'); + done(); + }); + }); + }); + + it('redirects you to link send fail page if you try to resend a link for a nonexistent user with emailVerifySuccessOnInvalidEmail disabled', done => { + reconfigureServer({ + appName: 'emailing app', + verifyUserEmails: true, + emailVerifySuccessOnInvalidEmail: false, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {}, + }, + publicServerURL: 'http://localhost:8378/1', }).then(() => { request({ url: 'http://localhost:8378/1/apps/test/resend_verification_email', diff --git a/src/Routers/PagesRouter.js b/src/Routers/PagesRouter.js index d2d6135551..687440c37a 100644 --- a/src/Routers/PagesRouter.js +++ b/src/Routers/PagesRouter.js @@ -120,12 +120,16 @@ export class PagesRouter extends PromiseRouter { } const userController = config.userController; + const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true; return userController.resendVerificationEmail(username, req, token).then( () => { return this.goToPage(req, pages.emailVerificationSendSuccess); }, () => { + if (suppressError) { + return this.goToPage(req, pages.emailVerificationSendSuccess); + } return this.goToPage(req, pages.emailVerificationSendFail); } ); From 859aa65f65973215ccfffd80aaa7b36217223fed Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 19 Mar 2026 02:10:48 +0000 Subject: [PATCH 118/156] chore(release): 9.6.0-alpha.40 [skip ci] # [9.6.0-alpha.40](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.39...9.6.0-alpha.40) (2026-03-19) ### Bug Fixes * Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10238](https://github.com/parse-community/parse-server/issues/10238)) ([fbda4cb](https://github.com/parse-community/parse-server/commit/fbda4cb0c5cbc8fad08a216823b6b64d4ae289c3)) --- 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 98c205d5a2..8b7e985e2d 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.40](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.39...9.6.0-alpha.40) (2026-03-19) + + +### Bug Fixes + +* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10238](https://github.com/parse-community/parse-server/issues/10238)) ([fbda4cb](https://github.com/parse-community/parse-server/commit/fbda4cb0c5cbc8fad08a216823b6b64d4ae289c3)) + # [9.6.0-alpha.39](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.38...9.6.0-alpha.39) (2026-03-18) diff --git a/package-lock.json b/package-lock.json index 7ceef328ee..71554f27b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.39", + "version": "9.6.0-alpha.40", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.39", + "version": "9.6.0-alpha.40", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 7254b16611..e97f7e2ba3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.39", + "version": "9.6.0-alpha.40", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 98f4ba5bcf2c199bfe6225f672e8edcd08ba732d Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:55:08 +0000 Subject: [PATCH 119/156] fix: Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) (#10246) --- DEPRECATIONS.md | 1 + spec/vulnerabilities.spec.js | 127 +++++++++++++++++++++++++++++++++ src/Deprecator/Deprecations.js | 5 ++ src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- src/RestWrite.js | 7 +- 7 files changed, 140 insertions(+), 6 deletions(-) diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index cd001e7162..b1593c224a 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -23,6 +23,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS17 | Remove config option `playgroundPath` | [#10110](https://github.com/parse-community/parse-server/issues/10110) | 9.5.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | | DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | +| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_change]: ## "The version and date of the planned change." diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 5b82e2bb9c..f2bd2c3bdc 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3221,4 +3221,131 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t obj.save({ publicField: 'changed' }, { useMasterKey: true }), ]); }); + + describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => { + let validatorSpy; + + const testAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + beforeEach(async () => { + validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { testAdapter }, + allowExpiredAuthDataToken: true, + }); + }); + + it('validates authData on login when incoming data is a strict subset of stored data', async () => { + // Sign up a user with full authData (id + access_token) + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); + + // Attempt to log in with only the id field (subset of stored data) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user123' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // The adapter MUST be called to validate the login attempt + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => { + // Sign up a user with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } }, + }); + validatorSpy.calls.reset(); + + // Simulate an attacker sending only the provider ID (no access_token) + // The adapter should reject this because the token is missing + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials') + ); + + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'victim123' } }, + }), + }).catch(e => e); + + // Login must be rejected — adapter validation must not be skipped + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('validates authData on login even when authData is identical', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + }); + validatorSpy.calls.reset(); + + // Log in with the exact same authData (all keys present, same values) + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } }, + }), + }); + expect(res.data.objectId).toBe(user.id); + // Auth providers are always validated on login regardless of allowExpiredAuthDataToken + expect(validatorSpy).toHaveBeenCalled(); + }); + + it('skips validation on update when authData is a subset of stored data', async () => { + // Sign up with full authData + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user789', access_token: 'valid_token' } }, + }); + validatorSpy.calls.reset(); + + // Update the user with a subset of authData (simulates afterFind stripping fields) + await request({ + method: 'PUT', + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user789' } }, + }), + }); + // On update with allowExpiredAuthDataToken: true, subset data skips validation + expect(validatorSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 18baa00927..dff88c78bb 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -76,4 +76,9 @@ module.exports = [ changeNewKey: '', solution: "The product purchase API is an undocumented, unmaintained legacy feature that may not function as expected and will be removed in a future major version. We strongly advise against using it. Set 'enableProductPurchaseLegacyApi' to 'false' to disable it, or remove the option to accept the future removal.", }, + { + optionKey: 'allowExpiredAuthDataToken', + changeNewKey: '', + solution: "Auth providers are always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.", + }, ]; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index af9279d712..26c50a5d3f 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -72,7 +72,7 @@ module.exports.ParseServerOptions = { }, allowExpiredAuthDataToken: { env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN', - help: 'Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`.', + help: 'Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.', action: parsers.booleanParser, default: false, }, diff --git a/src/Options/docs.js b/src/Options/docs.js index 20d74fb770..082f9cba38 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -15,7 +15,7 @@ * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.

Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL. * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId - * @property {Boolean} allowExpiredAuthDataToken Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`. + * @property {Boolean} allowExpiredAuthDataToken Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`. * @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers * @property {String|String[]} allowOrigin Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. * @property {Adapter} analyticsAdapter Adapter module for the analytics diff --git a/src/Options/index.js b/src/Options/index.js index 459494cdab..8707d4c87a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -379,7 +379,7 @@ export interface ParseServerOptions { /* Set to true if new users should be created without public read and write access. :DEFAULT: true */ enforcePrivateUsers: ?boolean; - /* Allow a user to log in even if the 3rd party authentication token that was used to sign in to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `false`. + /* Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`. :DEFAULT: false */ allowExpiredAuthDataToken: ?boolean; /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. diff --git a/src/RestWrite.js b/src/RestWrite.js index e986a35c67..80b233ec59 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -639,9 +639,10 @@ RestWrite.prototype.handleAuthData = async function (authData) { return; } - // Force to validate all provided authData on login - // on update only validate mutated ones - if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { + // Always validate all provided authData on login to prevent authentication + // bypass via partial authData (e.g. sending only the provider ID without + // an access token); on update only validate mutated ones + if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { const res = await Auth.handleAuthDataValidation( isLogin ? authData : mutatedAuthData, this, From b50e14e23d4253eb752a4704cc9dd5cecbf079cc Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 19 Mar 2026 18:56:14 +0000 Subject: [PATCH 120/156] chore(release): 9.6.0-alpha.41 [skip ci] # [9.6.0-alpha.41](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.40...9.6.0-alpha.41) (2026-03-19) ### Bug Fixes * Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10246](https://github.com/parse-community/parse-server/issues/10246)) ([98f4ba5](https://github.com/parse-community/parse-server/commit/98f4ba5bcf2c199bfe6225f672e8edcd08ba732d)) --- 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 8b7e985e2d..4e9cc14a73 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.41](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.40...9.6.0-alpha.41) (2026-03-19) + + +### Bug Fixes + +* Auth provider validation bypass on login via partial authData ([GHSA-pfj7-wv7c-22pr](https://github.com/parse-community/parse-server/security/advisories/GHSA-pfj7-wv7c-22pr)) ([#10246](https://github.com/parse-community/parse-server/issues/10246)) ([98f4ba5](https://github.com/parse-community/parse-server/commit/98f4ba5bcf2c199bfe6225f672e8edcd08ba732d)) + # [9.6.0-alpha.40](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.39...9.6.0-alpha.40) (2026-03-19) diff --git a/package-lock.json b/package-lock.json index 71554f27b1..a6956b64cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.40", + "version": "9.6.0-alpha.41", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.40", + "version": "9.6.0-alpha.41", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e97f7e2ba3..3fed7a23db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.40", + "version": "9.6.0-alpha.41", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 6c3317aca6eb618ac48f999021ae3ef7766ad1ea Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:17:14 +0000 Subject: [PATCH 121/156] fix: LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) (#10250) --- spec/vulnerabilities.spec.js | 301 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 74 ++++++- 2 files changed, 372 insertions(+), 3 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index f2bd2c3bdc..aede1a4f7b 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3349,3 +3349,304 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t }); }); }); + +describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforcement', () => { + const { sleep } = require('../lib/TestUtils'); + + beforeEach(() => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + }); + + afterEach(async () => { + try { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + } catch (e) { + // Ignore cleanup errors when client is not initialized + } + }); + + async function updateCLP(className, permissions) { + const response = await fetch(Parse.serverURL + '/schemas/' + className, { + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions: permissions }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + return body; + } + + it('should not deliver LiveQuery events to user not in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // Create users using master key to avoid session management issues + const userA = new Parse.User(); + userA.setUsername('userA_pointer'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_pointer'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with owner pointer, then set CLP + const seed = new Parse.Object('PrivateMessage'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User B subscribes — should NOT receive events for User A's objects + const query = new Parse.Query('PrivateMessage'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + const enterSpy = jasmine.createSpy('enter'); + subscription.on('create', createSpy); + subscription.on('enter', enterSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage'); + msg.set('content', 'secret message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT have received the create event + expect(createSpy).not.toHaveBeenCalled(); + expect(enterSpy).not.toHaveBeenCalled(); + }); + + it('should deliver LiveQuery events to user in readUserFields pointer', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateMessage2'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + // User A stays logged in for the subscription + const userA = new Parse.User(); + userA.setUsername('userA_owner'); + userA.setPassword('password123'); + await userA.signUp(); + + // Create schema by saving an object with owner pointer + const seed = new Parse.Object('PrivateMessage2'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('PrivateMessage2', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // User A subscribes — SHOULD receive events for their own objects + const query = new Parse.Query('PrivateMessage2'); + const subscription = await query.subscribe(userA.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create a message owned by User A + const msg = new Parse.Object('PrivateMessage2'); + msg.set('content', 'my own message'); + msg.set('owner', userA); + await msg.save(null, { useMasterKey: true }); + + await sleep(500); + + // User A SHOULD have received the create event + expect(createSpy).toHaveBeenCalledTimes(1); + }); + + it('should not deliver LiveQuery events when find uses pointerFields', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['PrivateDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_doc'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B stays logged in for the subscription + const userB = new Parse.User(); + userB.setUsername('userB_doc'); + userB.setPassword('password456'); + await userB.signUp(); + + // Create schema by saving an object with recipient pointer + const seed = new Parse.Object('PrivateDoc'); + seed.set('recipient', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + // Set CLP with pointerFields instead of readUserFields + await updateCLP('PrivateDoc', { + create: { '*': true }, + find: { pointerFields: ['recipient'] }, + get: { pointerFields: ['recipient'] }, + }); + + // User B subscribes + const query = new Parse.Query('PrivateDoc'); + const subscription = await query.subscribe(userB.getSessionToken()); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + // Create doc with recipient = User A (not User B) + const doc = new Parse.Object('PrivateDoc'); + doc.set('title', 'confidential'); + doc.set('recipient', userA); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B should NOT receive events for User A's document + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should not deliver LiveQuery events to unauthenticated users for pointer-protected classes', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SecureItem'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_secure'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // Create schema + const seed = new Parse.Object('SecureItem'); + seed.set('owner', userA); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SecureItem', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['owner'], + }); + + // Unauthenticated subscription + const query = new Parse.Query('SecureItem'); + const subscription = await query.subscribe(); + + const createSpy = jasmine.createSpy('create'); + subscription.on('create', createSpy); + + const item = new Parse.Object('SecureItem'); + item.set('data', 'private'); + item.set('owner', userA); + await item.save(null, { useMasterKey: true }); + + await sleep(500); + + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('should handle readUserFields with array of pointers', async () => { + await reconfigureServer({ + liveQuery: { classNames: ['SharedDoc'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const userA = new Parse.User(); + userA.setUsername('userA_shared'); + userA.setPassword('password123'); + await userA.signUp(); + await Parse.User.logOut(); + + // User B — don't log out, session must remain valid + const userB = new Parse.User(); + userB.setUsername('userB_shared'); + userB.setPassword('password456'); + await userB.signUp(); + const userBSessionToken = userB.getSessionToken(); + + // User C — signUp changes current user to C, but B's session stays valid + const userC = new Parse.User(); + userC.setUsername('userC_shared'); + userC.setPassword('password789'); + await userC.signUp(); + const userCSessionToken = userC.getSessionToken(); + + // Create schema with array field + const seed = new Parse.Object('SharedDoc'); + seed.set('collaborators', [userA]); + await seed.save(null, { useMasterKey: true }); + await seed.destroy({ useMasterKey: true }); + + await updateCLP('SharedDoc', { + create: { '*': true }, + find: {}, + get: {}, + readUserFields: ['collaborators'], + }); + + // User B subscribes — is in the collaborators array + const queryB = new Parse.Query('SharedDoc'); + const subscriptionB = await queryB.subscribe(userBSessionToken); + const createSpyB = jasmine.createSpy('createB'); + subscriptionB.on('create', createSpyB); + + // User C subscribes — is NOT in the collaborators array + const queryC = new Parse.Query('SharedDoc'); + const subscriptionC = await queryC.subscribe(userCSessionToken); + const createSpyC = jasmine.createSpy('createC'); + subscriptionC.on('create', createSpyC); + + // Create doc with collaborators = [userA, userB] (not userC) + const doc = new Parse.Object('SharedDoc'); + doc.set('title', 'team doc'); + doc.set('collaborators', [userA, userB]); + await doc.save(null, { useMasterKey: true }); + + await sleep(500); + + // User B SHOULD receive the event (in collaborators array) + expect(createSpyB).toHaveBeenCalledTimes(1); + // User C should NOT receive the event + expect(createSpyC).not.toHaveBeenCalled(); + }); +}); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index c530db4641..a0fba2184f 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -211,13 +211,16 @@ class ParseLiveQueryServer { const op = this._getCLPOperation(subscription.query); let res: any = {}; try { - await this._matchesCLP( + const matchesCLP = await this._matchesCLP( classLevelPermissions, message.currentParseObject, client, requestId, op ); + if (matchesCLP === false) { + return null; + } const isMatched = await this._matchesACL(acl, client, requestId); if (!isMatched) { return null; @@ -339,13 +342,16 @@ class ParseLiveQueryServer { } try { const op = this._getCLPOperation(subscription.query); - await this._matchesCLP( + const matchesCLP = await this._matchesCLP( classLevelPermissions, message.currentParseObject, client, requestId, op ); + if (matchesCLP === false) { + return; + } const [isOriginalMatched, isCurrentMatched] = await Promise.all([ originalACLCheckingPromise, currentACLCheckingPromise, @@ -659,8 +665,10 @@ class ParseLiveQueryServer { ): Promise { const subscriptionInfo = client.getSubscriptionInfo(requestId); const aclGroup = ['*']; + let userId; if (typeof subscriptionInfo !== 'undefined') { - const { userId } = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); + const result = await this.getAuthForSessionToken(subscriptionInfo.sessionToken); + userId = result.userId; if (userId) { aclGroup.push(userId); } @@ -671,6 +679,66 @@ class ParseLiveQueryServer { aclGroup, op ); + // Enforce pointer permissions that validatePermission defers. + // Returns false to silently skip the event (like ACL), rather than + // throwing which would push errors to the client and log noise. + if (!client.hasMasterKey && classLevelPermissions) { + const permissionField = + ['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields'; + const pointerFields = []; + if (classLevelPermissions[op]?.pointerFields) { + pointerFields.push(...classLevelPermissions[op].pointerFields); + } + if (Array.isArray(classLevelPermissions[permissionField])) { + for (const field of classLevelPermissions[permissionField]) { + if (!pointerFields.includes(field)) { + pointerFields.push(field); + } + } + } + if (pointerFields.length > 0) { + // If public or user-specific permission already grants access, skip pointer check + if ( + !SchemaController.testPermissions(classLevelPermissions, aclGroup, op) + ) { + if (!userId) { + return false; + } + // Check if any pointer field points to the current user + const hasAccess = pointerFields.some(field => { + const value = + typeof object.get === 'function' ? object.get(field) : object[field]; + if (!value) { + return false; + } + // Handle Parse.Object pointer (has .id) + if (value.id) { + return value.id === userId; + } + // Handle raw pointer JSON (has .objectId) + if (value.objectId) { + return value.objectId === userId; + } + // Handle array of pointers + if (Array.isArray(value)) { + return value.some(item => { + if (item.id) { + return item.id === userId; + } + if (item.objectId) { + return item.objectId === userId; + } + return false; + }); + } + return false; + }); + if (!hasAccess) { + return false; + } + } + } + } } async _filterSensitiveData( From 5fa75432274bde9b397ca51919697280c59a4004 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 20 Mar 2026 02:18:15 +0000 Subject: [PATCH 122/156] chore(release): 9.6.0-alpha.42 [skip ci] # [9.6.0-alpha.42](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.41...9.6.0-alpha.42) (2026-03-20) ### Bug Fixes * LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10250](https://github.com/parse-community/parse-server/issues/10250)) ([6c3317a](https://github.com/parse-community/parse-server/commit/6c3317aca6eb618ac48f999021ae3ef7766ad1ea)) --- 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 4e9cc14a73..ee5183e285 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.42](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.41...9.6.0-alpha.42) (2026-03-20) + + +### Bug Fixes + +* LiveQuery bypasses CLP pointer permission enforcement ([GHSA-fph2-r4qg-9576](https://github.com/parse-community/parse-server/security/advisories/GHSA-fph2-r4qg-9576)) ([#10250](https://github.com/parse-community/parse-server/issues/10250)) ([6c3317a](https://github.com/parse-community/parse-server/commit/6c3317aca6eb618ac48f999021ae3ef7766ad1ea)) + # [9.6.0-alpha.41](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.40...9.6.0-alpha.41) (2026-03-19) diff --git a/package-lock.json b/package-lock.json index a6956b64cc..e68fd07425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.41", + "version": "9.6.0-alpha.42", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.41", + "version": "9.6.0-alpha.42", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 3fed7a23db..a46398a9b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.41", + "version": "9.6.0-alpha.42", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 0c0a0a5a37ca821d2553119f2cb3be35322eda4b Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 04:05:34 +0000 Subject: [PATCH 123/156] fix: Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) (#10253) --- spec/vulnerabilities.spec.js | 101 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 13 +++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index aede1a4f7b..e51cdad7ff 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3650,3 +3650,104 @@ describe('(GHSA-fph2-r4qg-9576) LiveQuery bypasses CLP pointer permission enforc expect(createSpyC).not.toHaveBeenCalled(); }); }); + +describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => { + const { sleep } = require('../lib/TestUtils'); + let obj; + + beforeEach(async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['SecretClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const config = Config.get(Parse.applicationId); + const schemaController = await config.database.loadSchema(); + await schemaController.addClassIfNotExists('SecretClass', { + secretObj: { type: 'Object' }, + publicField: { type: 'String' }, + }); + await schemaController.updateClass( + 'SecretClass', + {}, + { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + protectedFields: { '*': ['secretObj'] }, + } + ); + + obj = new Parse.Object('SecretClass'); + obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 }); + obj.set('publicField', 'visible'); + await obj.save(null, { useMasterKey: true }); + }); + + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.apiKey'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should reject LiveQuery subscription with deeply nested dot-notation on protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('secretObj.nested.deep.key'); + await expectAsync(query.subscribe()).toBeRejectedWith( + new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied') + ); + }); + + it('should allow LiveQuery subscription with non-protected field in watch', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + await Promise.all([ + new Promise(resolve => { + subscription.on('update', object => { + expect(object.get('secretObj')).toBeUndefined(); + expect(object.get('publicField')).toBe('updated'); + resolve(); + }); + }), + obj.save({ publicField: 'updated' }, { useMasterKey: true }), + ]); + }); + + it('should not deliver update event when only non-watched field changes', async () => { + const query = new Parse.Query('SecretClass'); + query.watch('publicField'); + const subscription = await query.subscribe(); + const updateSpy = jasmine.createSpy('update'); + subscription.on('update', updateSpy); + + // Change a field that is NOT in the watch list + obj.set('secretObj', { apiKey: 'ROTATED_KEY', score: 99 }); + await obj.save(null, { useMasterKey: true }); + await sleep(500); + expect(updateSpy).not.toHaveBeenCalled(); + }); + +}); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index a0fba2184f..60d3839063 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -1050,7 +1050,7 @@ class ParseLiveQueryServer { op ); - // Check protected fields in WHERE clause + // Check protected fields in WHERE clause and WATCH parameter if (!client.hasMasterKey) { const auth = request.user ? { user: request.user, userRoles: [] } : {}; const protectedFields = @@ -1083,6 +1083,17 @@ class ParseLiveQueryServer { }; checkWhere(request.query.where); } + if (protectedFields.length > 0 && Array.isArray(request.query.watch)) { + for (const watchField of request.query.watch) { + const rootField = watchField.split('.')[0]; + if (protectedFields.includes(watchField) || protectedFields.includes(rootField)) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Permission denied' + ); + } + } + } } // Validate regex patterns in the subscription query From a9511a7e74472178a2082b37d6e765ee4fccb8de Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 20 Mar 2026 04:06:42 +0000 Subject: [PATCH 124/156] chore(release): 9.6.0-alpha.43 [skip ci] # [9.6.0-alpha.43](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.42...9.6.0-alpha.43) (2026-03-20) ### Bug Fixes * Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10253](https://github.com/parse-community/parse-server/issues/10253)) ([0c0a0a5](https://github.com/parse-community/parse-server/commit/0c0a0a5a37ca821d2553119f2cb3be35322eda4b)) --- 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 ee5183e285..53ff8bb510 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.43](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.42...9.6.0-alpha.43) (2026-03-20) + + +### Bug Fixes + +* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10253](https://github.com/parse-community/parse-server/issues/10253)) ([0c0a0a5](https://github.com/parse-community/parse-server/commit/0c0a0a5a37ca821d2553119f2cb3be35322eda4b)) + # [9.6.0-alpha.42](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.41...9.6.0-alpha.42) (2026-03-20) diff --git a/package-lock.json b/package-lock.json index e68fd07425..4ae59396be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.42", + "version": "9.6.0-alpha.43", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.42", + "version": "9.6.0-alpha.43", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index a46398a9b4..ffd9479e50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.42", + "version": "9.6.0-alpha.43", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 683e2ae204d288cf9c2c1d64cbd12cc76f28048e Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:39:57 +0000 Subject: [PATCH 125/156] test: Webhook registration requires master key (#10255) --- spec/vulnerabilities.spec.js | 93 ++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index e51cdad7ff..126491b09b 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3750,4 +3750,97 @@ describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via Live expect(updateSpy).not.toHaveBeenCalled(); }); + describe('(GHSA-8pjv-59c8-44p8) SSRF via Webhook URL requires master key', () => { + const expectMasterKeyRequired = async promise => { + try { + await promise; + fail('Expected request to be rejected'); + } catch (error) { + expect(error.status).toBe(403); + } + }; + + it('rejects registering a webhook function with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', + }), + }) + ); + }); + + it('rejects updating a webhook function URL to internal address without master key', async () => { + // Seed a legitimate webhook first so the PUT hits auth, not "not found" + await request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'https://example.com/webhook', + }), + }); + await expectMasterKeyRequired( + request({ + method: 'PUT', + url: Parse.serverURL + '/hooks/functions/ssrf_probe', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + url: 'http://169.254.169.254/latest/meta-data/', + }), + }) + ); + }); + + it('rejects registering a webhook trigger with internal URL without master key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/triggers', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + className: 'TestClass', + triggerName: 'beforeSave', + url: 'http://127.0.0.1:8080/admin/status', + }), + }) + ); + }); + + it('rejects registering a webhook with internal URL using JavaScript key', async () => { + await expectMasterKeyRequired( + request({ + method: 'POST', + url: Parse.serverURL + '/hooks/functions', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-JavaScript-Key': 'test', + }, + body: JSON.stringify({ + functionName: 'ssrf_probe', + url: 'http://10.0.0.1:3000/internal-api', + }), + }) + ); + }); + }); + }); From 85994eff9e7b34cac7e1a2f5791985022a1461d1 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:43:50 +0000 Subject: [PATCH 126/156] fix: Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) (#10257) --- spec/vulnerabilities.spec.js | 84 ++++++++++++++++++++++++++++++++++++ src/RestQuery.js | 33 ++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 126491b09b..74463ea9e4 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2833,6 +2833,90 @@ describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested q }) ); }); + + it('rejects deeply nested query before transform pipeline processes it', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + // Depth 50 bypasses the fix because RestQuery.js transform pipeline + // recursively traverses the structure before validateQuery() is reached + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('rejects deeply nested query via REST API without authentication', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $or: [where] }; + } + await expectAsync( + request({ + method: 'GET', + url: `${Parse.serverURL}/classes/_User`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + qs: { where: JSON.stringify(where) }, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + data: jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + }), + }) + ); + }); + + it('rejects deeply nested $nor query before transform pipeline', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + await expectAsync( + rest.find(config, auth.nobody(config), '_User', where) + ).toBeRejectedWith( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('allows queries within the depth limit', async () => { + await reconfigureServer({ + requestComplexity: { queryDepth: 10 }, + }); + const auth = require('../lib/Auth'); + const rest = require('../lib/rest'); + const config = Config.get('test'); + let where = { username: 'test' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + const result = await rest.find(config, auth.nobody(config), '_User', where); + expect(result.results).toBeDefined(); + }); }); describe('(GHSA-fjxm-vhvc-gcmj) LiveQuery Operator Type Confusion', () => { diff --git a/src/RestQuery.js b/src/RestQuery.js index f6cdc54886..29efa2caa1 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -281,6 +281,9 @@ function _UnsafeRestQuery( // TODO: consolidate the replaceX functions _UnsafeRestQuery.prototype.execute = function (executeOptions) { return Promise.resolve() + .then(() => { + return this.validateQueryDepth(); + }) .then(() => { return this.buildRestWhere(); }) @@ -352,6 +355,36 @@ _UnsafeRestQuery.prototype.each = function (callback) { ); }; +_UnsafeRestQuery.prototype.validateQueryDepth = function () { + if (this.auth.isMaster || this.auth.isMaintenance) { + return; + } + const rc = this.config.requestComplexity; + if (!rc || rc.queryDepth === -1) { + return; + } + const maxDepth = rc.queryDepth; + const checkDepth = (where, depth) => { + if (depth > maxDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` + ); + } + if (typeof where !== 'object' || where === null) { + return; + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + for (const subQuery of where[op]) { + checkDepth(subQuery, depth + 1); + } + } + } + }; + checkDepth(this.restWhere, 0); +}; + _UnsafeRestQuery.prototype.buildRestWhere = function () { return Promise.resolve() .then(() => { From 6eebd6e603de3ab2a8049930e0c54ce4fb145731 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 20 Mar 2026 17:44:50 +0000 Subject: [PATCH 127/156] chore(release): 9.6.0-alpha.44 [skip ci] # [9.6.0-alpha.44](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.43...9.6.0-alpha.44) (2026-03-20) ### Bug Fixes * Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10257](https://github.com/parse-community/parse-server/issues/10257)) ([85994ef](https://github.com/parse-community/parse-server/commit/85994eff9e7b34cac7e1a2f5791985022a1461d1)) --- 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 53ff8bb510..b81db4362c 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.44](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.43...9.6.0-alpha.44) (2026-03-20) + + +### Bug Fixes + +* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10257](https://github.com/parse-community/parse-server/issues/10257)) ([85994ef](https://github.com/parse-community/parse-server/commit/85994eff9e7b34cac7e1a2f5791985022a1461d1)) + # [9.6.0-alpha.43](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.42...9.6.0-alpha.43) (2026-03-20) diff --git a/package-lock.json b/package-lock.json index 4ae59396be..442d07730b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.43", + "version": "9.6.0-alpha.44", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.43", + "version": "9.6.0-alpha.44", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index ffd9479e50..5ab2220dda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.43", + "version": "9.6.0-alpha.44", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 2126fe4e12f9b399dc6b4b6a3fa70cb1825f159b Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:22:04 +0000 Subject: [PATCH 128/156] fix: LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) (#10259) --- spec/vulnerabilities.spec.js | 116 ++++++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.ts | 29 ++++++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 74463ea9e4..dc73888a67 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3928,3 +3928,119 @@ describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via Live }); }); + +describe('(GHSA-6qh5-m6g3-xhq6) LiveQuery query depth DoS via deeply nested subscription', () => { + afterEach(async () => { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + }); + + it('should reject LiveQuery subscription with deeply nested $or when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should reject LiveQuery subscription with deeply nested $and when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $and: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should reject LiveQuery subscription with deeply nested $nor when queryDepth is set', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 50; i++) { + where = { $nor: [where] }; + } + query._where = where; + await expectAsync(query.subscribe()).toBeRejectedWith( + jasmine.objectContaining({ + code: Parse.Error.INVALID_QUERY, + message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/), + }) + ); + }); + + it('should allow LiveQuery subscription within the depth limit', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: 10 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 5; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); + }); + + it('should allow LiveQuery subscription when queryDepth is disabled', async () => { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + await reconfigureServer({ + liveQuery: { classNames: ['TestClass'] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + requestComplexity: { queryDepth: -1 }, + }); + const query = new Parse.Query('TestClass'); + let where = { field: 'value' }; + for (let i = 0; i < 15; i++) { + where = { $or: [where] }; + } + query._where = where; + const subscription = await query.subscribe(); + expect(subscription).toBeDefined(); + }); +}); diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 60d3839063..e5e5e31871 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -1023,8 +1023,35 @@ class ParseLiveQueryServer { return; } } - // Check CLP for subscribe operation + // Validate query condition depth const appConfig = Config.get(this.config.appId); + if (!client.hasMasterKey) { + const rc = appConfig.requestComplexity; + if (rc && rc.queryDepth !== -1) { + const maxDepth = rc.queryDepth; + const checkDepth = (where: any, depth: number) => { + if (depth > maxDepth) { + throw new Parse.Error( + Parse.Error.INVALID_QUERY, + `Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}` + ); + } + if (typeof where !== 'object' || where === null) { + return; + } + for (const op of ['$or', '$and', '$nor']) { + if (Array.isArray(where[op])) { + for (const subQuery of where[op]) { + checkDepth(subQuery, depth + 1); + } + } + } + }; + checkDepth(request.query.where, 0); + } + } + + // Check CLP for subscribe operation const schemaController = await appConfig.database.loadSchema(); const classLevelPermissions = schemaController.getClassLevelPermissions(className); const op = this._getCLPOperation(request.query); From 12fdbd617fd2bd936c6be4ad7c429deb77c0cf16 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 20 Mar 2026 19:23:29 +0000 Subject: [PATCH 129/156] chore(release): 9.6.0-alpha.45 [skip ci] # [9.6.0-alpha.45](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.44...9.6.0-alpha.45) (2026-03-20) ### Bug Fixes * LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10259](https://github.com/parse-community/parse-server/issues/10259)) ([2126fe4](https://github.com/parse-community/parse-server/commit/2126fe4e12f9b399dc6b4b6a3fa70cb1825f159b)) --- 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 b81db4362c..ec8f704150 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.45](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.44...9.6.0-alpha.45) (2026-03-20) + + +### Bug Fixes + +* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10259](https://github.com/parse-community/parse-server/issues/10259)) ([2126fe4](https://github.com/parse-community/parse-server/commit/2126fe4e12f9b399dc6b4b6a3fa70cb1825f159b)) + # [9.6.0-alpha.44](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.43...9.6.0-alpha.44) (2026-03-20) diff --git a/package-lock.json b/package-lock.json index 442d07730b..cfc9e4d258 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.44", + "version": "9.6.0-alpha.45", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.44", + "version": "9.6.0-alpha.45", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 5ab2220dda..fe2881d3df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.44", + "version": "9.6.0-alpha.45", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From a6928737dd40a3310f6e419f223cf93fdd442f2b Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:33:40 +0000 Subject: [PATCH 130/156] fix: Incomplete JSON key escaping in PostgreSQL Increment on nested Object fields (#10261) --- spec/PostgresStorageAdapter.spec.js | 210 ++++++++++++++++++ spec/vulnerabilities.spec.js | 27 +-- .../Postgres/PostgresStorageAdapter.js | 6 +- 3 files changed, 226 insertions(+), 17 deletions(-) diff --git a/spec/PostgresStorageAdapter.spec.js b/spec/PostgresStorageAdapter.spec.js index aa5e692fe4..9c0c5c49ce 100644 --- a/spec/PostgresStorageAdapter.spec.js +++ b/spec/PostgresStorageAdapter.spec.js @@ -589,3 +589,213 @@ describe_only_db('postgres')('PostgresStorageAdapter shutdown', () => { expect(adapter._client.$pool.ending).toEqual(true); }); }); + +describe_only_db('postgres')('PostgresStorageAdapter Increment JSON key escaping', () => { + const request = require('../lib/request'); + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('does not inject additional JSONB keys via double-quote in sub-key name', async () => { + const obj = new Parse.Object('IncrementTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(); + + // Advisory payload: sub-key `":0,"isAdmin` produces JSON `{"":0,"isAdmin":amount}` + // which would inject/overwrite the `isAdmin` key via JSONB `||` merge + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementTest').get(obj.id); + // isAdmin must NOT have been changed by the injection + expect(verify.get('metadata').isAdmin).toBe(0); + // score must remain unchanged + expect(verify.get('metadata').score).toBe(100); + // No spurious empty-string key should exist + expect(verify.get('metadata')['']).toBeUndefined(); + }); + + it('does not overwrite existing JSONB keys via crafted sub-key injection', async () => { + const obj = new Parse.Object('IncrementTest'); + obj.set('metadata', { balance: 500 }); + await obj.save(); + + // Attempt to overwrite `balance` with 0 via injection, then set injected key to amount + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.":0,"balance': { __op: 'Increment', amount: 0 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementTest').get(obj.id); + // balance must NOT have been overwritten + expect(verify.get('metadata').balance).toBe(500); + }); + + it('does not escalate write access beyond what CLP already grants', async () => { + // A user with write CLP can already overwrite any sub-key of an Object field + // directly, so the JSON key injection does not grant additional capabilities. + const schema = new Parse.Schema('IncrementCLPTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: {}, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementCLPTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(); + + // A user with write CLP can already directly overwrite any sub-key + const directResponse = await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementCLPTest/${obj.id}`, + headers, + body: JSON.stringify({ + 'metadata.isAdmin': { __op: 'Increment', amount: 1 }, + }), + }); + expect(directResponse.status).toBe(200); + + const afterDirect = await new Parse.Query('IncrementCLPTest').get(obj.id); + // Direct Increment already overwrites the key — no injection needed + expect(afterDirect.get('metadata').isAdmin).toBe(1); + }); + + it('does not bypass protectedFields — injection has same access as direct write', async () => { + const user = await Parse.User.signUp('protuser', 'password123'); + + const schema = new Parse.Schema('IncrementProtectedTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + addField: {}, + protectedFields: { '*': ['metadata'] }, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementProtectedTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(null, { useMasterKey: true }); + + // Injection attempt on a protected field + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + // Direct write to same protected field + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementProtectedTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.isAdmin': { __op: 'Increment', amount: 1 }, + }), + }); + + // Both succeed — protectedFields controls read access, not write access. + // The injection has the same access as a direct write. + const verify = await new Parse.Query('IncrementProtectedTest').get(obj.id, { useMasterKey: true }); + + // Direct write succeeded (protectedFields doesn't block writes) + expect(verify.get('metadata').isAdmin).toBeGreaterThanOrEqual(1); + + // Verify the field is indeed read-protected for the user + const userResult = await new Parse.Query('IncrementProtectedTest').get(obj.id, { sessionToken: user.getSessionToken() }); + expect(userResult.get('metadata')).toBeUndefined(); + }); + + it('rejects injection when user lacks write CLP', async () => { + const user = await Parse.User.signUp('testuser', 'password123'); + + const schema = new Parse.Schema('IncrementNoCLPTest'); + schema.addObject('metadata'); + schema.setCLP({ + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: {}, + addField: {}, + }); + await schema.save(); + + const obj = new Parse.Object('IncrementNoCLPTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + await obj.save(null, { useMasterKey: true }); + + // Without write CLP, the injection attempt is rejected + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementNoCLPTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': user.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementNoCLPTest').get(obj.id); + // isAdmin unchanged — CLP blocked the write + expect(verify.get('metadata').isAdmin).toBe(0); + }); + + it('rejects injection when user lacks write access via ACL', async () => { + const owner = await Parse.User.signUp('owner', 'password123'); + const attacker = await Parse.User.signUp('attacker', 'password456'); + + const obj = new Parse.Object('IncrementACLTest'); + obj.set('metadata', { score: 100, isAdmin: 0 }); + const acl = new Parse.ACL(owner); + acl.setPublicReadAccess(true); + obj.setACL(acl); + await obj.save(null, { useMasterKey: true }); + + // Attacker has public read but not write — injection attempt should fail + await request({ + method: 'PUT', + url: `http://localhost:8378/1/classes/IncrementACLTest/${obj.id}`, + headers: { + ...headers, + 'X-Parse-Session-Token': attacker.getSessionToken(), + }, + body: JSON.stringify({ + 'metadata.":0,"isAdmin': { __op: 'Increment', amount: 1 }, + }), + }).catch(() => {}); + + const verify = await new Parse.Query('IncrementACLTest').get(obj.id); + // isAdmin unchanged — ACL blocked the write + expect(verify.get('metadata').isAdmin).toBe(0); + }); +}); diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index dc73888a67..6c734f308b 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -1615,25 +1615,23 @@ describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in I }).catch(() => {}); const elapsed = Date.now() - start; - // Double quotes break JSON structure inside the CONCAT, producing invalid JSONB. - // This causes a database error, NOT SQL injection. If injection succeeded, - // the query would take >= 3 seconds due to pg_sleep. + // Double quotes are escaped in the JSON context, producing a harmless literal key + // name. No SQL injection occurs. If injection succeeded, the query would take + // >= 3 seconds due to pg_sleep. expect(elapsed).toBeLessThan(3000); - // Invalid JSONB cast fails the UPDATE, so the row is not modified const verify = await new Parse.Query('SubKeyTest').get(obj.id); - expect(verify.get('stats')).toEqual({ counter: 0 }); + // Original counter is untouched + expect(verify.get('stats').counter).toBe(0); }); - it_only_db('postgres')('does not execute injected SQL via double quote crafted as valid JSONB in sub-key name', async () => { + it_only_db('postgres')('does not inject additional JSONB keys via double quote crafted as valid JSONB in sub-key name', async () => { const obj = new Parse.Object('SubKeyTest'); obj.set('stats', { counter: 0 }); await obj.save(); - // This payload uses double quotes to craft a sub-key that produces valid JSONB - // (e.g. '{"x":0,"evil":1}') instead of breaking JSON structure. Even so, both - // interpolation sites are inside single-quoted SQL strings, so double quotes - // cannot escape the SQL context — no arbitrary SQL execution is possible. - const start = Date.now(); + // This payload attempts to craft a sub-key that produces valid JSONB with + // injected keys (e.g. '{"x":0,"evil":1}'). Double quotes are escaped in the + // JSON context, so the payload becomes a harmless literal key name instead. await request({ method: 'PUT', url: `http://localhost:8378/1/classes/SubKeyTest/${obj.id}`, @@ -1642,13 +1640,12 @@ describe('(GHSA-gqpp-xgvh-9h7h) SQL Injection via dot-notation sub-key name in I 'stats.x":0,"pg_sleep(3)': { __op: 'Increment', amount: 1 }, }), }).catch(() => {}); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(3000); - // Double quotes craft valid JSONB with extra keys, but no SQL injection occurs; - // original counter is untouched const verify = await new Parse.Query('SubKeyTest').get(obj.id); + // Original counter is untouched expect(verify.get('stats').counter).toBe(0); + // No injected key exists — the payload is treated as a single literal key name + expect(verify.get('stats')['pg_sleep(3)']).toBeUndefined(); }); it_only_db('postgres')('allows valid Increment on nested object field with normal sub-key', async () => { diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index cd9e51fde3..b65e7d7ed8 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -209,6 +209,7 @@ const handleDotFields = object => { }; const escapeSqlString = value => value.replace(/'/g, "''"); +const escapeJsonString = value => JSON.stringify(value).slice(1, -1); const transformDotFieldToComponents = fieldName => { return fieldName.split('.').map((cmpt, index) => { @@ -1766,8 +1767,9 @@ export class PostgresStorageAdapter implements StorageAdapter { } incrementValues.push(amount); const amountIndex = index + incrementValues.length; - const safeName = escapeSqlString(c); - return `CONCAT('{"${safeName}":', COALESCE($${index}:name->>'${safeName}','0')::int + $${amountIndex}, '}')::jsonb`; + const jsonSafeName = escapeSqlString(escapeJsonString(c)); + const sqlSafeName = escapeSqlString(c); + return `CONCAT('{"${jsonSafeName}":', COALESCE($${index}:name->>'${sqlSafeName}','0')::int + $${amountIndex}, '}')::jsonb`; }) .join(' || '); // Strip the keys From f05aca61e42967e9b00f9c8c3fb0449677abe124 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 20 Mar 2026 20:34:33 +0000 Subject: [PATCH 131/156] chore(release): 9.6.0-alpha.46 [skip ci] # [9.6.0-alpha.46](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.45...9.6.0-alpha.46) (2026-03-20) ### Bug Fixes * Incomplete JSON key escaping in PostgreSQL Increment on nested Object fields ([#10261](https://github.com/parse-community/parse-server/issues/10261)) ([a692873](https://github.com/parse-community/parse-server/commit/a6928737dd40a3310f6e419f223cf93fdd442f2b)) --- 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 ec8f704150..c157c065b2 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.46](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.45...9.6.0-alpha.46) (2026-03-20) + + +### Bug Fixes + +* Incomplete JSON key escaping in PostgreSQL Increment on nested Object fields ([#10261](https://github.com/parse-community/parse-server/issues/10261)) ([a692873](https://github.com/parse-community/parse-server/commit/a6928737dd40a3310f6e419f223cf93fdd442f2b)) + # [9.6.0-alpha.45](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.44...9.6.0-alpha.45) (2026-03-20) diff --git a/package-lock.json b/package-lock.json index cfc9e4d258..ff74d72098 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.45", + "version": "9.6.0-alpha.46", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.45", + "version": "9.6.0-alpha.46", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index fe2881d3df..a1e64d57c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.45", + "version": "9.6.0-alpha.46", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From a248e8cc99d857466aa5a5d3a472795a238acbc2 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:22:14 +0000 Subject: [PATCH 132/156] fix: Normalize HTTP method case in `allowMethodOverride` middleware (#10262) --- spec/RateLimit.spec.js | 30 ++++++++++++++++++++++++++++++ src/middlewares.js | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index d1c912e309..7de5ee35f1 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -967,6 +967,36 @@ describe('rate limit', () => { }); }); + it('should enforce rate limit when _method override uses non-standard casing', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('testuser', 'password'); + const res1 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ username: 'testuser', password: 'password' }), + }); + expect(res1.data.username).toBe('testuser'); + // Second login via POST with _method:'get' (lowercase) — should still be rate limited + const res2 = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ _method: 'get', username: 'testuser', password: 'password' }), + }).catch(e => e); + expect(res2.data).toEqual({ + code: Parse.Error.CONNECTION_FAILED, + error: 'Too many requests', + }); + }); + it('should ignore _method override with non-string type', async () => { await reconfigureServer({ rateLimit: [ diff --git a/src/middlewares.js b/src/middlewares.js index c3ad4975e5..687f3aae69 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -484,7 +484,7 @@ export function allowMethodOverride(req, res, next) { if (req.method === 'POST' && req.body?._method) { if (typeof req.body._method === 'string') { req.originalMethod = req.method; - req.method = req.body._method; + req.method = req.body._method.toUpperCase(); } delete req.body._method; } From 94637aa57a506de06429d14377ff90b3c6fa96e3 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 20 Mar 2026 21:23:12 +0000 Subject: [PATCH 133/156] chore(release): 9.6.0-alpha.47 [skip ci] # [9.6.0-alpha.47](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.46...9.6.0-alpha.47) (2026-03-20) ### Bug Fixes * Normalize HTTP method case in `allowMethodOverride` middleware ([#10262](https://github.com/parse-community/parse-server/issues/10262)) ([a248e8c](https://github.com/parse-community/parse-server/commit/a248e8cc99d857466aa5a5d3a472795a238acbc2)) --- 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 c157c065b2..0e2a445e72 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.47](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.46...9.6.0-alpha.47) (2026-03-20) + + +### Bug Fixes + +* Normalize HTTP method case in `allowMethodOverride` middleware ([#10262](https://github.com/parse-community/parse-server/issues/10262)) ([a248e8c](https://github.com/parse-community/parse-server/commit/a248e8cc99d857466aa5a5d3a472795a238acbc2)) + # [9.6.0-alpha.46](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.45...9.6.0-alpha.46) (2026-03-20) diff --git a/package-lock.json b/package-lock.json index ff74d72098..f7e526568b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.46", + "version": "9.6.0-alpha.47", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.46", + "version": "9.6.0-alpha.47", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index a1e64d57c3..385fd0b30d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.46", + "version": "9.6.0-alpha.47", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From ea68fc0b22a6056c9675149469ff57817f7cf984 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 01:37:35 +0000 Subject: [PATCH 134/156] fix: Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) (#10263) --- spec/ParseSession.spec.js | 137 ++++++++++++++++++++++++++++++++++++++ src/RestWrite.js | 4 ++ 2 files changed, 141 insertions(+) diff --git a/spec/ParseSession.spec.js b/spec/ParseSession.spec.js index 7f4dfaddc0..c9d18532ca 100644 --- a/spec/ParseSession.spec.js +++ b/spec/ParseSession.spec.js @@ -257,6 +257,143 @@ describe('Parse.Session', () => { expect(newSession.createdWith.authProvider).toBeUndefined(); }); + it('should reject expiresAt when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdateuser1', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalExpiresAt = sessionRes.data.expiresAt; + + // Attempt to overwrite expiresAt via PUT + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: '2099-12-31T23:59:59.000Z' }, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + // Verify expiresAt was not changed + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt); + }); + + it('should reject createdWith when updating a session via PUT', async () => { + const user = await Parse.User.signUp('sessionupdateuser2', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const originalCreatedWith = sessionRes.data.createdWith; + + // Attempt to overwrite createdWith via PUT + const updateRes = await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + 'Content-Type': 'application/json', + }, + body: { + createdWith: { action: 'attacker', authProvider: 'evil' }, + }, + }).catch(e => e); + + expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME); + + // Verify createdWith was not changed + const verifyRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(verifyRes.data.createdWith).toEqual(originalCreatedWith); + }); + + it('should allow master key to update expiresAt on a session', async () => { + const user = await Parse.User.signUp('sessionupdateuser3', 'password'); + const sessionToken = user.getSessionToken(); + + // Get the session objectId + const sessionRes = await request({ + method: 'GET', + url: 'http://localhost:8378/1/sessions/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + const sessionId = sessionRes.data.objectId; + const farFuture = '2099-12-31T23:59:59.000Z'; + + // Master key should be able to update expiresAt + await request({ + method: 'PUT', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', + }, + body: { + expiresAt: { __type: 'Date', iso: farFuture }, + }, + }); + + // Verify expiresAt was changed + const verifyRes = await request({ + method: 'GET', + url: `http://localhost:8378/1/sessions/${sessionId}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(verifyRes.data.expiresAt.iso).toBe(farFuture); + }); + describe('PUT /sessions/me', () => { it('should return error with invalid session token', async () => { const response = await request({ diff --git a/src/RestWrite.js b/src/RestWrite.js index 80b233ec59..11794000b0 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1180,6 +1180,10 @@ RestWrite.prototype.handleSession = function () { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); } else if (this.data.sessionToken) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); + } else if (this.data.expiresAt && !this.auth.isMaster && !this.auth.isMaintenance) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); + } else if (this.data.createdWith && !this.auth.isMaster && !this.auth.isMaintenance) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME); } if (!this.auth.isMaster) { this.query = { From fb9ce5698a80e6ee441c42f922be7c162ae71541 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 21 Mar 2026 01:38:36 +0000 Subject: [PATCH 135/156] chore(release): 9.6.0-alpha.48 [skip ci] # [9.6.0-alpha.48](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.47...9.6.0-alpha.48) (2026-03-21) ### Bug Fixes * Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10263](https://github.com/parse-community/parse-server/issues/10263)) ([ea68fc0](https://github.com/parse-community/parse-server/commit/ea68fc0b22a6056c9675149469ff57817f7cf984)) --- 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 0e2a445e72..2ddccff2d5 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.48](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.47...9.6.0-alpha.48) (2026-03-21) + + +### Bug Fixes + +* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10263](https://github.com/parse-community/parse-server/issues/10263)) ([ea68fc0](https://github.com/parse-community/parse-server/commit/ea68fc0b22a6056c9675149469ff57817f7cf984)) + # [9.6.0-alpha.47](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.46...9.6.0-alpha.47) (2026-03-20) diff --git a/package-lock.json b/package-lock.json index f7e526568b..0320e0dbeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.47", + "version": "9.6.0-alpha.48", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.47", + "version": "9.6.0-alpha.48", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 385fd0b30d..17fdc31d8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.47", + "version": "9.6.0-alpha.48", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 164ed0dd1206e96ce42e46058016a7d7eaf84d85 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:20:35 +0000 Subject: [PATCH 136/156] fix: Add configurable batch request sub-request limit via option `requestComplexity.batchRequestLimit` (#10265) --- spec/RequestComplexity.spec.js | 1 + spec/SecurityCheckGroups.spec.js | 1 + spec/batch.spec.js | 170 ++++++++++++++++++ src/Deprecator/Deprecations.js | 5 + src/Options/Definitions.js | 6 + src/Options/docs.js | 1 + src/Options/index.js | 3 + .../CheckGroups/CheckGroupServerConfig.js | 2 +- src/batch.js | 7 + 9 files changed, 195 insertions(+), 1 deletion(-) diff --git a/spec/RequestComplexity.spec.js b/spec/RequestComplexity.spec.js index aa33a593fa..e126c195df 100644 --- a/spec/RequestComplexity.spec.js +++ b/spec/RequestComplexity.spec.js @@ -147,6 +147,7 @@ describe('request complexity', () => { await reconfigureServer({}); const config = Config.get('test'); expect(config.requestComplexity).toEqual({ + batchRequestLimit: -1, includeDepth: -1, includeCount: -1, subqueryDepth: -1, diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index 7867c8c7d5..08357e453f 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -44,6 +44,7 @@ describe('Security Check Groups', () => { queryDepth: 10, graphQLDepth: 50, graphQLFields: 200, + batchRequestLimit: 50, }; await reconfigureServer(config); diff --git a/spec/batch.spec.js b/spec/batch.spec.js index 43490df61f..b47254eb14 100644 --- a/spec/batch.spec.js +++ b/spec/batch.spec.js @@ -594,6 +594,176 @@ describe('batch', () => { }); } + describe('batch request size limit', () => { + it('should reject batch request when sub-requests exceed batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: jasmine.stringContaining('3'), + }), + }) + ); + }); + + it('should allow batch request when sub-requests are within batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 5 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } }, + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } }, + ], + }), + }); + expect(result.data.length).toEqual(2); + expect(result.data[0].success.objectId).toBeDefined(); + expect(result.data[1].success.objectId).toBeDefined(); + }); + + it('should allow batch request at exactly batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: [ + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v1' } }, + { method: 'POST', path: '/1/classes/TestClass', body: { key: 'v2' } }, + ], + }), + }); + expect(result.data.length).toEqual(2); + }); + + it('should not limit batch request when batchRequestLimit is -1 (disabled)', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: -1 }, + }); + const requests = Array.from({ length: 20 }, (_, i) => ({ + method: 'POST', + path: '/1/classes/TestClass', + body: { key: `v${i}` }, + })); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ requests }), + }); + expect(result.data.length).toEqual(20); + }); + + it('should not limit batch request by default (no requestComplexity configured)', async () => { + const requests = Array.from({ length: 20 }, (_, i) => ({ + method: 'POST', + path: '/1/classes/TestClass', + body: { key: `v${i}` }, + })); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ requests }), + }); + expect(result.data.length).toEqual(20); + }); + + it('should bypass batchRequestLimit for master key requests', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers: { + ...headers, + 'X-Parse-Master-Key': 'test', + }, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }); + expect(result.data.length).toEqual(3); + }); + + it('should bypass batchRequestLimit for maintenance key requests', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 2 }, + }); + const result = await request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers: { + ...headers, + 'X-Parse-Maintenance-Key': 'testing', + }, + body: JSON.stringify({ + requests: [ + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + { method: 'GET', path: '/1/classes/TestClass' }, + ], + }), + }); + expect(result.data.length).toEqual(3); + }); + + it('should include limit in error message when batch exceeds batchRequestLimit', async () => { + await reconfigureServer({ + requestComplexity: { batchRequestLimit: 5 }, + }); + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + headers, + body: JSON.stringify({ + requests: Array.from({ length: 10 }, () => ({ + method: 'GET', + path: '/1/classes/TestClass', + })), + }), + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 400, + data: jasmine.objectContaining({ + error: jasmine.stringContaining('5'), + }), + }) + ); + }); + }); + describe('subrequest path type validation', () => { it('rejects object path in batch subrequest with proper error instead of 500', async () => { await expectAsync( diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index dff88c78bb..c30c8cd71d 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -71,6 +71,11 @@ module.exports = [ changeNewDefault: '200', solution: "Set 'requestComplexity.graphQLFields' to a positive integer appropriate for your app to limit the number of GraphQL field selections, or to '-1' to disable.", }, + { + optionKey: 'requestComplexity.batchRequestLimit', + changeNewDefault: '100', + solution: "Set 'requestComplexity.batchRequestLimit' to a positive integer appropriate for your app to limit the number of sub-requests per batch request, or to '-1' to disable.", + }, { optionKey: 'enableProductPurchaseLegacyApi', changeNewKey: '', diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 26c50a5d3f..8b1eaf7db7 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -674,6 +674,12 @@ module.exports.RateLimitOptions = { }, }; module.exports.RequestComplexityOptions = { + batchRequestLimit: { + env: 'PARSE_SERVER_REQUEST_COMPLEXITY_BATCH_REQUEST_LIMIT', + help: 'Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`.', + action: parsers.numberParser('batchRequestLimit'), + default: -1, + }, graphQLDepth: { env: 'PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_DEPTH', help: 'Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`.', diff --git a/src/Options/docs.js b/src/Options/docs.js index 082f9cba38..d8c09aa1c6 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -131,6 +131,7 @@ /** * @interface RequestComplexityOptions + * @property {Number} batchRequestLimit Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`. * @property {Number} graphQLDepth Maximum depth of GraphQL field selections. Set to `-1` to disable. Default is `-1`. * @property {Number} graphQLFields Maximum number of field selections in a GraphQL query. Set to `-1` to disable. Default is `-1`. * @property {Number} includeCount Maximum number of include paths in a single query. Set to `-1` to disable. Default is `-1`. diff --git a/src/Options/index.js b/src/Options/index.js index 8707d4c87a..33b6af2f30 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -449,6 +449,9 @@ export interface RequestComplexityOptions { :ENV: PARSE_SERVER_REQUEST_COMPLEXITY_GRAPHQL_FIELDS :DEFAULT: -1 */ graphQLFields: ?number; + /* Maximum number of sub-requests in a single batch request. Set to `-1` to disable. Default is `-1`. + :DEFAULT: -1 */ + batchRequestLimit: ?number; } export interface SecurityOptions { diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index 8f93e51c35..345c0617f6 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -145,7 +145,7 @@ class CheckGroupServerConfig extends CheckGroup { if (!rc) { throw 1; } - const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.queryDepth, rc.graphQLDepth, rc.graphQLFields]; + const values = [rc.includeDepth, rc.includeCount, rc.subqueryDepth, rc.queryDepth, rc.graphQLDepth, rc.graphQLFields, rc.batchRequestLimit]; if (values.some(v => v === -1)) { throw 1; } diff --git a/src/batch.js b/src/batch.js index 6cb4a5bbfe..c3c1b2751d 100644 --- a/src/batch.js +++ b/src/batch.js @@ -67,6 +67,13 @@ async function handleBatch(router, req) { if (!Array.isArray(req.body?.requests)) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'requests must be an array'); } + const batchRequestLimit = req.config?.requestComplexity?.batchRequestLimit ?? -1; + if (batchRequestLimit > -1 && !req.auth?.isMaster && !req.auth?.isMaintenance && req.body.requests.length > batchRequestLimit) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `Batch request contains ${req.body.requests.length} sub-requests, which exceeds the limit of ${batchRequestLimit}.` + ); + } for (const restRequest of req.body.requests) { if (!restRequest || typeof restRequest !== 'object' || typeof restRequest.path !== 'string') { throw new Parse.Error(Parse.Error.INVALID_JSON, 'batch request path must be a string'); From cbdf1885ac154617897c720ee90df9af85f85862 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 21 Mar 2026 02:21:30 +0000 Subject: [PATCH 137/156] chore(release): 9.6.0-alpha.49 [skip ci] # [9.6.0-alpha.49](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.48...9.6.0-alpha.49) (2026-03-21) ### Bug Fixes * Add configurable batch request sub-request limit via option `requestComplexity.batchRequestLimit` ([#10265](https://github.com/parse-community/parse-server/issues/10265)) ([164ed0d](https://github.com/parse-community/parse-server/commit/164ed0dd1206e96ce42e46058016a7d7eaf84d85)) --- 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 2ddccff2d5..0cb44f3f5c 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.49](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.48...9.6.0-alpha.49) (2026-03-21) + + +### Bug Fixes + +* Add configurable batch request sub-request limit via option `requestComplexity.batchRequestLimit` ([#10265](https://github.com/parse-community/parse-server/issues/10265)) ([164ed0d](https://github.com/parse-community/parse-server/commit/164ed0dd1206e96ce42e46058016a7d7eaf84d85)) + # [9.6.0-alpha.48](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.47...9.6.0-alpha.48) (2026-03-21) diff --git a/package-lock.json b/package-lock.json index 0320e0dbeb..98594d675c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.48", + "version": "9.6.0-alpha.49", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.48", + "version": "9.6.0-alpha.49", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 17fdc31d8d..0c1e42dc9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.48", + "version": "9.6.0-alpha.49", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From ff70fee7e18d7e627b590f7f5717a58ee91cfecb Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:04:42 +0000 Subject: [PATCH 138/156] fix: Account lockout race condition allows bypassing threshold via concurrent requests (#10266) --- spec/AccountLockoutPolicy.spec.js | 50 +++++++++++++++++++++++++ src/AccountLockout.js | 62 ++++++++++--------------------- 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/spec/AccountLockoutPolicy.spec.js b/spec/AccountLockoutPolicy.spec.js index da8048adab..91d30e55fa 100644 --- a/spec/AccountLockoutPolicy.spec.js +++ b/spec/AccountLockoutPolicy.spec.js @@ -341,6 +341,56 @@ describe('Account Lockout Policy: ', () => { done(); }); }); + + it('should enforce lockout threshold under concurrent failed login attempts', async () => { + const threshold = 3; + await reconfigureServer({ + appName: 'lockout race', + accountLockout: { + duration: 5, + threshold, + }, + publicServerURL: 'http://localhost:8378/1', + }); + + const user = new Parse.User(); + user.setUsername('race_user'); + user.setPassword('correct_password'); + await user.signUp(); + + const concurrency = 30; + const results = await Promise.all( + Array.from({ length: concurrency }, () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username: 'race_user', password: 'wrong_password' }), + }).catch(err => err) + ) + ); + + const lockoutError = + 'Your account is locked due to multiple failed login attempts. Please try again after 5 minute(s)'; + const errors = results.map(r => { + const body = typeof r.data === 'string' ? JSON.parse(r.data) : r.data; + return body?.error; + }); + const invalidPassword = errors.filter(error => error === 'Invalid username/password.'); + const lockoutResponses = errors.filter(error => error === lockoutError); + + expect( + errors.every( + error => error === 'Invalid username/password.' || error === lockoutError + ) + ).toBeTrue(); + expect(lockoutResponses.length).toBeGreaterThan(0); + expect(invalidPassword.length).toBeLessThanOrEqual(threshold); + }); }); describe('lockout with password reset option', () => { diff --git a/src/AccountLockout.js b/src/AccountLockout.js index 13d655e6b7..f8371ff436 100644 --- a/src/AccountLockout.js +++ b/src/AccountLockout.js @@ -23,37 +23,7 @@ export class AccountLockout { } /** - * check if the _failed_login_count field has been set - */ - _isFailedLoginCountSet() { - const query = { - username: this._user.username, - _failed_login_count: { $exists: true }, - }; - - return this._config.database.find('_User', query).then(users => { - if (Array.isArray(users) && users.length > 0) { - return true; - } else { - return false; - } - }); - } - - /** - * if _failed_login_count is NOT set then set it to 0 - * else do nothing - */ - _initFailedLoginCount() { - return this._isFailedLoginCountSet().then(failedLoginCountIsSet => { - if (!failedLoginCountIsSet) { - return this._setFailedLoginCount(0); - } - }); - } - - /** - * increment _failed_login_count by 1 + * increment _failed_login_count by 1 and return the updated document */ _incrementFailedLoginCount() { const query = { @@ -127,20 +97,26 @@ export class AccountLockout { } /** - * set and/or increment _failed_login_count - * if _failed_login_count > threshold - * set the _account_lockout_expires_at to current_time + accountPolicy.duration - * else - * do nothing + * Atomically increment _failed_login_count and enforce lockout threshold. + * Uses the atomic increment result to determine the exact post-increment + * count, eliminating the TOCTOU race between checking and updating. */ _handleFailedLoginAttempt() { - return this._initFailedLoginCount() - .then(() => { - return this._incrementFailedLoginCount(); - }) - .then(() => { - return this._setLockoutExpiration(); - }); + return this._incrementFailedLoginCount().then(result => { + const count = result._failed_login_count; + if (count >= this._config.accountLockout.threshold) { + return this._setLockoutExpiration().then(() => { + if (count > this._config.accountLockout.threshold) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Your account is locked due to multiple failed login attempts. Please try again after ' + + this._config.accountLockout.duration + + ' minute(s)' + ); + } + }); + } + }); } /** From 7ce293f4a33c47f22c9d126618fab0d86b9c2fd6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 21 Mar 2026 03:05:41 +0000 Subject: [PATCH 139/156] chore(release): 9.6.0-alpha.50 [skip ci] # [9.6.0-alpha.50](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.49...9.6.0-alpha.50) (2026-03-21) ### Bug Fixes * Account lockout race condition allows bypassing threshold via concurrent requests ([#10266](https://github.com/parse-community/parse-server/issues/10266)) ([ff70fee](https://github.com/parse-community/parse-server/commit/ff70fee7e18d7e627b590f7f5717a58ee91cfecb)) --- 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 0cb44f3f5c..594187adfd 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.50](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.49...9.6.0-alpha.50) (2026-03-21) + + +### Bug Fixes + +* Account lockout race condition allows bypassing threshold via concurrent requests ([#10266](https://github.com/parse-community/parse-server/issues/10266)) ([ff70fee](https://github.com/parse-community/parse-server/commit/ff70fee7e18d7e627b590f7f5717a58ee91cfecb)) + # [9.6.0-alpha.49](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.48...9.6.0-alpha.49) (2026-03-21) diff --git a/package-lock.json b/package-lock.json index 98594d675c..dd27646711 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.49", + "version": "9.6.0-alpha.50", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.49", + "version": "9.6.0-alpha.50", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 0c1e42dc9c..3368d2da46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.49", + "version": "9.6.0-alpha.50", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From e8cc6763b101ab767de34b8569bf3c7018e6cf2f Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:00:25 +0000 Subject: [PATCH 140/156] test: Locale path traversal file existence oracle in PagesRouter (#10267) --- spec/PagesRouter.spec.js | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index b9db1fb715..12fb490106 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -1178,6 +1178,79 @@ describe('Pages Router', () => { expect(nonExistingResponse.status).toBe(303); expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail'); }); + + it('does not create file existence oracle via path traversal in locale query parameter', async () => { + // Create a canary file at a traversable path to test the oracle + const canaryDir = path.join(__dirname, 'tmp-locale-oracle-test'); + try { + await fs.mkdir(canaryDir, { recursive: true }); + await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary'); + + config.pages.enableLocalization = true; + await reconfigureServer(config); + + // Calculate traversal from pages directory to canary directory + const pagesPath = path.resolve(__dirname, '../public'); + const relativePath = path.relative(pagesPath, canaryDir); + + // Request with path traversal locale pointing to existing canary file + const existsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent(relativePath)}`, + followRedirects: false, + }).catch(e => e); + + // Request with path traversal locale pointing to non-existing directory + const notExistsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test&locale=${encodeURIComponent('../../../../../../tmp/nonexistent-dir')}`, + followRedirects: false, + }).catch(e => e); + + // Both responses must have the same status — no differential oracle + expect(existsResponse.status).toBe(notExistsResponse.status); + // Canary content must never be served + expect(existsResponse.text).not.toContain('canary'); + expect(notExistsResponse.text).not.toContain('canary'); + } finally { + await fs.rm(canaryDir, { recursive: true, force: true }); + } + }); + + it('does not create file existence oracle via path traversal in locale header', async () => { + // Create a canary file at a traversable path + const canaryDir = path.join(__dirname, 'tmp-locale-header-test'); + try { + await fs.mkdir(canaryDir, { recursive: true }); + await fs.writeFile(path.join(canaryDir, 'password_reset.html'), 'canary'); + + config.pages.enableLocalization = true; + await reconfigureServer(config); + + const pagesPath = path.resolve(__dirname, '../public'); + const relativePath = path.relative(pagesPath, canaryDir); + + // Request with path traversal locale via header pointing to existing file + const existsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`, + headers: { 'x-parse-page-param-locale': relativePath }, + followRedirects: false, + }).catch(e => e); + + // Request with path traversal locale via header pointing to non-existing directory + const notExistsResponse = await request({ + url: `${config.publicServerURL}/apps/${config.appId}/request_password_reset?token=test&username=test`, + headers: { 'x-parse-page-param-locale': '../../../../../../tmp/nonexistent-dir' }, + followRedirects: false, + }).catch(e => e); + + // Both responses must have the same status — no differential oracle + expect(existsResponse.status).toBe(notExistsResponse.status); + // Canary content must never be served + expect(existsResponse.text).not.toContain('canary'); + expect(notExistsResponse.text).not.toContain('canary'); + } finally { + await fs.rm(canaryDir, { recursive: true, force: true }); + } + }); }); describe('custom route', () => { From a0530c251a9e15198c60c1c15c6cc0802a1dd18c Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:04:54 +0000 Subject: [PATCH 141/156] fix: Create CLP not enforced before user field validation on signup (#10268) --- spec/vulnerabilities.spec.js | 101 +++++++++++++++++++++++++++++++++++ src/RestWrite.js | 21 ++++++++ 2 files changed, 122 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 6c734f308b..0f96ea9ee3 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -2278,6 +2278,107 @@ describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint }); }); +describe('(GHSA-4m9m-p9j9-5hjw) User enumeration via signup endpoint', () => { + async function updateCLP(permissions) { + const response = await fetch(Parse.serverURL + '/schemas/_User', { + method: 'PUT', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ classLevelPermissions: permissions }), + }); + const body = await response.json(); + if (body.error) { + throw body; + } + } + + it('does not reveal existing username when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + create: {}, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'existingUser', password: 'otherpassword' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.USERNAME_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + }); + + it('does not reveal existing email when public create CLP is disabled', async () => { + const user = new Parse.User(); + user.setUsername('emailUser'); + user.setPassword('password123'); + user.setEmail('existing@example.com'); + await user.signUp(); + await Parse.User.logOut(); + + await updateCLP({ + get: { '*': true }, + find: { '*': true }, + create: {}, + update: { '*': true }, + delete: { '*': true }, + addField: {}, + }); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'newUser', password: 'otherpassword', email: 'existing@example.com' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).not.toBe(Parse.Error.EMAIL_TAKEN); + expect(response.data.error).not.toContain('Account already exists'); + expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + }); + + it('still returns username taken error when public create CLP is enabled', async () => { + const user = new Parse.User(); + user.setUsername('existingUser'); + user.setPassword('password123'); + await user.signUp(); + await Parse.User.logOut(); + + const response = await request({ + url: 'http://localhost:8378/1/classes/_User', + method: 'POST', + body: { username: 'existingUser', password: 'otherpassword' }, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + }).catch(e => e); + expect(response.data.code).toBe(Parse.Error.USERNAME_TAKEN); + }); +}); + describe('(GHSA-c442-97qw-j6c6) SQL Injection via $regex query operator field name in PostgreSQL adapter', () => { const headers = { 'Content-Type': 'application/json', diff --git a/src/RestWrite.js b/src/RestWrite.js index 11794000b0..bdbac02148 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -131,6 +131,9 @@ RestWrite.prototype.execute = function () { this.validSchemaController = schemaController; return this.setRequiredFieldsIfNeeded(); }) + .then(() => { + return this.validateCreatePermission(); + }) .then(() => { return this.transformUser(); }) @@ -698,6 +701,24 @@ RestWrite.prototype.checkRestrictedFields = async function () { } }; +// Validates the create class-level permission before transformUser runs. +// This prevents user enumeration (username/email existence) when public +// create is disabled on _User, because transformUser checks uniqueness +// before the CLP is enforced in runDatabaseOperation. +RestWrite.prototype.validateCreatePermission = async function () { + if (this.query || this.auth.isMaster || this.auth.isMaintenance) { + return; + } + if (!this.validSchemaController) { + return; + } + await this.validSchemaController.validatePermission( + this.className, + this.runOptions.acl || [], + 'create' + ); +}; + // The non-third-party parts of User transformation RestWrite.prototype.transformUser = async function () { var promise = Promise.resolve(); From 48cc262ce865ff4c25244cfcd6fe8a0d339ed31e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 21 Mar 2026 14:05:55 +0000 Subject: [PATCH 142/156] chore(release): 9.6.0-alpha.51 [skip ci] # [9.6.0-alpha.51](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.50...9.6.0-alpha.51) (2026-03-21) ### Bug Fixes * Create CLP not enforced before user field validation on signup ([#10268](https://github.com/parse-community/parse-server/issues/10268)) ([a0530c2](https://github.com/parse-community/parse-server/commit/a0530c251a9e15198c60c1c15c6cc0802a1dd18c)) --- 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 594187adfd..b6b1722792 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.51](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.50...9.6.0-alpha.51) (2026-03-21) + + +### Bug Fixes + +* Create CLP not enforced before user field validation on signup ([#10268](https://github.com/parse-community/parse-server/issues/10268)) ([a0530c2](https://github.com/parse-community/parse-server/commit/a0530c251a9e15198c60c1c15c6cc0802a1dd18c)) + # [9.6.0-alpha.50](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.49...9.6.0-alpha.50) (2026-03-21) diff --git a/package-lock.json b/package-lock.json index dd27646711..ec3d0ef064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.50", + "version": "9.6.0-alpha.51", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.50", + "version": "9.6.0-alpha.51", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 3368d2da46..dd0354e5fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.50", + "version": "9.6.0-alpha.51", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From fbac847499e57f243315c5fc7135be1d58bb8e54 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 15:49:08 +0000 Subject: [PATCH 143/156] fix: Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) (#10270) --- spec/vulnerabilities.spec.js | 64 ++++++++++++++++++++++++++++++++++++ src/Auth.js | 8 ++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 0f96ea9ee3..ce0a643e0a 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -4142,3 +4142,67 @@ describe('(GHSA-6qh5-m6g3-xhq6) LiveQuery query depth DoS via deeply nested subs expect(subscription).toBeDefined(); }); }); + +describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigured auth providers', () => { + it('should not query database for unconfigured auth provider on signup', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + new Parse.User().save({ authData: { nonExistentProvider: { id: 'test123' } } }) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.') + ); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + }); + expect(authDataQueries.length).toBe(0); + }); + + it('should not query database for unconfigured auth provider on challenge', async () => { + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + await expectAsync( + request({ + method: 'POST', + url: Parse.serverURL + '/challenge', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + authData: { nonExistentProvider: { id: 'test123' } }, + challengeData: { nonExistentProvider: { token: 'abc' } }, + }), + }) + ).toBeRejected(); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.nonExistentProvider.id']); + }); + expect(authDataQueries.length).toBe(0); + }); + + it('should still query database for configured auth provider', async () => { + await reconfigureServer({ + auth: { + myConfiguredProvider: { + module: { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }, + }, + }, + }); + const databaseAdapter = Config.get(Parse.applicationId).database.adapter; + const spy = spyOn(databaseAdapter, 'find').and.callThrough(); + const user = new Parse.User(); + await user.save({ authData: { myConfiguredProvider: { id: 'validId', token: 'validToken' } } }); + const authDataQueries = spy.calls.all().filter(call => { + const query = call.args[2]; + return query?.$or?.some(q => q['authData.myConfiguredProvider.id']); + }); + expect(authDataQueries.length).toBeGreaterThan(0); + }); +}); diff --git a/src/Auth.js b/src/Auth.js index 9610cbe019..dd75aaace2 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -448,7 +448,13 @@ const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAu const isUnchanged = storedProviderData && incomingKeys.length > 0 && !incomingKeys.some(key => !isDeepStrictEqual(providerAuthData[key], storedProviderData[key])); - const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; + const validatorConfig = config.authDataManager.getValidatorForProvider(provider); + // Skip database query for unconfigured providers to avoid unindexed collection scans; + // the provider will be rejected later in handleAuthDataValidation with UNSUPPORTED_SERVICE + if (!validatorConfig?.validator) { + return null; + } + const adapter = validatorConfig.adapter; if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) { await adapter.beforeFind(providerAuthData); } From cdd377617b607e24cdf96ef8176fa0e70a2dc435 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 21 Mar 2026 15:50:06 +0000 Subject: [PATCH 144/156] chore(release): 9.6.0-alpha.52 [skip ci] # [9.6.0-alpha.52](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.51...9.6.0-alpha.52) (2026-03-21) ### Bug Fixes * Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10270](https://github.com/parse-community/parse-server/issues/10270)) ([fbac847](https://github.com/parse-community/parse-server/commit/fbac847499e57f243315c5fc7135be1d58bb8e54)) --- 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 b6b1722792..e31fda9e34 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.52](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.51...9.6.0-alpha.52) (2026-03-21) + + +### Bug Fixes + +* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10270](https://github.com/parse-community/parse-server/issues/10270)) ([fbac847](https://github.com/parse-community/parse-server/commit/fbac847499e57f243315c5fc7135be1d58bb8e54)) + # [9.6.0-alpha.51](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.50...9.6.0-alpha.51) (2026-03-21) diff --git a/package-lock.json b/package-lock.json index ec3d0ef064..cbf6df89bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.51", + "version": "9.6.0-alpha.52", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.51", + "version": "9.6.0-alpha.52", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index dd0354e5fa..53070ce4c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.51", + "version": "9.6.0-alpha.52", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From bdddab5f8b61a40cb8fc62dd895887bdd2f3838e Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:10:52 +0000 Subject: [PATCH 145/156] fix: SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) (#10272) --- spec/vulnerabilities.spec.js | 184 ++++++++++++++++++ .../Postgres/PostgresStorageAdapter.js | 21 +- src/Routers/AggregateRouter.js | 3 + 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index ce0a643e0a..5230de08af 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -4206,3 +4206,187 @@ describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigure expect(authDataQueries.length).toBeGreaterThan(0); }); }); + +describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + }; + const serverURL = 'http://localhost:8378/1'; + + beforeEach(async () => { + const obj = new Parse.Object('TestClass'); + obj.set('playerName', 'Alice'); + obj.set('score', 100); + obj.set('metadata', { tag: 'hello' }); + await obj.save(null, { useMasterKey: true }); + }); + + describe('aggregate $group._id SQL injection', () => { + it_only_db('postgres')('rejects $group._id field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName" OR 1=1 --', + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects $group._id field value containing semicolons', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + alias: '$playerName"; DROP TABLE "TestClass" --', + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects $group._id date operation field value containing double quotes', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$createdAt" OR 1=1 --' }, + }, + }, + }, + ]), + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate $group._id with field reference', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + name: '$playerName', + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + + it_only_db('postgres')('allows legitimate $group._id with date extraction', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + pipeline: JSON.stringify([ + { + $group: { + _id: { + day: { $dayOfMonth: '$_created_at' }, + }, + count: { $sum: 1 }, + }, + }, + ]), + }, + }); + expect(response.data?.results?.length).toBeGreaterThan(0); + }); + }); + + describe('distinct dot-notation SQL injection', () => { + it_only_db('postgres')('rejects distinct field name containing double quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata" FROM pg_tables; --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects distinct field name containing semicolons in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata; DROP TABLE "TestClass" --.tag', + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('rejects distinct field name containing single quotes in dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: "metadata' OR '1'='1.tag", + }, + }).catch(e => e); + expect(response.data?.code).toBe(Parse.Error.INVALID_KEY_NAME); + }); + + it_only_db('postgres')('allows legitimate distinct with dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'metadata.tag', + }, + }); + expect(response.data?.results).toEqual(['hello']); + }); + + it_only_db('postgres')('allows legitimate distinct without dot notation', async () => { + const response = await request({ + method: 'GET', + url: `${serverURL}/aggregate/TestClass`, + headers, + qs: { + distinct: 'playerName', + }, + }); + expect(response.data?.results).toEqual(['Alice']); + }); + }); +}); diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index b65e7d7ed8..7a2bafc460 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -234,6 +234,12 @@ const transformDotField = fieldName => { return name; }; +const validateAggregateFieldName = name => { + if (typeof name !== 'string' || !name.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${name}`); + } +}; + const transformAggregateField = fieldName => { if (typeof fieldName !== 'string') { return fieldName; @@ -244,7 +250,12 @@ const transformAggregateField = fieldName => { if (fieldName === '$_updated_at') { return 'updatedAt'; } - return fieldName.substring(1); + if (!fieldName.startsWith('$')) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + const name = fieldName.substring(1); + validateAggregateFieldName(name); + return name; }; const validateKeys = object => { @@ -2179,12 +2190,18 @@ export class PostgresStorageAdapter implements StorageAdapter { async distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) { debug('distinct'); + const fieldSegments = fieldName.split('.'); + for (const segment of fieldSegments) { + if (!segment.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`); + } + } let field = fieldName; let column = fieldName; const isNested = fieldName.indexOf('.') >= 0; if (isNested) { field = transformDotFieldToComponents(fieldName).join('->'); - column = fieldName.split('.')[0]; + column = fieldSegments[0]; } const isArrayField = schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array'; diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index 88c5795b82..5b35a9fbb9 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -52,6 +52,9 @@ export class AggregateRouter extends ClassesRouter { } return { response }; } catch (e) { + if (e instanceof Parse.Error) { + throw e; + } throw new Parse.Error(Parse.Error.INVALID_QUERY, e.message); } } From 97bdaf5299a40ad8d4b5c5e160a592bf63070934 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 21 Mar 2026 17:12:11 +0000 Subject: [PATCH 146/156] chore(release): 9.6.0-alpha.53 [skip ci] # [9.6.0-alpha.53](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.52...9.6.0-alpha.53) (2026-03-21) ### Bug Fixes * SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10272](https://github.com/parse-community/parse-server/issues/10272)) ([bdddab5](https://github.com/parse-community/parse-server/commit/bdddab5f8b61a40cb8fc62dd895887bdd2f3838e)) --- 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 e31fda9e34..f67538486b 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.53](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.52...9.6.0-alpha.53) (2026-03-21) + + +### Bug Fixes + +* SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10272](https://github.com/parse-community/parse-server/issues/10272)) ([bdddab5](https://github.com/parse-community/parse-server/commit/bdddab5f8b61a40cb8fc62dd895887bdd2f3838e)) + # [9.6.0-alpha.52](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.51...9.6.0-alpha.52) (2026-03-21) diff --git a/package-lock.json b/package-lock.json index cbf6df89bc..59bff92210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.52", + "version": "9.6.0-alpha.53", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.52", + "version": "9.6.0-alpha.53", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 53070ce4c9..6b8b7ea6bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.52", + "version": "9.6.0-alpha.53", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 1915f2c8556320f3df6a30ed0eaaccdc9fa40444 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:36:25 +0000 Subject: [PATCH 147/156] test: Stale authData login bypass via skipped adapter validation (#10274) --- spec/vulnerabilities.spec.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 5230de08af..8645dd524c 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -3504,6 +3504,39 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t expect(validatorSpy).toHaveBeenCalled(); }); + it('rejects login with identical but expired authData when adapter rejects', async () => { + // Sign up with authData that is initially valid + const user = new Parse.User(); + await user.save({ + authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + }); + validatorSpy.calls.reset(); + + // Simulate the token expiring on the provider side: the adapter now + // rejects the same token that was valid at signup time + validatorSpy.and.rejectWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Token expired') + ); + + // Attempt login with the exact same (now-expired) authData + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/users', + headers: { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + body: JSON.stringify({ + authData: { testAdapter: { id: 'user_expired', access_token: 'token_now_expired' } }, + }), + }).catch(e => e); + + // Login must be rejected even though authData is identical to what's stored + expect(res.status).toBe(400); + expect(validatorSpy).toHaveBeenCalled(); + }); + it('skips validation on update when authData is a subset of stored data', async () => { // Sign up with full authData const user = new Parse.User(); From 5e70094250a36bfcc14ecd49592be2b94fba66ff Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 22 Mar 2026 01:59:48 +0000 Subject: [PATCH 148/156] fix: MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) (#10275) --- spec/vulnerabilities.spec.js | 75 +++++++++++++++++++ src/Adapters/Storage/Mongo/MongoTransform.js | 6 +- .../Postgres/PostgresStorageAdapter.js | 8 ++ src/Routers/UsersRouter.js | 35 +++++++-- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index 8645dd524c..a866689c24 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -4240,6 +4240,81 @@ describe('(GHSA-g4cf-xj29-wqqr) DoS via unindexed database query for unconfigure }); }); +describe('(GHSA-2299-ghjr-6vjp) MFA recovery code reuse via concurrent requests', () => { + const mfaHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + beforeEach(async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + }); + + it('rejects concurrent logins using the same MFA recovery code', async () => { + const OTPAuth = require('otpauth'); + const user = await Parse.User.signUp('mfauser', 'password123'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + + // Get recovery codes from stored auth data + await user.fetch({ useMasterKey: true }); + const recoveryCode = user.get('authData').mfa.recovery[0]; + expect(recoveryCode).toBeDefined(); + + // Send concurrent login requests with the same recovery code + const loginWithRecovery = () => + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: mfaHeaders, + body: JSON.stringify({ + username: 'mfauser', + password: 'password123', + authData: { + mfa: { + token: recoveryCode, + }, + }, + }), + }); + + const results = await Promise.allSettled(Array(10).fill().map(() => loginWithRecovery())); + + const succeeded = results.filter(r => r.status === 'fulfilled'); + const failed = results.filter(r => r.status === 'rejected'); + + // Exactly one request should succeed; all others should fail + expect(succeeded.length).toBe(1); + expect(failed.length).toBe(9); + + // Verify the recovery code has been consumed + await user.fetch({ useMasterKey: true }); + const remainingRecovery = user.get('authData').mfa.recovery; + expect(remainingRecovery).not.toContain(recoveryCode); + }); +}); + describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field names in PostgreSQL adapter', () => { const headers = { 'Content-Type': 'application/json', diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index bbaefdd55a..0fd13017b3 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) { return { key: 'times_used', value: value }; default: { // Other auth data - const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); + const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/); if (authDataMatch && className === '_User') { const provider = authDataMatch[1]; - // Special-case auth data. - return { key: `_auth_data_${provider}.id`, value }; + const subField = authDataMatch[3]; + return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value }; } } } diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 7a2bafc460..7b7fc4ed97 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -340,6 +340,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus patterns.push(`$${index}:raw = $${index + 1}::text`); values.push(name, fieldValue); index += 2; + } else if ( + typeof fieldValue === 'object' && + !Object.keys(fieldValue).some(key => key.startsWith('$')) + ) { + name = transformDotFieldToComponents(fieldName).join('->'); + patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`); + values.push(name, JSON.stringify(fieldValue)); + index += 2; } } } else if (fieldValue === null || fieldValue === undefined) { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index beaa08f6f2..9b63229df8 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -286,12 +286,35 @@ export class UsersRouter extends ClassesRouter { // If we have some new validated authData update directly if (validatedAuthData && Object.keys(validatedAuthData).length) { - await req.config.database.update( - '_User', - { objectId: user.objectId }, - { authData: validatedAuthData }, - {} - ); + const query = { objectId: user.objectId }; + // Optimistic locking: include the original array fields in the WHERE clause + // for providers whose data is being updated. This prevents concurrent requests + // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes). + // Only array fields need locking — element removal is vulnerable to TOCTOU; + // scalar fields are simply overwritten and don't have concurrency issues. + if (user.authData) { + for (const provider of Object.keys(validatedAuthData)) { + const original = user.authData[provider]; + if (original && typeof original === 'object') { + for (const [field, value] of Object.entries(original)) { + if ( + Array.isArray(value) && + JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field]) + ) { + query[`authData.${provider}.${field}`] = value; + } + } + } + } + } + try { + await req.config.database.update('_User', query, { authData: validatedAuthData }, {}); + } catch (error) { + if (error.code === Parse.Error.OBJECT_NOT_FOUND) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); + } + throw error; + } } const { sessionData, createSession } = RestWrite.createSession(req.config, { From e521eb22c551f500e70bf61915677ad3101ef73e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 22 Mar 2026 02:01:05 +0000 Subject: [PATCH 149/156] chore(release): 9.6.0-alpha.54 [skip ci] # [9.6.0-alpha.54](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.53...9.6.0-alpha.54) (2026-03-22) ### Bug Fixes * MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10275](https://github.com/parse-community/parse-server/issues/10275)) ([5e70094](https://github.com/parse-community/parse-server/commit/5e70094250a36bfcc14ecd49592be2b94fba66ff)) --- 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 f67538486b..609c7d23f9 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.54](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.53...9.6.0-alpha.54) (2026-03-22) + + +### Bug Fixes + +* MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10275](https://github.com/parse-community/parse-server/issues/10275)) ([5e70094](https://github.com/parse-community/parse-server/commit/5e70094250a36bfcc14ecd49592be2b94fba66ff)) + # [9.6.0-alpha.53](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.52...9.6.0-alpha.53) (2026-03-21) diff --git a/package-lock.json b/package-lock.json index 59bff92210..975ca57a7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.53", + "version": "9.6.0-alpha.54", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.53", + "version": "9.6.0-alpha.54", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 6b8b7ea6bd..87b630826f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.53", + "version": "9.6.0-alpha.54", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From e61af4fdfed54b0d668103ae49b052a3a0ad7975 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 22 Mar 2026 02:46:34 +0000 Subject: [PATCH 150/156] test: Stale MFA unlink bypass via skipped adapter validation (#10277) --- spec/AuthenticationAdapters.spec.js | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index cd47130903..2779b70327 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2001,6 +2001,82 @@ describe('OTP TOTP auth adatper', () => { }) ).toBeRejectedWith({ code: Parse.Error.SCRIPT_FAILED, error: 'Invalid MFA token' }); }); + + it('allows unlinking MFA without TOTP verification (by design)', async () => { + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBeDefined(); + // Unlink MFA without providing TOTP + await user.save( + { authData: { mfa: null } }, + { sessionToken } + ); + // MFA should be removed + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toBeUndefined(); + // Login should succeed without MFA + const response = await request({ + headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + }), + }); + expect(response.data.sessionToken).toBeDefined(); + }); + + it('allows blocking MFA unlink via beforeSave trigger', async () => { + Parse.Cloud.beforeSave('_User', request => { + const authData = request.object.get('authData'); + if (authData?.mfa === null) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Cannot disable MFA without verification'); + } + }); + const user = await Parse.User.signUp('username', 'password'); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken: user.getSessionToken() } + ); + // Attempt to unlink MFA — should be blocked by beforeSave trigger + await expectAsync( + user.save( + { authData: { mfa: null } }, + { sessionToken: user.getSessionToken() } + ) + ).toBeRejectedWith( + new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Cannot disable MFA without verification') + ); + // MFA should still be enabled + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBeDefined(); + }); }); describe('OTP SMS auth adatper', () => { From 875cf10ac979bd60f70e7a0c534e2bc194d6982f Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:57:50 +0000 Subject: [PATCH 151/156] fix: Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) (#10278) --- spec/vulnerabilities.spec.js | 103 +++++++++++++++++++++++++++++++++++ src/Routers/UsersRouter.js | 64 ++++++++++++++-------- 2 files changed, 143 insertions(+), 24 deletions(-) diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js index a866689c24..b0626bb001 100644 --- a/spec/vulnerabilities.spec.js +++ b/spec/vulnerabilities.spec.js @@ -4497,4 +4497,107 @@ describe('(GHSA-p2w6-rmh7-w8q3) SQL Injection via aggregate and distinct field n expect(response.data?.results).toEqual(['Alice']); }); }); + + describe('(GHSA-37mj-c2wf-cx96) /users/me leaks raw authData via master context', () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }; + + it('does not leak raw MFA authData via /users/me', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + const token = totp.generate(); + // Enable MFA + await user.save( + { authData: { mfa: { secret: secret.base32, token } } }, + { sessionToken } + ); + // Verify MFA data is stored (master key) + await user.fetch({ useMasterKey: true }); + expect(user.get('authData').mfa.secret).toBe(secret.base32); + expect(user.get('authData').mfa.recovery).toBeDefined(); + // GET /users/me should NOT include raw MFA data + const response = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + expect(response.data.authData?.mfa?.secret).toBeUndefined(); + expect(response.data.authData?.mfa?.recovery).toBeUndefined(); + expect(response.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + + it('returns same authData from /users/me and /users/:id', async () => { + await reconfigureServer({ + auth: { + mfa: { + enabled: true, + options: ['TOTP'], + algorithm: 'SHA1', + digits: 6, + period: 30, + }, + }, + }); + const user = await Parse.User.signUp('username', 'password'); + const sessionToken = user.getSessionToken(); + const OTPAuth = require('otpauth'); + const secret = new OTPAuth.Secret(); + const totp = new OTPAuth.TOTP({ + algorithm: 'SHA1', + digits: 6, + period: 30, + secret, + }); + await user.save( + { authData: { mfa: { secret: secret.base32, token: totp.generate() } } }, + { sessionToken } + ); + // Fetch via /users/me + const meResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: 'http://localhost:8378/1/users/me', + }); + // Fetch via /users/:id + const idResponse = await request({ + headers: { + ...headers, + 'X-Parse-Session-Token': sessionToken, + }, + method: 'GET', + url: `http://localhost:8378/1/users/${user.id}`, + }); + // Both should return the same sanitized authData + expect(meResponse.data.authData).toEqual(idResponse.data.authData); + expect(meResponse.data.authData?.mfa).toEqual({ status: 'enabled' }); + }); + }); }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 9b63229df8..7372ac6baa 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -176,34 +176,50 @@ export class UsersRouter extends ClassesRouter { }); } - handleMe(req) { + async handleMe(req) { if (!req.info || !req.info.sessionToken) { throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); } const sessionToken = req.info.sessionToken; - return rest - .find( - req.config, - Auth.master(req.config), - '_Session', - { sessionToken }, - { include: 'user' }, - req.info.clientSDK, - req.info.context - ) - .then(response => { - if (!response.results || response.results.length == 0 || !response.results[0].user) { - throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); - } else { - const user = response.results[0].user; - // Send token back on the login, because SDKs expect that. - user.sessionToken = sessionToken; - - // Remove hidden properties. - UsersRouter.removeHiddenProperties(user); - return { response: user }; - } - }); + // Query the session with master key to validate the session token, + // but do NOT include 'user' to avoid leaking user data via master context + const sessionResponse = await rest.find( + req.config, + Auth.master(req.config), + '_Session', + { sessionToken }, + {}, + req.info.clientSDK, + req.info.context + ); + if ( + !sessionResponse.results || + sessionResponse.results.length == 0 || + !sessionResponse.results[0].user + ) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); + } + const userId = sessionResponse.results[0].user.objectId; + // Re-fetch the user with the caller's auth context so that + // protectedFields, CLP, and auth adapter afterFind apply correctly + const userResponse = await rest.get( + req.config, + req.auth, + '_User', + userId, + {}, + req.info.clientSDK, + req.info.context + ); + if (!userResponse.results || userResponse.results.length == 0) { + throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); + } + const user = userResponse.results[0]; + // Send token back on the login, because SDKs expect that. + user.sessionToken = sessionToken; + // Remove hidden properties. + UsersRouter.removeHiddenProperties(user); + return { response: user }; } async handleLogIn(req) { From 564c845aedff27cd0c5782b103559fd160cf5c07 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 22 Mar 2026 03:58:50 +0000 Subject: [PATCH 152/156] chore(release): 9.6.0-alpha.55 [skip ci] # [9.6.0-alpha.55](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.54...9.6.0-alpha.55) (2026-03-22) ### Bug Fixes * Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10278](https://github.com/parse-community/parse-server/issues/10278)) ([875cf10](https://github.com/parse-community/parse-server/commit/875cf10ac979bd60f70e7a0c534e2bc194d6982f)) --- 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 609c7d23f9..f975d25a77 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.55](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.54...9.6.0-alpha.55) (2026-03-22) + + +### Bug Fixes + +* Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10278](https://github.com/parse-community/parse-server/issues/10278)) ([875cf10](https://github.com/parse-community/parse-server/commit/875cf10ac979bd60f70e7a0c534e2bc194d6982f)) + # [9.6.0-alpha.54](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.53...9.6.0-alpha.54) (2026-03-22) diff --git a/package-lock.json b/package-lock.json index 975ca57a7e..5352084b2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.54", + "version": "9.6.0-alpha.55", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.54", + "version": "9.6.0-alpha.55", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 87b630826f..d85c2b93d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.54", + "version": "9.6.0-alpha.55", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From d5213f88054fbe066692b7a4661c1b2242aaeddb Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:56:01 +0000 Subject: [PATCH 153/156] feat: Add `protectedFieldsOwnerExempt` option to control `_User` class owner exemption for `protectedFields` (#10280) --- spec/ProtectedFields.spec.js | 136 ++++++++++++++++++++++++++ src/Controllers/DatabaseController.js | 15 +-- src/Deprecator/Deprecations.js | 5 + src/LiveQuery/ParseLiveQueryServer.ts | 2 +- src/Options/Definitions.js | 8 +- src/Options/docs.js | 3 +- src/Options/index.js | 6 +- 7 files changed, 165 insertions(+), 10 deletions(-) diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js index ba5eb96e2f..24101b21a0 100644 --- a/spec/ProtectedFields.spec.js +++ b/spec/ProtectedFields.spec.js @@ -1837,4 +1837,140 @@ describe('ProtectedFields', function () { await expectAsync(restQuery.denyProtectedFields()).toBeResolved(); }); }); + + describe('protectedFieldsOwnerExempt', function () { + it('owner sees own protectedFields when protectedFieldsOwnerExempt is true', async function () { + const protectedFields = { + _User: { + '*': ['phone'], + }, + }; + await reconfigureServer({ protectedFields, protectedFieldsOwnerExempt: true }); + const user1 = new Parse.User(); + user1.setUsername('user1'); + user1.setPassword('password'); + user1.set('phone', '555-1234'); + const acl = new Parse.ACL(); + acl.setPublicReadAccess(true); + user1.setACL(acl); + await user1.signUp(); + const sessionToken1 = user1.getSessionToken(); + + // Owner fetches own object — phone should be visible + const response = await request({ + url: `http://localhost:8378/1/users/${user1.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken1, + }, + }); + expect(response.data.phone).toBe('555-1234'); + + // Another user fetches the first user — phone should be hidden + const user2 = new Parse.User(); + user2.setUsername('user2'); + user2.setPassword('password'); + await user2.signUp(); + const response2 = await request({ + url: `http://localhost:8378/1/users/${user1.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user2.getSessionToken(), + }, + }); + expect(response2.data.phone).toBeUndefined(); + }); + + it('owner does NOT see own protectedFields when protectedFieldsOwnerExempt is false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + // Owner fetches own object — phone should be hidden + const response = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBeUndefined(); + + // Master key — phone should be visible + const masterResponse = await request({ + url: `http://localhost:8378/1/users/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }); + expect(masterResponse.data.phone).toBe('555-1234'); + }); + + it('non-_User classes unaffected by protectedFieldsOwnerExempt', async function () { + await reconfigureServer({ + protectedFields: { + TestClass: { + '*': ['secret'], + }, + }, + protectedFieldsOwnerExempt: true, + }); + const user = await Parse.User.signUp('user1', 'password'); + const obj = new Parse.Object('TestClass'); + obj.set('secret', 'hidden-value'); + obj.setACL(new Parse.ACL(user)); + await obj.save(null, { sessionToken: user.getSessionToken() }); + + // Owner fetches own object — secret should still be hidden (non-_User class) + const response = await request({ + url: `http://localhost:8378/1/classes/TestClass/${obj.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }); + expect(response.data.secret).toBeUndefined(); + }); + + it('/users/me respects protectedFieldsOwnerExempt: false', async function () { + await reconfigureServer({ + protectedFields: { + _User: { + '*': ['phone'], + }, + }, + protectedFieldsOwnerExempt: false, + }); + const user = await Parse.User.signUp('user1', 'password'); + const sessionToken = user.getSessionToken(); + user.set('phone', '555-1234'); + await user.save(null, { sessionToken }); + + // GET /users/me — phone should be hidden + const response = await request({ + url: 'http://localhost:8378/1/users/me', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': sessionToken, + }, + }); + expect(response.data.phone).toBeUndefined(); + expect(response.data.objectId).toBe(user.id); + }); + }); }); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index da710649bb..a3a94db13d 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -195,7 +195,8 @@ const filterSensitiveData = ( schema: SchemaController.SchemaController | any, className: string, protectedFields: null | Array, - object: any + object: any, + protectedFieldsOwnerExempt: ?boolean ) => { let userId = null; if (auth && auth.user) { userId = auth.user.id; } @@ -271,8 +272,9 @@ const filterSensitiveData = ( } /* special treat for the user class: don't filter protectedFields if currently loggedin user is - the retrieved user */ - if (!(isUserClass && userId && object.objectId === userId)) { + the retrieved user, unless protectedFieldsOwnerExempt is false */ + const isOwnerExempt = protectedFieldsOwnerExempt !== false && isUserClass && userId && object.objectId === userId; + if (!isOwnerExempt) { protectedFields && protectedFields.forEach(k => delete object[k]); // fields not requested by client (excluded), @@ -1407,7 +1409,8 @@ class DatabaseController { schemaController, className, protectedFields, - object + object, + this.options.protectedFieldsOwnerExempt ); }) ) @@ -1667,7 +1670,7 @@ class DatabaseController { const protectedFields = perms.protectedFields; if (!protectedFields) { return null; } - if (aclGroup.indexOf(query.objectId) > -1) { return null; } + if (className === '_User' && this.options.protectedFieldsOwnerExempt !== false && aclGroup.indexOf(query.objectId) > -1) { return null; } // for queries where "keys" are set and do not include all 'userField':{field}, // we have to transparently include it, and then remove before returning to client @@ -1989,7 +1992,7 @@ class DatabaseController { } static _validateQuery: (any, boolean, boolean, boolean) => void; - static filterSensitiveData: (boolean, boolean, any[], any, any, any, string, any[], any) => void; + static filterSensitiveData: (boolean, boolean, any[], any, any, any, string, any[], any, ?boolean) => void; } module.exports = DatabaseController; diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index c30c8cd71d..ca0a26e7a4 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -86,4 +86,9 @@ module.exports = [ changeNewKey: '', solution: "Auth providers are always validated on login regardless of this setting. Set 'allowExpiredAuthDataToken' to 'false' or remove the option to accept the future removal.", }, + { + optionKey: 'protectedFieldsOwnerExempt', + changeNewDefault: 'false', + solution: "Set 'protectedFieldsOwnerExempt' to 'false' to apply protectedFields consistently to the user's own _User object (same as all other classes), or to 'true' to keep the current behavior where a user can see all their own fields.", + }, ]; diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index e5e5e31871..1044902fbc 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -783,7 +783,7 @@ class ParseLiveQueryServer { res.object.className, protectedFields, obj, - query + this.config.protectedFieldsOwnerExempt ); }; res.object = filter(res.object); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 8b1eaf7db7..f5da09c352 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -470,7 +470,7 @@ module.exports.ParseServerOptions = { }, protectedFields: { env: 'PARSE_SERVER_PROTECTED_FIELDS', - help: 'Protected fields that should be treated with extra security when fetching details.', + help: "Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this.", action: parsers.objectParser, default: { _User: { @@ -478,6 +478,12 @@ module.exports.ParseServerOptions = { }, }, }, + protectedFieldsOwnerExempt: { + env: 'PARSE_SERVER_PROTECTED_FIELDS_OWNER_EXEMPT', + help: "Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes. Defaults to `true`.", + action: parsers.booleanParser, + default: true, + }, publicServerURL: { env: 'PARSE_PUBLIC_SERVER_URL', help: 'Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.', diff --git a/src/Options/docs.js b/src/Options/docs.js index d8c09aa1c6..8b0d520eee 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -88,7 +88,8 @@ * @property {Boolean} preserveFileName Enable (or disable) the addition of a unique hash to the file names * @property {Boolean} preventLoginWithUnverifiedEmail Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.

The `createdWith` values per scenario:
  • Password signup: `{ action: 'signup', authProvider: 'password' }`
  • Auth provider signup: `{ action: 'signup', authProvider: '' }`
  • Password login: `{ action: 'login', authProvider: 'password' }`
  • Auth provider login: function not invoked; auth provider login bypasses email verification
Default is `false`.
Requires option `verifyUserEmails: true`. * @property {Boolean} preventSignupWithUnverifiedEmail If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.

Default is `false`.
Requires option `verifyUserEmails: true`. - * @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details. + * @property {ProtectedFields} protectedFields Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this. + * @property {Boolean} protectedFieldsOwnerExempt Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes. Defaults to `true`. * @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`. * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case. diff --git a/src/Options/index.js b/src/Options/index.js index 33b6af2f30..32878d23b3 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -168,9 +168,13 @@ export interface ParseServerOptions { preserveFileName: ?boolean; /* Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields */ userSensitiveFields: ?(string[]); - /* Protected fields that should be treated with extra security when fetching details. + /* Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this. :DEFAULT: {"_User": {"*": ["email"]}} */ protectedFields: ?ProtectedFields; + /* Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes. Defaults to `true`. + :ENV: PARSE_SERVER_PROTECTED_FIELDS_OWNER_EXEMPT + :DEFAULT: true */ + protectedFieldsOwnerExempt: ?boolean; /* Enable (or disable) anonymous users, defaults to true :ENV: PARSE_SERVER_ENABLE_ANON_USERS :DEFAULT: true */ From e9591ebe4f22f35b30d711033ec6a3e7117055a9 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 22 Mar 2026 16:57:06 +0000 Subject: [PATCH 154/156] chore(release): 9.6.0-alpha.56 [skip ci] # [9.6.0-alpha.56](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.55...9.6.0-alpha.56) (2026-03-22) ### Features * Add `protectedFieldsOwnerExempt` option to control `_User` class owner exemption for `protectedFields` ([#10280](https://github.com/parse-community/parse-server/issues/10280)) ([d5213f8](https://github.com/parse-community/parse-server/commit/d5213f88054fbe066692b7a4661c1b2242aaeddb)) --- 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 f975d25a77..de1b09b5b8 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,10 @@ +# [9.6.0-alpha.56](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.55...9.6.0-alpha.56) (2026-03-22) + + +### Features + +* Add `protectedFieldsOwnerExempt` option to control `_User` class owner exemption for `protectedFields` ([#10280](https://github.com/parse-community/parse-server/issues/10280)) ([d5213f8](https://github.com/parse-community/parse-server/commit/d5213f88054fbe066692b7a4661c1b2242aaeddb)) + # [9.6.0-alpha.55](https://github.com/parse-community/parse-server/compare/9.6.0-alpha.54...9.6.0-alpha.55) (2026-03-22) diff --git a/package-lock.json b/package-lock.json index 5352084b2f..69e97e8037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.6.0-alpha.55", + "version": "9.6.0-alpha.56", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.6.0-alpha.55", + "version": "9.6.0-alpha.56", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index d85c2b93d9..3bc9b1e9fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.6.0-alpha.55", + "version": "9.6.0-alpha.56", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From cc04fb15cf1701b3f04c16a0308d873f49ed3c67 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:32:50 +0000 Subject: [PATCH 155/156] test: Reject non-ASCII characters in PagesRouter locale parameter (#10281) --- spec/PagesRouter.spec.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index 12fb490106..68dfb0b17b 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -1642,6 +1642,20 @@ describe('Pages Router', () => { expect(response.text).not.toContain('"><svg'); }); + it('should reject non-ASCII characters in locale parameter', async () => { + // Non-ASCII characters like ğ (U+011F) would cause ERR_INVALID_CHAR + // when set as HTTP header value if not rejected by locale validation + const nonAsciiLocale = 'ğ'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(nonAsciiLocale)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Non-ASCII locale is rejected by format validation; + // no ERR_INVALID_CHAR error occurs + expect(response.headers['x-parse-page-param-locale']).toBeUndefined(); + }); + it('should handle legitimate usernames with quotes correctly', async () => { const username = "O'Brien"; const response = await request({ From f1bfd10f114ad52824b499d9de0cb35ce8ecadac Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 22 Mar 2026 17:54:14 +0000 Subject: [PATCH 156/156] empty commit to trigger CI