Compare commits

...
22 Commits
Author SHA1 Message Date
semantic-release-bot 9702abd3dd chore(release): 8.6.69 [skip ci]
## [8.6.69](https://github.com/parse-community/parse-server/compare/8.6.68...8.6.69) (2026-03-29)

### Bug Fixes

* Session field immutability bypass via falsy-value guard ([GHSA-f6j3-w9v3-cq22](https://github.com/parse-community/parse-server/security/advisories/GHSA-f6j3-w9v3-cq22)) ([#10348](https://github.com/parse-community/parse-server/issues/10348)) ([ebccd7f](https://github.com/parse-community/parse-server/commit/ebccd7fe2708007e62f705ee1c820a6766178777))
2026-03-29 03:57:16 +00:00
Manuel ebccd7fe27 fix: Session field immutability bypass via falsy-value guard ([GHSA-f6j3-w9v3-cq22](https://github.com/parse-community/parse-server/security/advisories/GHSA-f6j3-w9v3-cq22)) (#10348) 2026-03-29 04:56:09 +01:00
semantic-release-bot fbd138cc26 chore(release): 8.6.68 [skip ci]
## [8.6.68](https://github.com/parse-community/parse-server/compare/8.6.67...8.6.68) (2026-03-29)

### Bug Fixes

* GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) ([#10345](https://github.com/parse-community/parse-server/issues/10345)) ([ea15412](https://github.com/parse-community/parse-server/commit/ea15412795f34594cc8a674fe858d445675e0295))
2026-03-29 01:34:18 +00:00
Manuel ea15412795 fix: GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) (#10345) 2026-03-29 02:32:53 +01:00
semantic-release-bot 7e4b4c13f9 chore(release): 8.6.67 [skip ci]
## [8.6.67](https://github.com/parse-community/parse-server/compare/8.6.66...8.6.67) (2026-03-28)

### Bug Fixes

* Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) ([#10343](https://github.com/parse-community/parse-server/issues/10343)) ([4fc48cf](https://github.com/parse-community/parse-server/commit/4fc48cf28f22eea200d74d883505f485234a48d7))
2026-03-28 20:05:11 +00:00
Manuel 4fc48cf28f fix: Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) (#10343) 2026-03-28 20:04:08 +00:00
semantic-release-bot 4fda17ccc1 chore(release): 8.6.66 [skip ci]
## [8.6.66](https://github.com/parse-community/parse-server/compare/8.6.65...8.6.66) (2026-03-27)

### Bug Fixes

* GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) ([#10335](https://github.com/parse-community/parse-server/issues/10335)) ([0347641](https://github.com/parse-community/parse-server/commit/0347641507891d0013ec57f7c10f012064f41263))
2026-03-27 15:05:11 +00:00
Manuel 0347641507 fix: GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) (#10335) 2026-03-27 15:04:01 +00:00
semantic-release-bot 9793e8fbb2 chore(release): 8.6.65 [skip ci]
## [8.6.65](https://github.com/parse-community/parse-server/compare/8.6.64...8.6.65) (2026-03-27)

### Bug Fixes

* LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) ([#10331](https://github.com/parse-community/parse-server/issues/10331)) ([5834e29](https://github.com/parse-community/parse-server/commit/5834e29234593addaa0251a85f572ad4f376320b))
2026-03-27 13:45:34 +00:00
Manuel 5834e29234 fix: LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) (#10331) 2026-03-27 13:44:20 +00:00
semantic-release-bot cf886438e6 chore(release): 8.6.64 [skip ci]
## [8.6.64](https://github.com/parse-community/parse-server/compare/8.6.63...8.6.64) (2026-03-26)

### Bug Fixes

* MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) ([#10327](https://github.com/parse-community/parse-server/issues/10327)) ([661f160](https://github.com/parse-community/parse-server/commit/661f160edac8daac0486bc94413cf9652876ab92))
2026-03-26 23:38:06 +00:00
Manuel 661f160eda fix: MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) (#10327) 2026-03-26 23:36:48 +00:00
semantic-release-bot a536a850b9 chore(release): 8.6.63 [skip ci]
## [8.6.63](https://github.com/parse-community/parse-server/compare/8.6.62...8.6.63) (2026-03-26)

### Bug Fixes

* Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) ([#10324](https://github.com/parse-community/parse-server/issues/10324)) ([a1d4e7b](https://github.com/parse-community/parse-server/commit/a1d4e7b12a12f16d3870dbee582a36765858e94c))
2026-03-26 20:36:45 +00:00
Manuel a1d4e7b12a fix: Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) (#10324) 2026-03-26 20:35:35 +00:00
semantic-release-bot 4ff8b79922 chore(release): 8.6.62 [skip ci]
## [8.6.62](https://github.com/parse-community/parse-server/compare/8.6.61...8.6.62) (2026-03-22)

### Bug Fixes

* Reject invalid locale format in PagesRouter ([#10282](https://github.com/parse-community/parse-server/issues/10282)) ([e047da9](https://github.com/parse-community/parse-server/commit/e047da961a4e911c3e110fc5534207a2d9b5ea35))
2026-03-22 17:34:23 +00:00
Manuel e047da961a fix: Reject invalid locale format in PagesRouter (#10282) 2026-03-22 17:33:10 +00:00
semantic-release-bot 99fcbb3a80 chore(release): 8.6.61 [skip ci]
## [8.6.61](https://github.com/parse-community/parse-server/compare/8.6.60...8.6.61) (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)) ([#10279](https://github.com/parse-community/parse-server/issues/10279)) ([5b8998e](https://github.com/parse-community/parse-server/commit/5b8998e6866bcf75be7b5bb625e27d23bfaf912c))
2026-03-22 03:49:01 +00:00
Manuel 5b8998e686 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)) (#10279) 2026-03-22 03:48:02 +00:00
semantic-release-bot 372a6cc613 chore(release): 8.6.60 [skip ci]
## [8.6.60](https://github.com/parse-community/parse-server/compare/8.6.59...8.6.60) (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)) ([#10276](https://github.com/parse-community/parse-server/issues/10276)) ([fc3da35](https://github.com/parse-community/parse-server/commit/fc3da35a81d5083b453e8967cabcc880f1a3bd0c))
2026-03-22 02:00:31 +00:00
Manuel fc3da35a81 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)) (#10276) 2026-03-22 01:59:30 +00:00
semantic-release-bot eeabc97878 chore(release): 8.6.59 [skip ci]
## [8.6.59](https://github.com/parse-community/parse-server/compare/8.6.58...8.6.59) (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)) ([#10273](https://github.com/parse-community/parse-server/issues/10273)) ([03249f9](https://github.com/parse-community/parse-server/commit/03249f9bf5b8783c8b848f84dab791ff0b761b8c))
2026-03-21 17:11:35 +00:00
Manuel 03249f9bf5 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)) (#10273) 2026-03-21 17:10:38 +00:00
18 changed files with 1544 additions and 78 deletions
+77
View File
@@ -1,3 +1,80 @@
## [8.6.69](https://github.com/parse-community/parse-server/compare/8.6.68...8.6.69) (2026-03-29)
### Bug Fixes
* Session field immutability bypass via falsy-value guard ([GHSA-f6j3-w9v3-cq22](https://github.com/parse-community/parse-server/security/advisories/GHSA-f6j3-w9v3-cq22)) ([#10348](https://github.com/parse-community/parse-server/issues/10348)) ([ebccd7f](https://github.com/parse-community/parse-server/commit/ebccd7fe2708007e62f705ee1c820a6766178777))
## [8.6.68](https://github.com/parse-community/parse-server/compare/8.6.67...8.6.68) (2026-03-29)
### Bug Fixes
* GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) ([#10345](https://github.com/parse-community/parse-server/issues/10345)) ([ea15412](https://github.com/parse-community/parse-server/commit/ea15412795f34594cc8a674fe858d445675e0295))
## [8.6.67](https://github.com/parse-community/parse-server/compare/8.6.66...8.6.67) (2026-03-28)
### Bug Fixes
* Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) ([#10343](https://github.com/parse-community/parse-server/issues/10343)) ([4fc48cf](https://github.com/parse-community/parse-server/commit/4fc48cf28f22eea200d74d883505f485234a48d7))
## [8.6.66](https://github.com/parse-community/parse-server/compare/8.6.65...8.6.66) (2026-03-27)
### Bug Fixes
* GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) ([#10335](https://github.com/parse-community/parse-server/issues/10335)) ([0347641](https://github.com/parse-community/parse-server/commit/0347641507891d0013ec57f7c10f012064f41263))
## [8.6.65](https://github.com/parse-community/parse-server/compare/8.6.64...8.6.65) (2026-03-27)
### Bug Fixes
* LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) ([#10331](https://github.com/parse-community/parse-server/issues/10331)) ([5834e29](https://github.com/parse-community/parse-server/commit/5834e29234593addaa0251a85f572ad4f376320b))
## [8.6.64](https://github.com/parse-community/parse-server/compare/8.6.63...8.6.64) (2026-03-26)
### Bug Fixes
* MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) ([#10327](https://github.com/parse-community/parse-server/issues/10327)) ([661f160](https://github.com/parse-community/parse-server/commit/661f160edac8daac0486bc94413cf9652876ab92))
## [8.6.63](https://github.com/parse-community/parse-server/compare/8.6.62...8.6.63) (2026-03-26)
### Bug Fixes
* Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) ([#10324](https://github.com/parse-community/parse-server/issues/10324)) ([a1d4e7b](https://github.com/parse-community/parse-server/commit/a1d4e7b12a12f16d3870dbee582a36765858e94c))
## [8.6.62](https://github.com/parse-community/parse-server/compare/8.6.61...8.6.62) (2026-03-22)
### Bug Fixes
* Reject invalid locale format in PagesRouter ([#10282](https://github.com/parse-community/parse-server/issues/10282)) ([e047da9](https://github.com/parse-community/parse-server/commit/e047da961a4e911c3e110fc5534207a2d9b5ea35))
## [8.6.61](https://github.com/parse-community/parse-server/compare/8.6.60...8.6.61) (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)) ([#10279](https://github.com/parse-community/parse-server/issues/10279)) ([5b8998e](https://github.com/parse-community/parse-server/commit/5b8998e6866bcf75be7b5bb625e27d23bfaf912c))
## [8.6.60](https://github.com/parse-community/parse-server/compare/8.6.59...8.6.60) (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)) ([#10276](https://github.com/parse-community/parse-server/issues/10276)) ([fc3da35](https://github.com/parse-community/parse-server/commit/fc3da35a81d5083b453e8967cabcc880f1a3bd0c))
## [8.6.59](https://github.com/parse-community/parse-server/compare/8.6.58...8.6.59) (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)) ([#10273](https://github.com/parse-community/parse-server/issues/10273)) ([03249f9](https://github.com/parse-community/parse-server/commit/03249f9bf5b8783c8b848f84dab791ff0b761b8c))
## [8.6.58](https://github.com/parse-community/parse-server/compare/8.6.57...8.6.58) (2026-03-21)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "parse-server",
"version": "8.6.58",
"version": "8.6.69",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
"version": "8.6.58",
"version": "8.6.69",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "8.6.58",
"version": "8.6.69",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
+28
View File
@@ -178,4 +178,32 @@ describe('graphql query complexity', () => {
expect(result.errors).toBeUndefined();
});
});
describe('fragment fan-out', () => {
it('should reject query with exponential fragment fan-out efficiently', async () => {
await setupGraphQL({
requestComplexity: { graphQLFields: 100 },
});
// Binary fan-out: each fragment spreads the next one twice.
// Without fix: 2^(levels-1) field visits = 2^25 ≈ 33M (hangs event loop).
// With fix (memoization): O(levels) traversal, same field count, instant rejection.
const levels = 26;
let query = 'query Q { ...F0 }\n';
for (let i = 0; i < levels; i++) {
if (i === levels - 1) {
query += `fragment F${i} on Query { __typename }\n`;
} else {
query += `fragment F${i} on Query { ...F${i + 1} ...F${i + 1} }\n`;
}
}
const start = Date.now();
const result = await graphqlRequest(query);
const elapsed = Date.now() - start;
// Must complete in under 5 seconds (without fix it would take seconds or hang)
expect(elapsed).toBeLessThan(5000);
// Field count is 2^(levels-1) = 16777216, which exceeds the limit of 100
expect(result.errors).toBeDefined();
expect(result.errors[0].message).toMatch(/Number of GraphQL fields .* exceeds maximum allowed/);
});
});
});
+18 -2
View File
@@ -1396,15 +1396,31 @@ describe('Pages Router', () => {
expect(response.text).toContain('&lt;img');
});
it('should escape XSS in locale parameter', async () => {
it('should reject XSS payload in locale parameter', async () => {
const xssLocale = '"><svg/onload=alert(1)>';
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('<svg/onload=alert(1)>');
expect(response.text).toContain('&quot;&gt;&lt;svg');
expect(response.text).not.toContain('&quot;&gt;&lt;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 () => {
+2 -2
View File
@@ -503,7 +503,7 @@ describe('ParseGraphQLServer', () => {
}
});
it('should be cors enabled and scope the response within the source origin', async () => {
it('should be cors enabled', async () => {
let checked = false;
const apolloClient = new ApolloClient({
link: new ApolloLink((operation, forward) => {
@@ -512,7 +512,7 @@ describe('ParseGraphQLServer', () => {
const {
response: { headers },
} = context;
expect(headers.get('access-control-allow-origin')).toEqual('http://example.com');
expect(headers.get('access-control-allow-origin')).toEqual('*');
checked = true;
return response;
});
+152
View File
@@ -393,4 +393,156 @@ describe('Parse.Session', () => {
});
expect(verifyRes.data.expiresAt.iso).toBe(farFuture);
});
it('should reject null expiresAt when updating a session via PUT', async () => {
const user = await Parse.User.signUp('sessionupdatenull1', 'password');
const sessionToken = user.getSessionToken();
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;
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: null,
},
}).catch(e => e);
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
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 null createdWith when updating a session via PUT', async () => {
const user = await Parse.User.signUp('sessionupdatenull2', 'password');
const sessionToken = user.getSessionToken();
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;
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: null,
},
}).catch(e => e);
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
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 reject null installationId when updating a session via PUT', async () => {
const user = await Parse.User.signUp('sessionupdatenull3', 'password');
const sessionToken = user.getSessionToken();
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 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: {
installationId: null,
},
}).catch(e => e);
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});
it('should reject null sessionToken when updating a session via PUT', async () => {
const user = await Parse.User.signUp('sessionupdatenull4', 'password');
const sessionToken = user.getSessionToken();
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 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: {
sessionToken: null,
},
}).catch(e => e);
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
});
});
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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 };
}
}
}
@@ -233,6 +233,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;
@@ -243,7 +249,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 => {
@@ -328,6 +339,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) {
@@ -2157,12 +2176,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';
+3 -5
View File
@@ -1,11 +1,10 @@
import corsMiddleware from 'cors';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
import express from 'express';
import { GraphQLError } from 'graphql';
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter';
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
import defaultLogger from '../logger';
@@ -76,8 +75,7 @@ class ParseGraphQLServer {
try {
return {
schema: await this.parseGraphQLSchema.load(),
context: async ({ req, res }) => {
res.set('access-control-allow-origin', req.get('origin') || '*');
context: async ({ req }) => {
return {
info: req.info,
config: req.config,
@@ -162,7 +160,7 @@ class ParseGraphQLServer {
if (!app || !app.use) {
requiredParameter('You must provide an Express.js app instance!');
}
app.use(this.config.graphQLPath, corsMiddleware());
app.use(this.config.graphQLPath, allowCrossDomain(this.parseServer.config.appId));
app.use(this.config.graphQLPath, handleParseHeaders);
app.use(this.config.graphQLPath, handleParseSession);
this.applyRequestContextMiddleware(app, this.parseServer.config);
+36 -5
View File
@@ -1,14 +1,22 @@
import { GraphQLError } from 'graphql';
import logger from '../../logger';
function calculateQueryComplexity(operation, fragments) {
function calculateQueryComplexity(operation, fragments, limits = {}) {
let maxDepth = 0;
let totalFields = 0;
const fragmentCache = new Map();
const { maxDepth: allowedMaxDepth, maxFields: allowedMaxFields } = limits;
function visitSelectionSet(selectionSet, depth, visitedFragments) {
if (!selectionSet) {
return;
}
if (
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
) {
return;
}
for (const selection of selectionSet.selections) {
if (selection.kind === 'Field') {
totalFields++;
@@ -23,14 +31,36 @@ function calculateQueryComplexity(operation, fragments) {
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
} else if (selection.kind === 'FragmentSpread') {
const name = selection.name.value;
if (fragmentCache.has(name)) {
const cached = fragmentCache.get(name);
totalFields += cached.fields;
const adjustedDepth = depth + cached.maxDepthDelta;
if (adjustedDepth > maxDepth) {
maxDepth = adjustedDepth;
}
continue;
}
if (visitedFragments.has(name)) {
continue;
}
const fragment = fragments[name];
if (fragment) {
const branchVisited = new Set(visitedFragments);
branchVisited.add(name);
visitSelectionSet(fragment.selectionSet, depth, branchVisited);
if (
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
) {
continue;
}
visitedFragments.add(name);
const savedFields = totalFields;
const savedMaxDepth = maxDepth;
maxDepth = depth;
visitSelectionSet(fragment.selectionSet, depth, visitedFragments);
const fieldsContribution = totalFields - savedFields;
const maxDepthDelta = maxDepth - depth;
fragmentCache.set(name, { fields: fieldsContribution, maxDepthDelta });
maxDepth = Math.max(savedMaxDepth, maxDepth);
visitedFragments.delete(name);
}
}
}
@@ -69,7 +99,8 @@ function createComplexityValidationPlugin(getConfig) {
const { depth, fields } = calculateQueryComplexity(
requestContext.operation,
fragments
fragments,
{ maxDepth: graphQLDepth, maxFields: graphQLFields }
);
if (graphQLDepth !== -1 && depth > graphQLDepth) {
+26 -18
View File
@@ -206,6 +206,8 @@ class ParseLiveQueryServer {
continue;
}
requestIds.forEach(async requestId => {
// Deep-clone shared object so each concurrent callback works on its own copy
let localDeletedParseObject = JSON.parse(JSON.stringify(deletedParseObject));
const acl = message.currentParseObject.getACL();
// Check CLP
const op = this._getCLPOperation(subscription.query);
@@ -228,7 +230,7 @@ class ParseLiveQueryServer {
res = {
event: 'delete',
sessionToken: client.sessionToken,
object: deletedParseObject,
object: localDeletedParseObject,
clients: this.clients.size,
subscriptions: this.subscriptions.size,
useMasterKey: client.hasMasterKey,
@@ -250,9 +252,9 @@ class ParseLiveQueryServer {
return;
}
if (res.object && typeof res.object.toJSON === 'function') {
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
localDeletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
}
res.object = deletedParseObject;
res.object = localDeletedParseObject;
await this._filterSensitiveData(
classLevelPermissions,
res,
@@ -261,8 +263,7 @@ class ParseLiveQueryServer {
op,
subscription.query
);
deletedParseObject = res.object;
client.pushDelete(requestId, deletedParseObject);
client.pushDelete(requestId, res.object);
} catch (e) {
const error = resolveError(e);
Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId);
@@ -318,6 +319,13 @@ class ParseLiveQueryServer {
continue;
}
requestIds.forEach(async requestId => {
// Deep-clone shared objects so each concurrent callback works on its own copy.
// Without cloning, _filterSensitiveData's in-place field deletion and afterEvent
// trigger modifications corrupt the shared state across concurrent subscribers.
let localCurrentParseObject = JSON.parse(JSON.stringify(currentParseObject));
let localOriginalParseObject = originalParseObject
? JSON.parse(JSON.stringify(originalParseObject))
: null;
// Set orignal ParseObject ACL checking promise, if the object does not match
// subscription, we do not need to check ACL
let originalACLCheckingPromise;
@@ -358,8 +366,8 @@ class ParseLiveQueryServer {
]);
logger.verbose(
'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s',
originalParseObject,
currentParseObject,
localOriginalParseObject,
localCurrentParseObject,
isOriginalSubscriptionMatched,
isCurrentSubscriptionMatched,
isOriginalMatched,
@@ -373,7 +381,7 @@ class ParseLiveQueryServer {
} else if (isOriginalMatched && !isCurrentMatched) {
type = 'leave';
} else if (!isOriginalMatched && isCurrentMatched) {
if (originalParseObject) {
if (localOriginalParseObject) {
type = 'enter';
} else {
type = 'create';
@@ -388,8 +396,8 @@ class ParseLiveQueryServer {
res = {
event: type,
sessionToken: client.sessionToken,
object: currentParseObject,
original: originalParseObject,
object: localCurrentParseObject,
original: localOriginalParseObject,
clients: this.clients.size,
subscriptions: this.subscriptions.size,
useMasterKey: client.hasMasterKey,
@@ -414,16 +422,16 @@ class ParseLiveQueryServer {
return;
}
if (res.object && typeof res.object.toJSON === 'function') {
currentParseObject = toJSONwithObjects(res.object, res.object.className || className);
localCurrentParseObject = toJSONwithObjects(res.object, res.object.className || className);
}
if (res.original && typeof res.original.toJSON === 'function') {
originalParseObject = toJSONwithObjects(
localOriginalParseObject = toJSONwithObjects(
res.original,
res.original.className || className
);
}
res.object = currentParseObject;
res.original = originalParseObject;
res.object = localCurrentParseObject;
res.original = localOriginalParseObject;
await this._filterSensitiveData(
classLevelPermissions,
res,
@@ -432,11 +440,9 @@ 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);
client[functionName](requestId, res.object, res.original ?? null);
}
} catch (e) {
const error = resolveError(e);
@@ -764,7 +770,9 @@ class ParseLiveQueryServer {
return;
}
let protectedFields = classLevelPermissions?.protectedFields || [];
if (!client.hasMasterKey && !Array.isArray(protectedFields)) {
if (client.hasMasterKey) {
protectedFields = [];
} else if (!Array.isArray(protectedFields)) {
protectedFields = getDatabaseController(this.config).addProtectedFields(
classLevelPermissions,
res.object.className,
+36 -5
View File
@@ -650,6 +650,15 @@ RestWrite.prototype.handleAuthData = async function (authData) {
// We are supposed to have a response only on LOGIN with authData, so we skip those
// If we're not logging in, but just updating the current user, we can safely skip that part
if (this.response) {
// Capture original authData before mutating userResult via the response reference
const originalAuthData = userResult?.authData
? Object.fromEntries(
Object.entries(userResult.authData).map(([k, v]) =>
[k, v && typeof v === 'object' ? { ...v } : v]
)
)
: undefined;
// Assign the new authData in the response
Object.keys(mutatedAuthData).forEach(provider => {
this.response.response.authData[provider] = mutatedAuthData[provider];
@@ -660,14 +669,36 @@ 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) {
const query = { objectId: this.data.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).
if (originalAuthData) {
for (const provider of Object.keys(this.data.authData)) {
const original = originalAuthData[provider];
if (original && typeof original === 'object') {
for (const [field, value] of Object.entries(original)) {
if (
Array.isArray(value) &&
JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field])
) {
query[`authData.${provider}.${field}`] = value;
}
}
}
}
}
try {
await this.config.database.update(
this.className,
{ objectId: this.data.objectId },
query,
{ authData: this.data.authData },
{}
);
} catch (error) {
if (error.code === Parse.Error.OBJECT_NOT_FOUND) {
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data');
}
this._throwIfAuthDataDuplicate(error);
throw error;
}
@@ -1143,13 +1174,13 @@ RestWrite.prototype.handleSession = function () {
if (this.query) {
if (this.data.user && !this.auth.isMaster && this.data.user.objectId != this.auth.user.id) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
} else if (this.data.installationId) {
} else if ('installationId' in this.data) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
} else if (this.data.sessionToken) {
} else if ('sessionToken' in this.data) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
} else if (this.data.expiresAt && !this.auth.isMaster && !this.auth.isMaintenance) {
} else if ('expiresAt' in this.data && !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) {
} else if ('createdWith' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) {
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
}
if (!this.auth.isMaster) {
+3
View File
@@ -48,6 +48,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);
}
}
+10
View File
@@ -555,6 +555,16 @@ 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 and invalid
// HTTP header characters; 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;
}
+71 -32
View File
@@ -170,34 +170,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) {
@@ -280,12 +296,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, {
@@ -383,10 +422,10 @@ export class UsersRouter extends ClassesRouter {
handleVerifyPassword(req) {
return this._authenticateUserFromRequest(req)
.then(user => {
.then(async user => {
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
await req.config.authDataManager.runAfterFind(req, user.authData);
return { response: user };
})
.catch(error => {
+1 -1
View File
@@ -113,7 +113,7 @@ function getStore(category, name, applicationId) {
return createStore();
}
store = store[component];
if (!store) {
if (!store || Object.getPrototypeOf(store) !== null) {
return createStore();
}
}