mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc2164b5d | ||
|
|
976dad109f | ||
|
|
5d9b5bdafc | ||
|
|
8d7df5639c | ||
|
|
2bae2a1c76 | ||
|
|
967aa57732 | ||
|
|
665216d6dc | ||
|
|
743324e71f | ||
|
|
b65262fd78 | ||
|
|
b62336be06 | ||
|
|
122f4ace19 | ||
|
|
83b4de0b7c | ||
|
|
2af0d05736 | ||
|
|
8d8c760479 | ||
|
|
5e17f25de6 | ||
|
|
a3a57c1507 | ||
|
|
277ef7683b | ||
|
|
433fa8fb19 | ||
|
|
65820cee6e | ||
|
|
6aec8ea9e1 |
@@ -16,6 +16,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
|
||||
| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | deprecated | - |
|
||||
| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | deprecated | - |
|
||||
| DEPPS12 | Database option `allowPublicExplain` will default to `true` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | deprecated | - |
|
||||
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 8.6.0 (2026) | 9.0.0 (2026) | deprecated | - |
|
||||
|
||||
[i_deprecation]: ## "The version and date of the deprecation."
|
||||
[i_removal]: ## "The version and date of the planned removal."
|
||||
|
||||
@@ -1,3 +1,73 @@
|
||||
## [8.6.53](https://github.com/parse-community/parse-server/compare/8.6.52...8.6.53) (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)) ([#10252](https://github.com/parse-community/parse-server/issues/10252)) ([976dad1](https://github.com/parse-community/parse-server/commit/976dad109f3fe3fbd0a3a35ef62e7a5d35eb0bee))
|
||||
|
||||
## [8.6.52](https://github.com/parse-community/parse-server/compare/8.6.51...8.6.52) (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)) ([#10247](https://github.com/parse-community/parse-server/issues/10247)) ([8d7df56](https://github.com/parse-community/parse-server/commit/8d7df5639c4a35768fe8b78b4580b30e8a74721c))
|
||||
|
||||
## [8.6.51](https://github.com/parse-community/parse-server/compare/8.6.50...8.6.51) (2026-03-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Email verification resend page leaks user existence (GHSA-h29g-q5c2-9h4f) ([#10243](https://github.com/parse-community/parse-server/issues/10243)) ([967aa57](https://github.com/parse-community/parse-server/commit/967aa57732202009b2389ce9ecb3130d53d657e5))
|
||||
|
||||
## [8.6.50](https://github.com/parse-community/parse-server/compare/8.6.49...8.6.50) (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)) ([#10233](https://github.com/parse-community/parse-server/issues/10233)) ([743324e](https://github.com/parse-community/parse-server/commit/743324e71fa2a6693bea78c4589cf2211b210eb6))
|
||||
|
||||
## [8.6.49](https://github.com/parse-community/parse-server/compare/8.6.48...8.6.49) (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)) ([#10220](https://github.com/parse-community/parse-server/issues/10220)) ([b62336b](https://github.com/parse-community/parse-server/commit/b62336be06d06e3e9fbf3365354363e381603f58))
|
||||
|
||||
## [8.6.48](https://github.com/parse-community/parse-server/compare/8.6.47...8.6.48) (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)) ([#10217](https://github.com/parse-community/parse-server/issues/10217)) ([83b4de0](https://github.com/parse-community/parse-server/commit/83b4de0b7ce722fac0ecbb5fe815e3da8fb6b8a0))
|
||||
|
||||
## [8.6.47](https://github.com/parse-community/parse-server/compare/8.6.46...8.6.47) (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)) ([#10211](https://github.com/parse-community/parse-server/issues/10211)) ([8d8c760](https://github.com/parse-community/parse-server/commit/8d8c7604790f931531ac31bd88c98eb3d995b9c5))
|
||||
|
||||
## [8.6.46](https://github.com/parse-community/parse-server/compare/8.6.45...8.6.46) (2026-03-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Revert accidental breaking default values for query complexity limits ([#10206](https://github.com/parse-community/parse-server/issues/10206)) ([a3a57c1](https://github.com/parse-community/parse-server/commit/a3a57c15077d0c9c902fd444de986915b942ab2f))
|
||||
|
||||
## [8.6.45](https://github.com/parse-community/parse-server/compare/8.6.44...8.6.45) (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)) ([#10203](https://github.com/parse-community/parse-server/issues/10203)) ([433fa8f](https://github.com/parse-community/parse-server/commit/433fa8fb19f813966871da8be874e1197a29b10b))
|
||||
|
||||
## [8.6.44](https://github.com/parse-community/parse-server/compare/8.6.43...8.6.44) (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)) ([#10201](https://github.com/parse-community/parse-server/issues/10201)) ([6aec8ea](https://github.com/parse-community/parse-server/commit/6aec8ea9e17375930e55406d0c62a429505924cc))
|
||||
|
||||
## [8.6.43](https://github.com/parse-community/parse-server/compare/8.6.42...8.6.43) (2026-03-14)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-32
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.43",
|
||||
"version": "8.6.53",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "8.6.43",
|
||||
"version": "8.6.53",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -19,7 +19,6 @@
|
||||
"bcryptjs": "3.0.2",
|
||||
"commander": "13.1.0",
|
||||
"cors": "2.8.5",
|
||||
"deepcopy": "2.1.0",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "7.5.1",
|
||||
"follow-redirects": "1.15.9",
|
||||
@@ -9541,14 +9540,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",
|
||||
@@ -22164,14 +22155,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",
|
||||
@@ -29757,14 +29740,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",
|
||||
@@ -38597,11 +38572,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",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.43",
|
||||
"version": "8.6.53",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
@@ -29,7 +29,6 @@
|
||||
"bcryptjs": "3.0.2",
|
||||
"commander": "13.1.0",
|
||||
"cors": "2.8.5",
|
||||
"deepcopy": "2.1.0",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "7.5.1",
|
||||
"follow-redirects": "1.15.9",
|
||||
|
||||
@@ -852,6 +852,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}`
|
||||
);
|
||||
@@ -1002,6 +1065,81 @@ describe('Pages Router', () => {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// 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();
|
||||
|
||||
const formUrl = `${config.publicServerURL}/apps/${config.appId}/resend_verification_email`;
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 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: formUrl,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'username=fakeuser',
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
|
||||
expect(nonExistingResponse.status).toBe(303);
|
||||
expect(nonExistingResponse.headers.location).toContain('email_verification_send_fail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom route', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -110,21 +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.graphQLDepth).toBe(50);
|
||||
expect(config.requestComplexity.graphQLFields).toBe(200);
|
||||
expect(config.requestComplexity.includeCount).toBe(-1);
|
||||
expect(config.requestComplexity.subqueryDepth).toBe(-1);
|
||||
expect(config.requestComplexity.queryDepth).toBe(-1);
|
||||
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,
|
||||
graphQLDepth: 50,
|
||||
graphQLFields: 200,
|
||||
includeDepth: -1,
|
||||
includeCount: -1,
|
||||
subqueryDepth: -1,
|
||||
queryDepth: -1,
|
||||
graphQLDepth: -1,
|
||||
graphQLFields: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -34,6 +34,14 @@ describe('Security Check Groups', () => {
|
||||
config.allowClientClassCreation = false;
|
||||
config.enableInsecureAuthAdapters = false;
|
||||
config.graphQLPublicIntrospection = false;
|
||||
config.requestComplexity = {
|
||||
includeDepth: 5,
|
||||
includeCount: 50,
|
||||
subqueryDepth: 5,
|
||||
queryDepth: 10,
|
||||
graphQLDepth: 50,
|
||||
graphQLFields: 200,
|
||||
};
|
||||
await reconfigureServer(config);
|
||||
|
||||
const group = new CheckGroupServerConfig();
|
||||
@@ -57,6 +65,7 @@ describe('Security Check Groups', () => {
|
||||
includeDepth: -1,
|
||||
includeCount: -1,
|
||||
subqueryDepth: -1,
|
||||
queryDepth: -1,
|
||||
graphQLDepth: -1,
|
||||
graphQLFields: -1,
|
||||
};
|
||||
|
||||
@@ -747,7 +747,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,
|
||||
@@ -757,6 +757,37 @@ 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(302);
|
||||
// With emailVerifySuccessOnInvalidEmail: true (default), the resend
|
||||
// page redirects to success to prevent user enumeration
|
||||
expect(response.text).toEqual(
|
||||
'Found. Redirecting to http://localhost:8378/1/apps/link_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',
|
||||
|
||||
@@ -222,6 +222,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('Request denylist', () => {
|
||||
describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => {
|
||||
it('denies _bsontype:Code after a sibling nested object', async () => {
|
||||
@@ -277,12 +342,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 () => {
|
||||
@@ -2510,4 +2586,833 @@ describe('(GHSA-42ph-pf9q-cr72) Stored XSS filter bypass via parameterized Conte
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 }),
|
||||
]);
|
||||
});
|
||||
|
||||
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 identical', 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 identical authData
|
||||
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', access_token: 'valid_token' } },
|
||||
}),
|
||||
});
|
||||
// On update with allowExpiredAuthDataToken: true, identical data skips validation
|
||||
expect(validatorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -75,18 +73,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.');
|
||||
}
|
||||
@@ -94,7 +101,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.');
|
||||
}
|
||||
@@ -102,7 +109,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,
|
||||
@@ -505,7 +512,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 || [];
|
||||
@@ -547,7 +554,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 => {
|
||||
@@ -795,7 +802,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 => {
|
||||
@@ -1300,7 +1307,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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -19,4 +19,9 @@ module.exports = [
|
||||
{ optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' },
|
||||
{ optionKey: 'enableInsecureAuthAdapters', changeNewDefault: 'false' },
|
||||
{ optionKey: 'databaseOptions.allowPublicExplain', changeNewDefault: 'false' },
|
||||
{
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } = (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -249,6 +252,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 +261,7 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
deletedParseObject = res.object;
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
@@ -337,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,
|
||||
@@ -414,6 +422,8 @@ class ParseLiveQueryServer {
|
||||
res.original.className || className
|
||||
);
|
||||
}
|
||||
res.object = currentParseObject;
|
||||
res.original = originalParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -422,6 +432,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);
|
||||
@@ -585,7 +597,7 @@ class ParseLiveQueryServer {
|
||||
if (!parseObject) {
|
||||
return false;
|
||||
}
|
||||
return matchesQuery(deepcopy(parseObject), subscription.query);
|
||||
return matchesQuery(structuredClone(parseObject), subscription.query);
|
||||
}
|
||||
|
||||
async _clearCachedRoles(userId: string) {
|
||||
@@ -653,8 +665,10 @@ class ParseLiveQueryServer {
|
||||
): Promise<any> {
|
||||
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);
|
||||
}
|
||||
@@ -665,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(
|
||||
|
||||
+18
-11
@@ -78,7 +78,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`.',
|
||||
'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,
|
||||
},
|
||||
@@ -720,37 +720,44 @@ 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`.',
|
||||
'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`.',
|
||||
'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`.',
|
||||
'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',
|
||||
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`.',
|
||||
'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 = {
|
||||
|
||||
+7
-6
@@ -15,7 +15,7 @@
|
||||
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.
|
||||
* @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>} analyticsAdapter Adapter module for the analytics
|
||||
@@ -130,11 +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} subqueryDepth Maximum nesting depth of `$inQuery`, `$notInQuery`, `$select`, `$dontSelect` subqueries. 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 `-1`.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
+14
-11
@@ -347,7 +347,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.
|
||||
@@ -397,22 +397,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 depth of GraphQL field selections. Set to `-1` to disable. Default is `50`.
|
||||
/* 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 `-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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+3
-3
@@ -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 };
|
||||
}
|
||||
|
||||
+31
-25
@@ -3,7 +3,7 @@
|
||||
// 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 +75,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 +377,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;
|
||||
}
|
||||
@@ -453,8 +453,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');
|
||||
}
|
||||
@@ -463,13 +469,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,
|
||||
@@ -478,14 +481,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,
|
||||
@@ -609,7 +614,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
|
||||
@@ -627,9 +632,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,
|
||||
@@ -1754,7 +1760,7 @@ RestWrite.prototype.sanitizedData = function () {
|
||||
delete data[key];
|
||||
}
|
||||
return data;
|
||||
}, deepcopy(this.data));
|
||||
}, structuredClone(this.data));
|
||||
return Parse._decode(undefined, data);
|
||||
};
|
||||
|
||||
@@ -1804,7 +1810,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -71,6 +71,7 @@ export class PublicAPIRouter extends PromiseRouter {
|
||||
}
|
||||
|
||||
const userController = config.userController;
|
||||
const suppressError = config.emailVerifySuccessOnInvalidEmail ?? true;
|
||||
|
||||
return userController.resendVerificationEmail(username, req, token).then(
|
||||
() => {
|
||||
@@ -80,6 +81,12 @@ export class PublicAPIRouter extends PromiseRouter {
|
||||
});
|
||||
},
|
||||
() => {
|
||||
if (suppressError) {
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.linkSendSuccessURL}`,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 302,
|
||||
location: `${config.linkSendFailURL}`,
|
||||
|
||||
@@ -116,7 +116,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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user