mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e397b83c51 | ||
|
|
26b628c8fb | ||
|
|
a59a60794e | ||
|
|
060d27053f | ||
|
|
ec028e7354 | ||
|
|
2581b54260 | ||
|
|
9cda64fdc0 | ||
|
|
c62eacaf38 | ||
|
|
9dc2164b5d | ||
|
|
976dad109f | ||
|
|
5d9b5bdafc | ||
|
|
8d7df5639c | ||
|
|
2bae2a1c76 | ||
|
|
967aa57732 |
@@ -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,52 @@
|
||||
## [8.6.57](https://github.com/parse-community/parse-server/compare/8.6.56...8.6.57) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Session update endpoint allows overwriting server-generated session fields ([GHSA-jc39-686j-wp6q](https://github.com/parse-community/parse-server/security/advisories/GHSA-jc39-686j-wp6q)) ([#10264](https://github.com/parse-community/parse-server/issues/10264)) ([26b628c](https://github.com/parse-community/parse-server/commit/26b628c8fb3cc79ea955374769eebcff6f8a8a73))
|
||||
|
||||
## [8.6.56](https://github.com/parse-community/parse-server/compare/8.6.55...8.6.56) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery subscription query depth bypass ([GHSA-6qh5-m6g3-xhq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-6qh5-m6g3-xhq6)) ([#10260](https://github.com/parse-community/parse-server/issues/10260)) ([060d270](https://github.com/parse-community/parse-server/commit/060d27053fb0fadf613c25aabab7fe0c82b7a899))
|
||||
|
||||
## [8.6.55](https://github.com/parse-community/parse-server/compare/8.6.54...8.6.55) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Query condition depth bypass via pre-validation transform pipeline ([GHSA-9fjp-q3c4-6w3j](https://github.com/parse-community/parse-server/security/advisories/GHSA-9fjp-q3c4-6w3j)) ([#10258](https://github.com/parse-community/parse-server/issues/10258)) ([2581b54](https://github.com/parse-community/parse-server/commit/2581b5426047ce9cbcd3d9c0e8379e9c30e23ab5))
|
||||
|
||||
## [8.6.54](https://github.com/parse-community/parse-server/compare/8.6.53...8.6.54) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Protected field change detection oracle via LiveQuery watch parameter ([GHSA-qpc3-fg4j-8hgm](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpc3-fg4j-8hgm)) ([#10254](https://github.com/parse-community/parse-server/issues/10254)) ([c62eaca](https://github.com/parse-community/parse-server/commit/c62eacaf38de86913f09240583448360b1cc8e67))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.50",
|
||||
"version": "8.6.57",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "8.6.50",
|
||||
"version": "8.6.57",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.50",
|
||||
"version": "8.6.57",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -256,4 +256,141 @@ describe('Parse.Session', () => {
|
||||
expect(newSession.createdWith.action).toBe('create');
|
||||
expect(newSession.createdWith.authProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject expiresAt when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdateuser1', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Get the session objectId
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalExpiresAt = sessionRes.data.expiresAt;
|
||||
|
||||
// Attempt to overwrite expiresAt via PUT
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
expiresAt: { __type: 'Date', iso: '2099-12-31T23:59:59.000Z' },
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
// Verify expiresAt was not changed
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt);
|
||||
});
|
||||
|
||||
it('should reject createdWith when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdateuser2', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Get the session objectId
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalCreatedWith = sessionRes.data.createdWith;
|
||||
|
||||
// Attempt to overwrite createdWith via PUT
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
createdWith: { action: 'attacker', authProvider: 'evil' },
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
// Verify createdWith was not changed
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.createdWith).toEqual(originalCreatedWith);
|
||||
});
|
||||
|
||||
it('should allow master key to update expiresAt on a session', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdateuser3', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
// Get the session objectId
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const farFuture = '2099-12-31T23:59:59.000Z';
|
||||
|
||||
// Master key should be able to update expiresAt
|
||||
await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
expiresAt: { __type: 'Date', iso: farFuture },
|
||||
},
|
||||
});
|
||||
|
||||
// Verify expiresAt was changed
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.expiresAt.iso).toBe(farFuture);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2700,6 +2700,90 @@ describe('(GHSA-9xp9-j92r-p88v) Stack overflow process crash via deeply nested q
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects deeply nested query before transform pipeline processes it', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
const auth = require('../lib/Auth');
|
||||
const rest = require('../lib/rest');
|
||||
const config = Config.get('test');
|
||||
// Depth 50 bypasses the fix because RestQuery.js transform pipeline
|
||||
// recursively traverses the structure before validateQuery() is reached
|
||||
let where = { username: 'test' };
|
||||
for (let i = 0; i < 50; i++) {
|
||||
where = { $and: [where] };
|
||||
}
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects deeply nested query via REST API without authentication', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
let where = { username: 'test' };
|
||||
for (let i = 0; i < 50; i++) {
|
||||
where = { $or: [where] };
|
||||
}
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'GET',
|
||||
url: `${Parse.serverURL}/classes/_User`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
qs: { where: JSON.stringify(where) },
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
data: jasmine.objectContaining({
|
||||
code: Parse.Error.INVALID_QUERY,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects deeply nested $nor query before transform pipeline', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
const auth = require('../lib/Auth');
|
||||
const rest = require('../lib/rest');
|
||||
const config = Config.get('test');
|
||||
let where = { username: 'test' };
|
||||
for (let i = 0; i < 50; i++) {
|
||||
where = { $nor: [where] };
|
||||
}
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('allows queries within the depth limit', async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
const auth = require('../lib/Auth');
|
||||
const rest = require('../lib/rest');
|
||||
const config = Config.get('test');
|
||||
let where = { username: 'test' };
|
||||
for (let i = 0; i < 5; i++) {
|
||||
where = { $or: [where] };
|
||||
}
|
||||
const result = await rest.find(config, auth.nobody(config), '_User', where);
|
||||
expect(result.results).toBeDefined();
|
||||
});
|
||||
|
||||
describe('(GHSA-wjqw-r9x4-j59v) Empty authData session issuance bypass', () => {
|
||||
const signupHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -2987,4 +3071,648 @@ describe('(GHSA-5hmj-jcgp-6hff) Protected fields leak via LiveQuery afterEvent t
|
||||
obj.save({ publicField: 'changed' }, { useMasterKey: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('(GHSA-pfj7-wv7c-22pr) AuthData subset validation bypass with allowExpiredAuthDataToken', () => {
|
||||
let validatorSpy;
|
||||
|
||||
const testAdapter = {
|
||||
validateAppId: () => Promise.resolve(),
|
||||
validateAuthData: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
validatorSpy = spyOn(testAdapter, 'validateAuthData').and.resolveTo({});
|
||||
await reconfigureServer({
|
||||
auth: { testAdapter },
|
||||
allowExpiredAuthDataToken: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates authData on login when incoming data is a strict subset of stored data', async () => {
|
||||
// Sign up a user with full authData (id + access_token)
|
||||
const user = new Parse.User();
|
||||
await user.save({
|
||||
authData: { testAdapter: { id: 'user123', access_token: 'valid_token' } },
|
||||
});
|
||||
validatorSpy.calls.reset();
|
||||
|
||||
// Attempt to log in with only the id field (subset of stored data)
|
||||
const res = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/users',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
authData: { testAdapter: { id: 'user123' } },
|
||||
}),
|
||||
});
|
||||
expect(res.data.objectId).toBe(user.id);
|
||||
// The adapter MUST be called to validate the login attempt
|
||||
expect(validatorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents account takeover via partial authData when allowExpiredAuthDataToken is enabled', async () => {
|
||||
// Sign up a user with full authData
|
||||
const user = new Parse.User();
|
||||
await user.save({
|
||||
authData: { testAdapter: { id: 'victim123', access_token: 'secret_token' } },
|
||||
});
|
||||
validatorSpy.calls.reset();
|
||||
|
||||
// Simulate an attacker sending only the provider ID (no access_token)
|
||||
// The adapter should reject this because the token is missing
|
||||
validatorSpy.and.rejectWith(
|
||||
new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid credentials')
|
||||
);
|
||||
|
||||
const res = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/users',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
authData: { testAdapter: { id: 'victim123' } },
|
||||
}),
|
||||
}).catch(e => e);
|
||||
|
||||
// Login must be rejected — adapter validation must not be skipped
|
||||
expect(res.status).toBe(400);
|
||||
expect(validatorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates authData on login even when authData is identical', async () => {
|
||||
// Sign up with full authData
|
||||
const user = new Parse.User();
|
||||
await user.save({
|
||||
authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } },
|
||||
});
|
||||
validatorSpy.calls.reset();
|
||||
|
||||
// Log in with the exact same authData (all keys present, same values)
|
||||
const res = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/users',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
authData: { testAdapter: { id: 'user456', access_token: 'expired_token' } },
|
||||
}),
|
||||
});
|
||||
expect(res.data.objectId).toBe(user.id);
|
||||
// Auth providers are always validated on login regardless of allowExpiredAuthDataToken
|
||||
expect(validatorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips validation on update when authData is 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-qpc3-fg4j-8hgm) Protected field change detection oracle via LiveQuery watch parameter', () => {
|
||||
const { sleep } = require('../lib/TestUtils');
|
||||
let obj;
|
||||
|
||||
beforeEach(async () => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['SecretClass'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
const config = Config.get(Parse.applicationId);
|
||||
const schemaController = await config.database.loadSchema();
|
||||
await schemaController.addClassIfNotExists('SecretClass', {
|
||||
secretObj: { type: 'Object' },
|
||||
publicField: { type: 'String' },
|
||||
});
|
||||
await schemaController.updateClass(
|
||||
'SecretClass',
|
||||
{},
|
||||
{
|
||||
find: { '*': true },
|
||||
get: { '*': true },
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: { '*': true },
|
||||
addField: {},
|
||||
protectedFields: { '*': ['secretObj'] },
|
||||
}
|
||||
);
|
||||
|
||||
obj = new Parse.Object('SecretClass');
|
||||
obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 });
|
||||
obj.set('publicField', 'visible');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
|
||||
if (client) {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject LiveQuery subscription with protected field in watch', async () => {
|
||||
const query = new Parse.Query('SecretClass');
|
||||
query.watch('secretObj');
|
||||
await expectAsync(query.subscribe()).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject LiveQuery subscription with dot-notation on protected field in watch', async () => {
|
||||
const query = new Parse.Query('SecretClass');
|
||||
query.watch('secretObj.apiKey');
|
||||
await expectAsync(query.subscribe()).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject LiveQuery subscription with deeply nested dot-notation on protected field in watch', async () => {
|
||||
const query = new Parse.Query('SecretClass');
|
||||
query.watch('secretObj.nested.deep.key');
|
||||
await expectAsync(query.subscribe()).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow LiveQuery subscription with non-protected field in watch', async () => {
|
||||
const query = new Parse.Query('SecretClass');
|
||||
query.watch('publicField');
|
||||
const subscription = await query.subscribe();
|
||||
await Promise.all([
|
||||
new Promise(resolve => {
|
||||
subscription.on('update', object => {
|
||||
expect(object.get('secretObj')).toBeUndefined();
|
||||
expect(object.get('publicField')).toBe('updated');
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
obj.save({ publicField: 'updated' }, { useMasterKey: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not deliver update event when only non-watched field changes', async () => {
|
||||
const query = new Parse.Query('SecretClass');
|
||||
query.watch('publicField');
|
||||
const subscription = await query.subscribe();
|
||||
const updateSpy = jasmine.createSpy('update');
|
||||
subscription.on('update', updateSpy);
|
||||
|
||||
// Change a field that is NOT in the watch list
|
||||
obj.set('secretObj', { apiKey: 'ROTATED_KEY', score: 99 });
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
await sleep(500);
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-6qh5-m6g3-xhq6) LiveQuery query depth DoS via deeply nested subscription', () => {
|
||||
afterEach(async () => {
|
||||
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
|
||||
if (client) {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject LiveQuery subscription with deeply nested $or when queryDepth is set', async () => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestClass'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
const query = new Parse.Query('TestClass');
|
||||
let where = { field: 'value' };
|
||||
for (let i = 0; i < 15; i++) {
|
||||
where = { $or: [where] };
|
||||
}
|
||||
query._where = where;
|
||||
await expectAsync(query.subscribe()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.INVALID_QUERY,
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject LiveQuery subscription with deeply nested $and when queryDepth is set', async () => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestClass'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
const query = new Parse.Query('TestClass');
|
||||
let where = { field: 'value' };
|
||||
for (let i = 0; i < 50; i++) {
|
||||
where = { $and: [where] };
|
||||
}
|
||||
query._where = where;
|
||||
await expectAsync(query.subscribe()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.INVALID_QUERY,
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject LiveQuery subscription with deeply nested $nor when queryDepth is set', async () => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestClass'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
const query = new Parse.Query('TestClass');
|
||||
let where = { field: 'value' };
|
||||
for (let i = 0; i < 50; i++) {
|
||||
where = { $nor: [where] };
|
||||
}
|
||||
query._where = where;
|
||||
await expectAsync(query.subscribe()).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
code: Parse.Error.INVALID_QUERY,
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow LiveQuery subscription within the depth limit', async () => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestClass'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
requestComplexity: { queryDepth: 10 },
|
||||
});
|
||||
const query = new Parse.Query('TestClass');
|
||||
let where = { field: 'value' };
|
||||
for (let i = 0; i < 5; i++) {
|
||||
where = { $or: [where] };
|
||||
}
|
||||
query._where = where;
|
||||
const subscription = await query.subscribe();
|
||||
expect(subscription).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow LiveQuery subscription when queryDepth is disabled', async () => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestClass'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
requestComplexity: { queryDepth: -1 },
|
||||
});
|
||||
const query = new Parse.Query('TestClass');
|
||||
let where = { field: 'value' };
|
||||
for (let i = 0; i < 15; i++) {
|
||||
where = { $or: [where] };
|
||||
}
|
||||
query._where = where;
|
||||
const subscription = await query.subscribe();
|
||||
expect(subscription).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -211,13 +211,16 @@ class ParseLiveQueryServer {
|
||||
const op = this._getCLPOperation(subscription.query);
|
||||
let res: any = {};
|
||||
try {
|
||||
await this._matchesCLP(
|
||||
const matchesCLP = await this._matchesCLP(
|
||||
classLevelPermissions,
|
||||
message.currentParseObject,
|
||||
client,
|
||||
requestId,
|
||||
op
|
||||
);
|
||||
if (matchesCLP === false) {
|
||||
return null;
|
||||
}
|
||||
const isMatched = await this._matchesACL(acl, client, requestId);
|
||||
if (!isMatched) {
|
||||
return null;
|
||||
@@ -339,13 +342,16 @@ class ParseLiveQueryServer {
|
||||
}
|
||||
try {
|
||||
const op = this._getCLPOperation(subscription.query);
|
||||
await this._matchesCLP(
|
||||
const matchesCLP = await this._matchesCLP(
|
||||
classLevelPermissions,
|
||||
message.currentParseObject,
|
||||
client,
|
||||
requestId,
|
||||
op
|
||||
);
|
||||
if (matchesCLP === false) {
|
||||
return;
|
||||
}
|
||||
const [isOriginalMatched, isCurrentMatched] = await Promise.all([
|
||||
originalACLCheckingPromise,
|
||||
currentACLCheckingPromise,
|
||||
@@ -659,8 +665,10 @@ class ParseLiveQueryServer {
|
||||
): Promise<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);
|
||||
}
|
||||
@@ -671,6 +679,66 @@ class ParseLiveQueryServer {
|
||||
aclGroup,
|
||||
op
|
||||
);
|
||||
// Enforce pointer permissions that validatePermission defers.
|
||||
// Returns false to silently skip the event (like ACL), rather than
|
||||
// throwing which would push errors to the client and log noise.
|
||||
if (!client.hasMasterKey && classLevelPermissions) {
|
||||
const permissionField =
|
||||
['get', 'find', 'count'].indexOf(op) > -1 ? 'readUserFields' : 'writeUserFields';
|
||||
const pointerFields = [];
|
||||
if (classLevelPermissions[op]?.pointerFields) {
|
||||
pointerFields.push(...classLevelPermissions[op].pointerFields);
|
||||
}
|
||||
if (Array.isArray(classLevelPermissions[permissionField])) {
|
||||
for (const field of classLevelPermissions[permissionField]) {
|
||||
if (!pointerFields.includes(field)) {
|
||||
pointerFields.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pointerFields.length > 0) {
|
||||
// If public or user-specific permission already grants access, skip pointer check
|
||||
if (
|
||||
!SchemaController.testPermissions(classLevelPermissions, aclGroup, op)
|
||||
) {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
// Check if any pointer field points to the current user
|
||||
const hasAccess = pointerFields.some(field => {
|
||||
const value =
|
||||
typeof object.get === 'function' ? object.get(field) : object[field];
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
// Handle Parse.Object pointer (has .id)
|
||||
if (value.id) {
|
||||
return value.id === userId;
|
||||
}
|
||||
// Handle raw pointer JSON (has .objectId)
|
||||
if (value.objectId) {
|
||||
return value.objectId === userId;
|
||||
}
|
||||
// Handle array of pointers
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(item => {
|
||||
if (item.id) {
|
||||
return item.id === userId;
|
||||
}
|
||||
if (item.objectId) {
|
||||
return item.objectId === userId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _filterSensitiveData(
|
||||
@@ -955,8 +1023,35 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check CLP for subscribe operation
|
||||
// Validate query condition depth
|
||||
const appConfig = Config.get(this.config.appId);
|
||||
if (!client.hasMasterKey) {
|
||||
const rc = appConfig.requestComplexity;
|
||||
if (rc && rc.queryDepth !== -1) {
|
||||
const maxDepth = rc.queryDepth;
|
||||
const checkDepth = (where: any, depth: number) => {
|
||||
if (depth > maxDepth) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.INVALID_QUERY,
|
||||
`Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}`
|
||||
);
|
||||
}
|
||||
if (typeof where !== 'object' || where === null) {
|
||||
return;
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
for (const subQuery of where[op]) {
|
||||
checkDepth(subQuery, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
checkDepth(request.query.where, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Check CLP for subscribe operation
|
||||
const schemaController = await appConfig.database.loadSchema();
|
||||
const classLevelPermissions = schemaController.getClassLevelPermissions(className);
|
||||
const op = this._getCLPOperation(request.query);
|
||||
@@ -982,7 +1077,7 @@ class ParseLiveQueryServer {
|
||||
op
|
||||
);
|
||||
|
||||
// Check protected fields in WHERE clause
|
||||
// Check protected fields in WHERE clause and WATCH parameter
|
||||
if (!client.hasMasterKey) {
|
||||
const auth = request.user ? { user: request.user, userRoles: [] } : {};
|
||||
const protectedFields =
|
||||
@@ -1015,6 +1110,17 @@ class ParseLiveQueryServer {
|
||||
};
|
||||
checkWhere(request.query.where);
|
||||
}
|
||||
if (protectedFields.length > 0 && Array.isArray(request.query.watch)) {
|
||||
for (const watchField of request.query.watch) {
|
||||
const rootField = watchField.split('.')[0];
|
||||
if (protectedFields.includes(watchField) || protectedFields.includes(rootField)) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
'Permission denied'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regex patterns in the subscription query
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -281,6 +281,9 @@ function _UnsafeRestQuery(
|
||||
// TODO: consolidate the replaceX functions
|
||||
_UnsafeRestQuery.prototype.execute = function (executeOptions) {
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
return this.validateQueryDepth();
|
||||
})
|
||||
.then(() => {
|
||||
return this.buildRestWhere();
|
||||
})
|
||||
@@ -352,6 +355,36 @@ _UnsafeRestQuery.prototype.each = function (callback) {
|
||||
);
|
||||
};
|
||||
|
||||
_UnsafeRestQuery.prototype.validateQueryDepth = function () {
|
||||
if (this.auth.isMaster || this.auth.isMaintenance) {
|
||||
return;
|
||||
}
|
||||
const rc = this.config.requestComplexity;
|
||||
if (!rc || rc.queryDepth === -1) {
|
||||
return;
|
||||
}
|
||||
const maxDepth = rc.queryDepth;
|
||||
const checkDepth = (where, depth) => {
|
||||
if (depth > maxDepth) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.INVALID_QUERY,
|
||||
`Query condition nesting depth exceeds maximum allowed depth of ${maxDepth}`
|
||||
);
|
||||
}
|
||||
if (typeof where !== 'object' || where === null) {
|
||||
return;
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
for (const subQuery of where[op]) {
|
||||
checkDepth(subQuery, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
checkDepth(this.restWhere, 0);
|
||||
};
|
||||
|
||||
_UnsafeRestQuery.prototype.buildRestWhere = function () {
|
||||
return Promise.resolve()
|
||||
.then(() => {
|
||||
|
||||
+8
-3
@@ -632,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,
|
||||
@@ -1146,6 +1147,10 @@ RestWrite.prototype.handleSession = function () {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.sessionToken) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.expiresAt && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.createdWith && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
}
|
||||
if (!this.auth.isMaster) {
|
||||
this.query = {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user