mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e7949196 | ||
|
|
ffad0ec6b9 | ||
|
|
9702abd3dd | ||
|
|
ebccd7fe27 | ||
|
|
fbd138cc26 | ||
|
|
ea15412795 | ||
|
|
7e4b4c13f9 | ||
|
|
4fc48cf28f | ||
|
|
4fda17ccc1 | ||
|
|
0347641507 | ||
|
|
9793e8fbb2 | ||
|
|
5834e29234 | ||
|
|
cf886438e6 | ||
|
|
661f160eda | ||
|
|
a536a850b9 | ||
|
|
a1d4e7b12a | ||
|
|
4ff8b79922 | ||
|
|
e047da961a | ||
|
|
99fcbb3a80 | ||
|
|
5b8998e686 | ||
|
|
372a6cc613 | ||
|
|
fc3da35a81 | ||
|
|
eeabc97878 | ||
|
|
03249f9bf5 | ||
|
|
ec2cce9e33 | ||
|
|
40eb442e02 | ||
|
|
e397b83c51 | ||
|
|
26b628c8fb | ||
|
|
a59a60794e | ||
|
|
060d27053f | ||
|
|
ec028e7354 | ||
|
|
2581b54260 | ||
|
|
9cda64fdc0 | ||
|
|
c62eacaf38 | ||
|
|
9dc2164b5d | ||
|
|
976dad109f | ||
|
|
5d9b5bdafc | ||
|
|
8d7df5639c | ||
|
|
2bae2a1c76 | ||
|
|
967aa57732 | ||
|
|
665216d6dc | ||
|
|
743324e71f | ||
|
|
b65262fd78 | ||
|
|
b62336be06 | ||
|
|
122f4ace19 | ||
|
|
83b4de0b7c | ||
|
|
2af0d05736 | ||
|
|
8d8c760479 |
@@ -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,171 @@
|
||||
## [8.6.70](https://github.com/parse-community/parse-server/compare/8.6.69...8.6.70) (2026-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery protected-field guard bypass via array-like logical operator value ([GHSA-mmg8-87c5-jrc2](https://github.com/parse-community/parse-server/security/advisories/GHSA-mmg8-87c5-jrc2)) ([#10351](https://github.com/parse-community/parse-server/issues/10351)) ([ffad0ec](https://github.com/parse-community/parse-server/commit/ffad0ec6b971ee0dd9545e1bf1fb34ddebf275c2))
|
||||
|
||||
## [8.6.69](https://github.com/parse-community/parse-server/compare/8.6.68...8.6.69) (2026-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Session field immutability bypass via falsy-value guard ([GHSA-f6j3-w9v3-cq22](https://github.com/parse-community/parse-server/security/advisories/GHSA-f6j3-w9v3-cq22)) ([#10348](https://github.com/parse-community/parse-server/issues/10348)) ([ebccd7f](https://github.com/parse-community/parse-server/commit/ebccd7fe2708007e62f705ee1c820a6766178777))
|
||||
|
||||
## [8.6.68](https://github.com/parse-community/parse-server/compare/8.6.67...8.6.68) (2026-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL complexity validator exponential fragment traversal DoS ([GHSA-mfj6-6p54-m98c](https://github.com/parse-community/parse-server/security/advisories/GHSA-mfj6-6p54-m98c)) ([#10345](https://github.com/parse-community/parse-server/issues/10345)) ([ea15412](https://github.com/parse-community/parse-server/commit/ea15412795f34594cc8a674fe858d445675e0295))
|
||||
|
||||
## [8.6.67](https://github.com/parse-community/parse-server/compare/8.6.66...8.6.67) (2026-03-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud function validator bypass via prototype chain traversal ([GHSA-vpj2-qq7w-5qq6](https://github.com/parse-community/parse-server/security/advisories/GHSA-vpj2-qq7w-5qq6)) ([#10343](https://github.com/parse-community/parse-server/issues/10343)) ([4fc48cf](https://github.com/parse-community/parse-server/commit/4fc48cf28f22eea200d74d883505f485234a48d7))
|
||||
|
||||
## [8.6.66](https://github.com/parse-community/parse-server/compare/8.6.65...8.6.66) (2026-03-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL API endpoint ignores CORS origin restriction ([GHSA-q3p6-g7c4-829c](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3p6-g7c4-829c)) ([#10335](https://github.com/parse-community/parse-server/issues/10335)) ([0347641](https://github.com/parse-community/parse-server/commit/0347641507891d0013ec57f7c10f012064f41263))
|
||||
|
||||
## [8.6.65](https://github.com/parse-community/parse-server/compare/8.6.64...8.6.65) (2026-03-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery protected field leak via shared mutable state across concurrent subscribers ([GHSA-m983-v2ff-wq65](https://github.com/parse-community/parse-server/security/advisories/GHSA-m983-v2ff-wq65)) ([#10331](https://github.com/parse-community/parse-server/issues/10331)) ([5834e29](https://github.com/parse-community/parse-server/commit/5834e29234593addaa0251a85f572ad4f376320b))
|
||||
|
||||
## [8.6.64](https://github.com/parse-community/parse-server/compare/8.6.63...8.6.64) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MFA single-use token bypass via concurrent authData login requests ([GHSA-w73w-g5xw-rwhf](https://github.com/parse-community/parse-server/security/advisories/GHSA-w73w-g5xw-rwhf)) ([#10327](https://github.com/parse-community/parse-server/issues/10327)) ([661f160](https://github.com/parse-community/parse-server/commit/661f160edac8daac0486bc94413cf9652876ab92))
|
||||
|
||||
## [8.6.63](https://github.com/parse-community/parse-server/compare/8.6.62...8.6.63) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Auth data exposed via verify password endpoint ([GHSA-wp76-gg32-8258](https://github.com/parse-community/parse-server/security/advisories/GHSA-wp76-gg32-8258)) ([#10324](https://github.com/parse-community/parse-server/issues/10324)) ([a1d4e7b](https://github.com/parse-community/parse-server/commit/a1d4e7b12a12f16d3870dbee582a36765858e94c))
|
||||
|
||||
## [8.6.62](https://github.com/parse-community/parse-server/compare/8.6.61...8.6.62) (2026-03-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Reject invalid locale format in PagesRouter ([#10282](https://github.com/parse-community/parse-server/issues/10282)) ([e047da9](https://github.com/parse-community/parse-server/commit/e047da961a4e911c3e110fc5534207a2d9b5ea35))
|
||||
|
||||
## [8.6.61](https://github.com/parse-community/parse-server/compare/8.6.60...8.6.61) (2026-03-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Auth data exposed via /users/me endpoint ([GHSA-37mj-c2wf-cx96](https://github.com/parse-community/parse-server/security/advisories/GHSA-37mj-c2wf-cx96)) ([#10279](https://github.com/parse-community/parse-server/issues/10279)) ([5b8998e](https://github.com/parse-community/parse-server/commit/5b8998e6866bcf75be7b5bb625e27d23bfaf912c))
|
||||
|
||||
## [8.6.60](https://github.com/parse-community/parse-server/compare/8.6.59...8.6.60) (2026-03-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MFA recovery code single-use bypass via concurrent requests ([GHSA-2299-ghjr-6vjp](https://github.com/parse-community/parse-server/security/advisories/GHSA-2299-ghjr-6vjp)) ([#10276](https://github.com/parse-community/parse-server/issues/10276)) ([fc3da35](https://github.com/parse-community/parse-server/commit/fc3da35a81d5083b453e8967cabcc880f1a3bd0c))
|
||||
|
||||
## [8.6.59](https://github.com/parse-community/parse-server/compare/8.6.58...8.6.59) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* SQL injection via aggregate and distinct field names in PostgreSQL adapter ([GHSA-p2w6-rmh7-w8q3](https://github.com/parse-community/parse-server/security/advisories/GHSA-p2w6-rmh7-w8q3)) ([#10273](https://github.com/parse-community/parse-server/issues/10273)) ([03249f9](https://github.com/parse-community/parse-server/commit/03249f9bf5b8783c8b848f84dab791ff0b761b8c))
|
||||
|
||||
## [8.6.58](https://github.com/parse-community/parse-server/compare/8.6.57...8.6.58) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial of service via unindexed database query for unconfigured auth providers ([GHSA-g4cf-xj29-wqqr](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4cf-xj29-wqqr)) ([#10271](https://github.com/parse-community/parse-server/issues/10271)) ([40eb442](https://github.com/parse-community/parse-server/commit/40eb442e02672986730007d0a1edb22c1c4bd357))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.46",
|
||||
"version": "8.6.70",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "8.6.46",
|
||||
"version": "8.6.70",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.46",
|
||||
"version": "8.6.70",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
|
||||
@@ -178,4 +178,32 @@ describe('graphql query complexity', () => {
|
||||
expect(result.errors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fragment fan-out', () => {
|
||||
it('should reject query with exponential fragment fan-out efficiently', async () => {
|
||||
await setupGraphQL({
|
||||
requestComplexity: { graphQLFields: 100 },
|
||||
});
|
||||
// Binary fan-out: each fragment spreads the next one twice.
|
||||
// Without fix: 2^(levels-1) field visits = 2^25 ≈ 33M (hangs event loop).
|
||||
// With fix (memoization): O(levels) traversal, same field count, instant rejection.
|
||||
const levels = 26;
|
||||
let query = 'query Q { ...F0 }\n';
|
||||
for (let i = 0; i < levels; i++) {
|
||||
if (i === levels - 1) {
|
||||
query += `fragment F${i} on Query { __typename }\n`;
|
||||
} else {
|
||||
query += `fragment F${i} on Query { ...F${i + 1} ...F${i + 1} }\n`;
|
||||
}
|
||||
}
|
||||
const start = Date.now();
|
||||
const result = await graphqlRequest(query);
|
||||
const elapsed = Date.now() - start;
|
||||
// Must complete in under 5 seconds (without fix it would take seconds or hang)
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
// Field count is 2^(levels-1) = 16777216, which exceeds the limit of 100
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors[0].message).toMatch(/Number of GraphQL fields .* exceeds maximum allowed/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+156
-2
@@ -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', () => {
|
||||
@@ -1258,15 +1396,31 @@ describe('Pages Router', () => {
|
||||
expect(response.text).toContain('<img');
|
||||
});
|
||||
|
||||
it('should escape XSS in locale parameter', async () => {
|
||||
it('should reject XSS payload in locale parameter', async () => {
|
||||
const xssLocale = '"><svg/onload=alert(1)>';
|
||||
const response = await request({
|
||||
url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Invalid locale is rejected by format validation, so the XSS
|
||||
// payload never reaches the page content
|
||||
expect(response.text).not.toContain('<svg/onload=alert(1)>');
|
||||
expect(response.text).toContain('"><svg');
|
||||
expect(response.text).not.toContain('"><svg');
|
||||
});
|
||||
|
||||
it('should reject non-ASCII characters in locale parameter', async () => {
|
||||
// Non-ASCII characters like ğ (U+011F) would cause ERR_INVALID_CHAR
|
||||
// when set as HTTP header value if not rejected by locale validation
|
||||
const nonAsciiLocale = 'ğ';
|
||||
const response = await request({
|
||||
url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(nonAsciiLocale)}&appId=test`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Non-ASCII locale is rejected by format validation;
|
||||
// no ERR_INVALID_CHAR error occurs
|
||||
expect(response.headers['x-parse-page-param-locale']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle legitimate usernames with quotes correctly', async () => {
|
||||
|
||||
@@ -503,7 +503,7 @@ describe('ParseGraphQLServer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should be cors enabled and scope the response within the source origin', async () => {
|
||||
it('should be cors enabled', async () => {
|
||||
let checked = false;
|
||||
const apolloClient = new ApolloClient({
|
||||
link: new ApolloLink((operation, forward) => {
|
||||
@@ -512,7 +512,7 @@ describe('ParseGraphQLServer', () => {
|
||||
const {
|
||||
response: { headers },
|
||||
} = context;
|
||||
expect(headers.get('access-control-allow-origin')).toEqual('http://example.com');
|
||||
expect(headers.get('access-control-allow-origin')).toEqual('*');
|
||||
checked = true;
|
||||
return response;
|
||||
});
|
||||
|
||||
@@ -256,4 +256,293 @@ 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);
|
||||
});
|
||||
|
||||
it('should reject null expiresAt when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull1', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalExpiresAt = sessionRes.data.expiresAt;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
expiresAt: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt);
|
||||
});
|
||||
|
||||
it('should reject null createdWith when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull2', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalCreatedWith = sessionRes.data.createdWith;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
createdWith: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.createdWith).toEqual(originalCreatedWith);
|
||||
});
|
||||
|
||||
it('should reject null installationId when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull3', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
installationId: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
});
|
||||
|
||||
it('should reject null sessionToken when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull4', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
sessionToken: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
+4092
-1779
File diff suppressed because it is too large
Load Diff
@@ -304,11 +304,11 @@ function transformQueryKeyValue(className, key, value, schema, count = false) {
|
||||
return { key: 'times_used', value: value };
|
||||
default: {
|
||||
// Other auth data
|
||||
const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/);
|
||||
const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)(\.(.+))?$/);
|
||||
if (authDataMatch && className === '_User') {
|
||||
const provider = authDataMatch[1];
|
||||
// Special-case auth data.
|
||||
return { key: `_auth_data_${provider}.id`, value };
|
||||
const subField = authDataMatch[3];
|
||||
return { key: `_auth_data_${provider}${subField ? `.${subField}` : ''}`, value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,12 @@ const transformDotField = fieldName => {
|
||||
return name;
|
||||
};
|
||||
|
||||
const validateAggregateFieldName = name => {
|
||||
if (typeof name !== 'string' || !name.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const transformAggregateField = fieldName => {
|
||||
if (typeof fieldName !== 'string') {
|
||||
return fieldName;
|
||||
@@ -243,7 +249,12 @@ const transformAggregateField = fieldName => {
|
||||
if (fieldName === '$_updated_at') {
|
||||
return 'updatedAt';
|
||||
}
|
||||
return fieldName.substring(1);
|
||||
if (!fieldName.startsWith('$')) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`);
|
||||
}
|
||||
const name = fieldName.substring(1);
|
||||
validateAggregateFieldName(name);
|
||||
return name;
|
||||
};
|
||||
|
||||
const validateKeys = object => {
|
||||
@@ -328,6 +339,14 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus
|
||||
patterns.push(`$${index}:raw = $${index + 1}::text`);
|
||||
values.push(name, fieldValue);
|
||||
index += 2;
|
||||
} else if (
|
||||
typeof fieldValue === 'object' &&
|
||||
!Object.keys(fieldValue).some(key => key.startsWith('$'))
|
||||
) {
|
||||
name = transformDotFieldToComponents(fieldName).join('->');
|
||||
patterns.push(`($${index}:raw)::jsonb = $${index + 1}::jsonb`);
|
||||
values.push(name, JSON.stringify(fieldValue));
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
} else if (fieldValue === null || fieldValue === undefined) {
|
||||
@@ -2157,12 +2176,18 @@ export class PostgresStorageAdapter implements StorageAdapter {
|
||||
|
||||
async distinct(className: string, schema: SchemaType, query: QueryType, fieldName: string) {
|
||||
debug('distinct');
|
||||
const fieldSegments = fieldName.split('.');
|
||||
for (const segment of fieldSegments) {
|
||||
if (!segment.match(/^[a-zA-Z][a-zA-Z0-9_]*$/)) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}`);
|
||||
}
|
||||
}
|
||||
let field = fieldName;
|
||||
let column = fieldName;
|
||||
const isNested = fieldName.indexOf('.') >= 0;
|
||||
if (isNested) {
|
||||
field = transformDotFieldToComponents(fieldName).join('->');
|
||||
column = fieldName.split('.')[0];
|
||||
column = fieldSegments[0];
|
||||
}
|
||||
const isArrayField =
|
||||
schema.fields && schema.fields[fieldName] && schema.fields[fieldName].type === 'Array';
|
||||
|
||||
+7
-1
@@ -424,7 +424,13 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => {
|
||||
providers.map(async provider => {
|
||||
const providerAuthData = authData[provider];
|
||||
|
||||
const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter;
|
||||
const validatorConfig = config.authDataManager.getValidatorForProvider(provider);
|
||||
// Skip database query for unconfigured providers to avoid unindexed collection scans;
|
||||
// the provider will be rejected later in handleAuthDataValidation with UNSUPPORTED_SERVICE
|
||||
if (!validatorConfig?.validator) {
|
||||
return null;
|
||||
}
|
||||
const adapter = validatorConfig.adapter;
|
||||
if (beforeFind && typeof adapter?.beforeFind === 'function') {
|
||||
await adapter.beforeFind(providerAuthData);
|
||||
}
|
||||
|
||||
@@ -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,11 +1,10 @@
|
||||
import corsMiddleware from 'cors';
|
||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { expressMiddleware } from '@apollo/server/express4';
|
||||
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
|
||||
import express from 'express';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
|
||||
import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
|
||||
import requiredParameter from '../requiredParameter';
|
||||
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
|
||||
import defaultLogger from '../logger';
|
||||
@@ -76,8 +75,7 @@ class ParseGraphQLServer {
|
||||
try {
|
||||
return {
|
||||
schema: await this.parseGraphQLSchema.load(),
|
||||
context: async ({ req, res }) => {
|
||||
res.set('access-control-allow-origin', req.get('origin') || '*');
|
||||
context: async ({ req }) => {
|
||||
return {
|
||||
info: req.info,
|
||||
config: req.config,
|
||||
@@ -162,7 +160,7 @@ class ParseGraphQLServer {
|
||||
if (!app || !app.use) {
|
||||
requiredParameter('You must provide an Express.js app instance!');
|
||||
}
|
||||
app.use(this.config.graphQLPath, corsMiddleware());
|
||||
app.use(this.config.graphQLPath, allowCrossDomain(this.parseServer.config.appId));
|
||||
app.use(this.config.graphQLPath, handleParseHeaders);
|
||||
app.use(this.config.graphQLPath, handleParseSession);
|
||||
this.applyRequestContextMiddleware(app, this.parseServer.config);
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { GraphQLError } from 'graphql';
|
||||
import logger from '../../logger';
|
||||
|
||||
function calculateQueryComplexity(operation, fragments) {
|
||||
function calculateQueryComplexity(operation, fragments, limits = {}) {
|
||||
let maxDepth = 0;
|
||||
let totalFields = 0;
|
||||
const fragmentCache = new Map();
|
||||
const { maxDepth: allowedMaxDepth, maxFields: allowedMaxFields } = limits;
|
||||
|
||||
function visitSelectionSet(selectionSet, depth, visitedFragments) {
|
||||
if (!selectionSet) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
|
||||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const selection of selectionSet.selections) {
|
||||
if (selection.kind === 'Field') {
|
||||
totalFields++;
|
||||
@@ -23,14 +31,36 @@ function calculateQueryComplexity(operation, fragments) {
|
||||
visitSelectionSet(selection.selectionSet, depth, visitedFragments);
|
||||
} else if (selection.kind === 'FragmentSpread') {
|
||||
const name = selection.name.value;
|
||||
if (fragmentCache.has(name)) {
|
||||
const cached = fragmentCache.get(name);
|
||||
totalFields += cached.fields;
|
||||
const adjustedDepth = depth + cached.maxDepthDelta;
|
||||
if (adjustedDepth > maxDepth) {
|
||||
maxDepth = adjustedDepth;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (visitedFragments.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const fragment = fragments[name];
|
||||
if (fragment) {
|
||||
const branchVisited = new Set(visitedFragments);
|
||||
branchVisited.add(name);
|
||||
visitSelectionSet(fragment.selectionSet, depth, branchVisited);
|
||||
if (
|
||||
(allowedMaxFields !== undefined && allowedMaxFields !== -1 && totalFields > allowedMaxFields) ||
|
||||
(allowedMaxDepth !== undefined && allowedMaxDepth !== -1 && maxDepth > allowedMaxDepth)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
visitedFragments.add(name);
|
||||
const savedFields = totalFields;
|
||||
const savedMaxDepth = maxDepth;
|
||||
maxDepth = depth;
|
||||
visitSelectionSet(fragment.selectionSet, depth, visitedFragments);
|
||||
const fieldsContribution = totalFields - savedFields;
|
||||
const maxDepthDelta = maxDepth - depth;
|
||||
fragmentCache.set(name, { fields: fieldsContribution, maxDepthDelta });
|
||||
maxDepth = Math.max(savedMaxDepth, maxDepth);
|
||||
visitedFragments.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +99,8 @@ function createComplexityValidationPlugin(getConfig) {
|
||||
|
||||
const { depth, fields } = calculateQueryComplexity(
|
||||
requestContext.operation,
|
||||
fragments
|
||||
fragments,
|
||||
{ maxDepth: graphQLDepth, maxFields: graphQLFields }
|
||||
);
|
||||
|
||||
if (graphQLDepth !== -1 && depth > graphQLDepth) {
|
||||
|
||||
@@ -206,18 +206,23 @@ class ParseLiveQueryServer {
|
||||
continue;
|
||||
}
|
||||
requestIds.forEach(async requestId => {
|
||||
// Deep-clone shared object so each concurrent callback works on its own copy
|
||||
let localDeletedParseObject = JSON.parse(JSON.stringify(deletedParseObject));
|
||||
const acl = message.currentParseObject.getACL();
|
||||
// Check CLP
|
||||
const op = this._getCLPOperation(subscription.query);
|
||||
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;
|
||||
@@ -225,7 +230,7 @@ class ParseLiveQueryServer {
|
||||
res = {
|
||||
event: 'delete',
|
||||
sessionToken: client.sessionToken,
|
||||
object: deletedParseObject,
|
||||
object: localDeletedParseObject,
|
||||
clients: this.clients.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
useMasterKey: client.hasMasterKey,
|
||||
@@ -247,8 +252,9 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
localDeletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
res.object = localDeletedParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -257,7 +263,7 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
client.pushDelete(requestId, res.object);
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId);
|
||||
@@ -313,6 +319,13 @@ class ParseLiveQueryServer {
|
||||
continue;
|
||||
}
|
||||
requestIds.forEach(async requestId => {
|
||||
// Deep-clone shared objects so each concurrent callback works on its own copy.
|
||||
// Without cloning, _filterSensitiveData's in-place field deletion and afterEvent
|
||||
// trigger modifications corrupt the shared state across concurrent subscribers.
|
||||
let localCurrentParseObject = JSON.parse(JSON.stringify(currentParseObject));
|
||||
let localOriginalParseObject = originalParseObject
|
||||
? JSON.parse(JSON.stringify(originalParseObject))
|
||||
: null;
|
||||
// Set orignal ParseObject ACL checking promise, if the object does not match
|
||||
// subscription, we do not need to check ACL
|
||||
let originalACLCheckingPromise;
|
||||
@@ -337,21 +350,24 @@ 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,
|
||||
]);
|
||||
logger.verbose(
|
||||
'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s',
|
||||
originalParseObject,
|
||||
currentParseObject,
|
||||
localOriginalParseObject,
|
||||
localCurrentParseObject,
|
||||
isOriginalSubscriptionMatched,
|
||||
isCurrentSubscriptionMatched,
|
||||
isOriginalMatched,
|
||||
@@ -365,7 +381,7 @@ class ParseLiveQueryServer {
|
||||
} else if (isOriginalMatched && !isCurrentMatched) {
|
||||
type = 'leave';
|
||||
} else if (!isOriginalMatched && isCurrentMatched) {
|
||||
if (originalParseObject) {
|
||||
if (localOriginalParseObject) {
|
||||
type = 'enter';
|
||||
} else {
|
||||
type = 'create';
|
||||
@@ -380,8 +396,8 @@ class ParseLiveQueryServer {
|
||||
res = {
|
||||
event: type,
|
||||
sessionToken: client.sessionToken,
|
||||
object: currentParseObject,
|
||||
original: originalParseObject,
|
||||
object: localCurrentParseObject,
|
||||
original: localOriginalParseObject,
|
||||
clients: this.clients.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
useMasterKey: client.hasMasterKey,
|
||||
@@ -406,14 +422,16 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
currentParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
localCurrentParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
if (res.original && typeof res.original.toJSON === 'function') {
|
||||
originalParseObject = toJSONwithObjects(
|
||||
localOriginalParseObject = toJSONwithObjects(
|
||||
res.original,
|
||||
res.original.className || className
|
||||
);
|
||||
}
|
||||
res.object = localCurrentParseObject;
|
||||
res.original = localOriginalParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -424,7 +442,7 @@ class ParseLiveQueryServer {
|
||||
);
|
||||
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
|
||||
if (client[functionName]) {
|
||||
client[functionName](requestId, currentParseObject, originalParseObject);
|
||||
client[functionName](requestId, res.object, res.original ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
@@ -537,6 +555,16 @@ class ParseLiveQueryServer {
|
||||
if (typeof where !== 'object' || where === null) {
|
||||
return;
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (where[op] !== undefined && !Array.isArray(where[op])) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`);
|
||||
}
|
||||
if (Array.isArray(where[op])) {
|
||||
where[op].forEach((subQuery: any) => {
|
||||
this._validateQueryConstraints(subQuery);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(where)) {
|
||||
const constraint = where[key];
|
||||
if (typeof constraint === 'object' && constraint !== null) {
|
||||
@@ -564,18 +592,6 @@ class ParseLiveQueryServer {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(constraint[op])) {
|
||||
constraint[op].forEach((subQuery: any) => {
|
||||
this._validateQueryConstraints(subQuery);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(where[key])) {
|
||||
where[key].forEach((subQuery: any) => {
|
||||
this._validateQueryConstraints(subQuery);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,8 +669,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 +683,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(
|
||||
@@ -690,7 +768,9 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
let protectedFields = classLevelPermissions?.protectedFields || [];
|
||||
if (!client.hasMasterKey && !Array.isArray(protectedFields)) {
|
||||
if (client.hasMasterKey) {
|
||||
protectedFields = [];
|
||||
} else if (!Array.isArray(protectedFields)) {
|
||||
protectedFields = getDatabaseController(this.config).addProtectedFields(
|
||||
classLevelPermissions,
|
||||
res.object.className,
|
||||
@@ -949,8 +1029,38 @@ 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 (where[op] !== undefined && !Array.isArray(where[op])) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`);
|
||||
}
|
||||
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);
|
||||
@@ -976,7 +1086,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 =
|
||||
@@ -1002,6 +1112,9 @@ class ParseLiveQueryServer {
|
||||
}
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (where[op] !== undefined && !Array.isArray(where[op])) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`);
|
||||
}
|
||||
if (Array.isArray(where[op])) {
|
||||
where[op].forEach((subQuery: any) => checkWhere(subQuery));
|
||||
}
|
||||
@@ -1009,6 +1122,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
|
||||
|
||||
@@ -206,6 +206,9 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
}
|
||||
var i;
|
||||
if (key === '$or') {
|
||||
if (!Array.isArray(constraints)) {
|
||||
return false;
|
||||
}
|
||||
for (i = 0; i < constraints.length; i++) {
|
||||
if (matchesQuery(object, constraints[i])) {
|
||||
return true;
|
||||
@@ -214,6 +217,9 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
return false;
|
||||
}
|
||||
if (key === '$and') {
|
||||
if (!Array.isArray(constraints)) {
|
||||
return false;
|
||||
}
|
||||
for (i = 0; i < constraints.length; i++) {
|
||||
if (!matchesQuery(object, constraints[i])) {
|
||||
return false;
|
||||
@@ -222,6 +228,9 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
return true;
|
||||
}
|
||||
if (key === '$nor') {
|
||||
if (!Array.isArray(constraints)) {
|
||||
return false;
|
||||
}
|
||||
for (i = 0; i < constraints.length; i++) {
|
||||
if (matchesQuery(object, constraints[i])) {
|
||||
return false;
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -871,6 +904,13 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () {
|
||||
}
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (where[op] !== undefined && !Array.isArray(where[op])) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.INVALID_QUERY,
|
||||
`${op} must be an array`,
|
||||
this.config
|
||||
);
|
||||
}
|
||||
if (Array.isArray(where[op])) {
|
||||
where[op].forEach(subQuery => checkWhere(subQuery));
|
||||
}
|
||||
|
||||
+61
-20
@@ -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,
|
||||
@@ -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,
|
||||
@@ -644,6 +650,15 @@ RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
// We are supposed to have a response only on LOGIN with authData, so we skip those
|
||||
// If we're not logging in, but just updating the current user, we can safely skip that part
|
||||
if (this.response) {
|
||||
// Capture original authData before mutating userResult via the response reference
|
||||
const originalAuthData = userResult?.authData
|
||||
? Object.fromEntries(
|
||||
Object.entries(userResult.authData).map(([k, v]) =>
|
||||
[k, v && typeof v === 'object' ? { ...v } : v]
|
||||
)
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Assign the new authData in the response
|
||||
Object.keys(mutatedAuthData).forEach(provider => {
|
||||
this.response.response.authData[provider] = mutatedAuthData[provider];
|
||||
@@ -654,14 +669,36 @@ RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
// uses the `doNotSave` option. Just update the authData part
|
||||
// Then we're good for the user, early exit of sorts
|
||||
if (Object.keys(this.data.authData).length) {
|
||||
const query = { objectId: this.data.objectId };
|
||||
// Optimistic locking: include the original array fields in the WHERE clause
|
||||
// for providers whose data is being updated. This prevents concurrent requests
|
||||
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
|
||||
if (originalAuthData) {
|
||||
for (const provider of Object.keys(this.data.authData)) {
|
||||
const original = originalAuthData[provider];
|
||||
if (original && typeof original === 'object') {
|
||||
for (const [field, value] of Object.entries(original)) {
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
JSON.stringify(value) !== JSON.stringify(this.data.authData[provider]?.[field])
|
||||
) {
|
||||
query[`authData.${provider}.${field}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.config.database.update(
|
||||
this.className,
|
||||
{ objectId: this.data.objectId },
|
||||
query,
|
||||
{ authData: this.data.authData },
|
||||
{}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === Parse.Error.OBJECT_NOT_FOUND) {
|
||||
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data');
|
||||
}
|
||||
this._throwIfAuthDataDuplicate(error);
|
||||
throw error;
|
||||
}
|
||||
@@ -1137,9 +1174,13 @@ RestWrite.prototype.handleSession = function () {
|
||||
if (this.query) {
|
||||
if (this.data.user && !this.auth.isMaster && this.data.user.objectId != this.auth.user.id) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.installationId) {
|
||||
} else if ('installationId' in this.data) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.sessionToken) {
|
||||
} else if ('sessionToken' in this.data) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if ('expiresAt' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if ('createdWith' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
}
|
||||
if (!this.auth.isMaster) {
|
||||
|
||||
@@ -48,6 +48,9 @@ export class AggregateRouter extends ClassesRouter {
|
||||
}
|
||||
return { response };
|
||||
} catch (e) {
|
||||
if (e instanceof Parse.Error) {
|
||||
throw e;
|
||||
}
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -551,6 +555,16 @@ export class PagesRouter extends PromiseRouter {
|
||||
(req.body || {})[pageParams.locale] ||
|
||||
(req.params || {})[pageParams.locale] ||
|
||||
(req.headers || {})[pageParamHeaderPrefix + pageParams.locale];
|
||||
|
||||
// Validate locale format to prevent path traversal and invalid
|
||||
// HTTP header characters; only allow standard locale patterns
|
||||
// like "en", "en-US", "de-AT", "zh-Hans-CN"
|
||||
if (locale !== undefined && typeof locale !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof locale === 'string' && !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(locale)) {
|
||||
return undefined;
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,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}`,
|
||||
|
||||
+71
-32
@@ -170,34 +170,50 @@ export class UsersRouter extends ClassesRouter {
|
||||
});
|
||||
}
|
||||
|
||||
handleMe(req) {
|
||||
async handleMe(req) {
|
||||
if (!req.info || !req.info.sessionToken) {
|
||||
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
|
||||
}
|
||||
const sessionToken = req.info.sessionToken;
|
||||
return rest
|
||||
.find(
|
||||
req.config,
|
||||
Auth.master(req.config),
|
||||
'_Session',
|
||||
{ sessionToken },
|
||||
{ include: 'user' },
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
if (!response.results || response.results.length == 0 || !response.results[0].user) {
|
||||
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
|
||||
} else {
|
||||
const user = response.results[0].user;
|
||||
// Send token back on the login, because SDKs expect that.
|
||||
user.sessionToken = sessionToken;
|
||||
|
||||
// Remove hidden properties.
|
||||
UsersRouter.removeHiddenProperties(user);
|
||||
return { response: user };
|
||||
}
|
||||
});
|
||||
// Query the session with master key to validate the session token,
|
||||
// but do NOT include 'user' to avoid leaking user data via master context
|
||||
const sessionResponse = await rest.find(
|
||||
req.config,
|
||||
Auth.master(req.config),
|
||||
'_Session',
|
||||
{ sessionToken },
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (
|
||||
!sessionResponse.results ||
|
||||
sessionResponse.results.length == 0 ||
|
||||
!sessionResponse.results[0].user
|
||||
) {
|
||||
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
|
||||
}
|
||||
const userId = sessionResponse.results[0].user.objectId;
|
||||
// Re-fetch the user with the caller's auth context so that
|
||||
// protectedFields, CLP, and auth adapter afterFind apply correctly
|
||||
const userResponse = await rest.get(
|
||||
req.config,
|
||||
req.auth,
|
||||
'_User',
|
||||
userId,
|
||||
{},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (!userResponse.results || userResponse.results.length == 0) {
|
||||
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
|
||||
}
|
||||
const user = userResponse.results[0];
|
||||
// Send token back on the login, because SDKs expect that.
|
||||
user.sessionToken = sessionToken;
|
||||
// Remove hidden properties.
|
||||
UsersRouter.removeHiddenProperties(user);
|
||||
return { response: user };
|
||||
}
|
||||
|
||||
async handleLogIn(req) {
|
||||
@@ -280,12 +296,35 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
// If we have some new validated authData update directly
|
||||
if (validatedAuthData && Object.keys(validatedAuthData).length) {
|
||||
await req.config.database.update(
|
||||
'_User',
|
||||
{ objectId: user.objectId },
|
||||
{ authData: validatedAuthData },
|
||||
{}
|
||||
);
|
||||
const query = { objectId: user.objectId };
|
||||
// Optimistic locking: include the original array fields in the WHERE clause
|
||||
// for providers whose data is being updated. This prevents concurrent requests
|
||||
// from both succeeding when consuming single-use tokens (e.g. MFA recovery codes).
|
||||
// Only array fields need locking — element removal is vulnerable to TOCTOU;
|
||||
// scalar fields are simply overwritten and don't have concurrency issues.
|
||||
if (user.authData) {
|
||||
for (const provider of Object.keys(validatedAuthData)) {
|
||||
const original = user.authData[provider];
|
||||
if (original && typeof original === 'object') {
|
||||
for (const [field, value] of Object.entries(original)) {
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
JSON.stringify(value) !== JSON.stringify(validatedAuthData[provider]?.[field])
|
||||
) {
|
||||
query[`authData.${provider}.${field}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await req.config.database.update('_User', query, { authData: validatedAuthData }, {});
|
||||
} catch (error) {
|
||||
if (error.code === Parse.Error.OBJECT_NOT_FOUND) {
|
||||
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const { sessionData, createSession } = RestWrite.createSession(req.config, {
|
||||
@@ -383,10 +422,10 @@ export class UsersRouter extends ClassesRouter {
|
||||
|
||||
handleVerifyPassword(req) {
|
||||
return this._authenticateUserFromRequest(req)
|
||||
.then(user => {
|
||||
.then(async user => {
|
||||
// Remove hidden properties.
|
||||
UsersRouter.removeHiddenProperties(user);
|
||||
|
||||
await req.config.authDataManager.runAfterFind(req, user.authData);
|
||||
return { response: user };
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
+7
-1
@@ -109,8 +109,11 @@ 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) {
|
||||
if (!store || Object.getPrototypeOf(store) !== null) {
|
||||
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