mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19611b7e93 | ||
|
|
bc863d7085 | ||
|
|
c14da8141b | ||
|
|
6985a388e0 | ||
|
|
dbd4281744 | ||
|
|
2728fcbabc | ||
|
|
e66482b00e | ||
|
|
55eab321fa | ||
|
|
700a86760f | ||
|
|
c9b24cecfe | ||
|
|
9c8063175b | ||
|
|
0f5d2ad77b | ||
|
|
bccd92c768 | ||
|
|
97c6a78d19 | ||
|
|
c78cae6f09 | ||
|
|
efef11bc2d | ||
|
|
d0048ff8d2 | ||
|
|
9e992797eb | ||
|
|
4a7fd10bae | ||
|
|
a0ddb850e1 | ||
|
|
baa153c490 | ||
|
|
8523425525 | ||
|
|
ea543b1049 | ||
|
|
8681c600c5 | ||
|
|
1b2107eb73 | ||
|
|
6ecc6422c8 | ||
|
|
4b6966106f | ||
|
|
1be6c97494 | ||
|
|
1750456f82 | ||
|
|
0de3e9f4bd | ||
|
|
8191b6d3e6 | ||
|
|
18a1560d48 | ||
|
|
9acb5cf202 | ||
|
|
053109b3ee | ||
|
|
83e7949196 | ||
|
|
ffad0ec6b9 | ||
|
|
9702abd3dd | ||
|
|
ebccd7fe27 | ||
|
|
fbd138cc26 | ||
|
|
ea15412795 | ||
|
|
7e4b4c13f9 | ||
|
|
4fc48cf28f | ||
|
|
4fda17ccc1 | ||
|
|
0347641507 | ||
|
|
9793e8fbb2 | ||
|
|
5834e29234 | ||
|
|
cf886438e6 | ||
|
|
661f160eda | ||
|
|
a536a850b9 | ||
|
|
a1d4e7b12a | ||
|
|
4ff8b79922 | ||
|
|
e047da961a | ||
|
|
99fcbb3a80 | ||
|
|
5b8998e686 | ||
|
|
372a6cc613 | ||
|
|
fc3da35a81 |
@@ -557,6 +557,40 @@ async function benchmarkObjectCreateNestedDenylist(name) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: $relatedTo relation query (public, non-master)
|
||||
*
|
||||
* Measures a public `$relatedTo` query, which now performs an owning-object
|
||||
* read-access check before reading the relation join table (GHSA-wmwx-jr2p-4j4r).
|
||||
* This captures the cost of that added authorization read on the relation path.
|
||||
*/
|
||||
async function benchmarkRelatedToQuery(name) {
|
||||
const Child = Parse.Object.extend('BenchmarkRelChild');
|
||||
const children = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
children.push(new Child({ value: i }));
|
||||
}
|
||||
await Parse.Object.saveAll(children, { useMasterKey: true });
|
||||
|
||||
// Publicly readable owning object, so the authorized relation path runs fully.
|
||||
const Parent = Parse.Object.extend('BenchmarkRelParent');
|
||||
const parent = new Parent({ name: 'benchmark-parent' });
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(true);
|
||||
parent.setACL(acl);
|
||||
parent.relation('members').add(children);
|
||||
await parent.save(null, { useMasterKey: true });
|
||||
|
||||
return measureOperation({
|
||||
name,
|
||||
iterations: 1_000,
|
||||
operation: async () => {
|
||||
// Non-master query exercises the owning-object read-access check.
|
||||
await parent.relation('members').query().find();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all benchmarks
|
||||
*/
|
||||
@@ -582,6 +616,7 @@ async function runBenchmarks() {
|
||||
{ name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave },
|
||||
{ name: 'Query.get (by objectId)', fn: benchmarkObjectRead },
|
||||
{ name: 'Query.find (simple query)', fn: benchmarkSimpleQuery },
|
||||
{ name: 'Query.find ($relatedTo relation)', fn: benchmarkRelatedToQuery },
|
||||
{ name: 'User.signUp', fn: benchmarkUserSignup },
|
||||
{ name: 'User.login', fn: benchmarkUserLogin },
|
||||
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
|
||||
|
||||
@@ -1,3 +1,199 @@
|
||||
## [8.6.87](https://github.com/parse-community/parse-server/compare/8.6.86...8.6.87) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) ([#10569](https://github.com/parse-community/parse-server/issues/10569)) ([bc863d7](https://github.com/parse-community/parse-server/commit/bc863d70852fdd775b26a6dc5146ae5088f87779))
|
||||
|
||||
## [8.6.86](https://github.com/parse-community/parse-server/compare/8.6.85...8.6.86) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL error messages disclose required input field names when public introspection is disabled ([GHSA-2fgh-8j2g-w354](https://github.com/parse-community/parse-server/security/advisories/GHSA-2fgh-8j2g-w354)) ([#10567](https://github.com/parse-community/parse-server/issues/10567)) ([6985a38](https://github.com/parse-community/parse-server/commit/6985a388e0c4cfa54f914aa9ee7371fc0095f69e)), closes [GHSA-2f#8j2g-w354](https://github.com/GHSA-2f/issues/8j2g-w354) [/github.com/parse-community/parse-server/security/advisories/GHSA-2f#8j2g-w354](https://github.com//github.com/parse-community/parse-server/security/advisories/GHSA-2f/issues/8j2g-w354)
|
||||
|
||||
## [8.6.85](https://github.com/parse-community/parse-server/compare/8.6.84...8.6.85) (2026-07-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) ([#10564](https://github.com/parse-community/parse-server/issues/10564)) ([2728fcb](https://github.com/parse-community/parse-server/commit/2728fcbabcd8462e92cb49f45fdb875dd9543347))
|
||||
|
||||
## [8.6.84](https://github.com/parse-community/parse-server/compare/8.6.83...8.6.84) (2026-06-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via malformed Content-Type bypassing file upload extension blocklist ([GHSA-r899-h629-j84r](https://github.com/parse-community/parse-server/security/advisories/GHSA-r899-h629-j84r)) ([#10523](https://github.com/parse-community/parse-server/issues/10523)) ([55eab32](https://github.com/parse-community/parse-server/commit/55eab321fa02ad886e38ad2a19b101c9868e6757))
|
||||
|
||||
## [8.6.83](https://github.com/parse-community/parse-server/compare/8.6.82...8.6.83) (2026-06-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery discloses object data to a subscriber across an ACL read-access change ([GHSA-97pr-9hgg-3p8r](https://github.com/parse-community/parse-server/security/advisories/GHSA-97pr-9hgg-3p8r)) ([#10516](https://github.com/parse-community/parse-server/issues/10516)) ([c9b24ce](https://github.com/parse-community/parse-server/commit/c9b24cecfee76d8563019adaacbcbd78471dc41e))
|
||||
|
||||
## [8.6.82](https://github.com/parse-community/parse-server/compare/8.6.81...8.6.82) (2026-06-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial of service via exponential-time processing of deeply nested query operators ([GHSA-cgxm-vr2f-6fj8](https://github.com/parse-community/parse-server/security/advisories/GHSA-cgxm-vr2f-6fj8)) ([#10512](https://github.com/parse-community/parse-server/issues/10512)) ([0f5d2ad](https://github.com/parse-community/parse-server/commit/0f5d2ad77b422dc904458254548be87397fc6e9b))
|
||||
|
||||
## [8.6.81](https://github.com/parse-community/parse-server/compare/8.6.80...8.6.81) (2026-06-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via non-standard file extension bypassing file upload extension blocklist ([GHSA-v8x7-r927-cc93](https://github.com/parse-community/parse-server/security/advisories/GHSA-v8x7-r927-cc93)) ([#10506](https://github.com/parse-community/parse-server/issues/10506)) ([97c6a78](https://github.com/parse-community/parse-server/commit/97c6a78d19f976ec756c1295f08a8fccab90799a))
|
||||
|
||||
## [8.6.80](https://github.com/parse-community/parse-server/compare/8.6.79...8.6.80) (2026-06-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Relation `$relatedTo` query bypasses `protectedFields` and owning-object ACL ([GHSA-wmwx-jr2p-4j4r](https://github.com/parse-community/parse-server/security/advisories/GHSA-wmwx-jr2p-4j4r)) ([#10494](https://github.com/parse-community/parse-server/issues/10494)) ([efef11b](https://github.com/parse-community/parse-server/commit/efef11bc2dac50b994607adca66e2901075ab640))
|
||||
|
||||
## [8.6.79](https://github.com/parse-community/parse-server/compare/8.6.78...8.6.79) (2026-06-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via trailing-dot filename bypassing file upload extension blocklist ([GHSA-7wqv-xjf3-x35v](https://github.com/parse-community/parse-server/security/advisories/GHSA-7wqv-xjf3-x35v)) ([#10490](https://github.com/parse-community/parse-server/issues/10490)) ([9e99279](https://github.com/parse-community/parse-server/commit/9e992797ebd47df8143d4530fce4cc46fefb6532))
|
||||
|
||||
## [8.6.78](https://github.com/parse-community/parse-server/compare/8.6.77...8.6.78) (2026-05-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL "Did you mean" validation suggestions disclose schema to unauthenticated callers ([GHSA-8cph-rgr4-g5vj](https://github.com/parse-community/parse-server/security/advisories/GHSA-8cph-rgr4-g5vj)) ([#10468](https://github.com/parse-community/parse-server/issues/10468)) ([a0ddb85](https://github.com/parse-community/parse-server/commit/a0ddb850e1060908f7aac3755861fb4a3d364127))
|
||||
|
||||
## [8.6.77](https://github.com/parse-community/parse-server/compare/8.6.76...8.6.77) (2026-05-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Pre-authentication denial of service via client version header regex backtracking ([GHSA-38m6-82c8-4xfm](https://github.com/parse-community/parse-server/security/advisories/GHSA-38m6-82c8-4xfm)) ([#10464](https://github.com/parse-community/parse-server/issues/10464)) ([8523425](https://github.com/parse-community/parse-server/commit/8523425525a16bbff13fa9718ca356a7e48caa0d))
|
||||
|
||||
## [8.6.76](https://github.com/parse-community/parse-server/compare/8.6.75...8.6.76) (2026-04-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MFA SMS one-time password accepted twice under concurrent login ([GHSA-jpq4-7fmq-q5fj](https://github.com/parse-community/parse-server/security/advisories/GHSA-jpq4-7fmq-q5fj)) ([#10449](https://github.com/parse-community/parse-server/issues/10449)) ([8681c60](https://github.com/parse-community/parse-server/commit/8681c600c5fe2924c9d6ae805482bbb2896d2958))
|
||||
|
||||
## [8.6.75](https://github.com/parse-community/parse-server/compare/8.6.74...8.6.75) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Endpoint `/sessions/me` bypasses `_Session` `protectedFields` ([GHSA-g4v2-qx3q-4p64](https://github.com/parse-community/parse-server/security/advisories/GHSA-g4v2-qx3q-4p64)) ([#10407](https://github.com/parse-community/parse-server/issues/10407)) ([6ecc642](https://github.com/parse-community/parse-server/commit/6ecc6422c8624ba89a0fa71090d2c5f45d07d8de))
|
||||
|
||||
## [8.6.74](https://github.com/parse-community/parse-server/compare/8.6.73...8.6.74) (2026-04-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Login timing side-channel reveals user existence ([GHSA-mmpq-5hcv-hf2v](https://github.com/parse-community/parse-server/security/advisories/GHSA-mmpq-5hcv-hf2v)) ([#10399](https://github.com/parse-community/parse-server/issues/10399)) ([1be6c97](https://github.com/parse-community/parse-server/commit/1be6c97494ab31cb93107c501d619c4f8b7d8e56))
|
||||
|
||||
## [8.6.73](https://github.com/parse-community/parse-server/compare/8.6.72...8.6.73) (2026-04-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* File upload Content-Type override via extension mismatch ([GHSA-vr5f-2r24-w5hc](https://github.com/parse-community/parse-server/security/advisories/GHSA-vr5f-2r24-w5hc)) ([#10384](https://github.com/parse-community/parse-server/issues/10384)) ([0de3e9f](https://github.com/parse-community/parse-server/commit/0de3e9f4bd477b0f0866f519974f513060876c04))
|
||||
|
||||
## [8.6.72](https://github.com/parse-community/parse-server/compare/8.6.71...8.6.72) (2026-03-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Security upgrade @apollo/server from 4.12.1 to 4.13.0 ([#10082](https://github.com/parse-community/parse-server/issues/10082)) ([18a1560](https://github.com/parse-community/parse-server/commit/18a1560d480d5bdcacbc24dbb7e45c02d7f93613))
|
||||
|
||||
## [8.6.71](https://github.com/parse-community/parse-server/compare/8.6.70...8.6.71) (2026-03-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](https://github.com/parse-community/parse-server/security/advisories/GHSA-hpm8-9qx6-jvwv)) ([#10362](https://github.com/parse-community/parse-server/issues/10362)) ([053109b](https://github.com/parse-community/parse-server/commit/053109b3ee71815bc39ed84116c108ff9edbf337))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
Generated
+12
-210
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.59",
|
||||
"version": "8.6.87",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "8.6.59",
|
||||
"version": "8.6.87",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@apollo/server": "4.12.1",
|
||||
"@apollo/server": "4.13.0",
|
||||
"@graphql-tools/merge": "9.0.24",
|
||||
"@graphql-tools/schema": "10.0.23",
|
||||
"@graphql-tools/utils": "10.8.6",
|
||||
@@ -235,9 +235,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@apollo/server": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.12.1.tgz",
|
||||
"integrity": "sha512-jceK1LK6l074in4mWu8XkwzDjvxdSzkmk8M7YpVLOGjQ6PmlaChcGRJ9BUwB46r22MxdPCXdC/6U5Vi8gzYbvQ==",
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.13.0.tgz",
|
||||
"integrity": "sha512-t4GzaRiYIcPwYy40db6QjZzgvTr9ztDKBddykUXmBb2SVjswMKXbkaJ5nPeHqmT3awr9PAaZdCZdZhRj55I/8A==",
|
||||
"deprecated": "Apollo Server v4 is end-of-life since January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apollo/cache-control-types": "^1.0.3",
|
||||
@@ -255,6 +256,7 @@
|
||||
"@types/express-serve-static-core": "^4.17.30",
|
||||
"@types/node-fetch": "^2.6.1",
|
||||
"async-retry": "^1.2.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.1",
|
||||
"loglevel": "^1.6.8",
|
||||
@@ -11694,88 +11696,6 @@
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
|
||||
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"gaxios": "^5.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata/node_modules/gaxios": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
|
||||
"integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/gcp-metadata/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/gcp-metadata/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
@@ -12850,13 +12770,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -21080,32 +20993,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
|
||||
"integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ip": "^2.0.0",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -23268,9 +23155,9 @@
|
||||
}
|
||||
},
|
||||
"@apollo/server": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.12.1.tgz",
|
||||
"integrity": "sha512-jceK1LK6l074in4mWu8XkwzDjvxdSzkmk8M7YpVLOGjQ6PmlaChcGRJ9BUwB46r22MxdPCXdC/6U5Vi8gzYbvQ==",
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@apollo/server/-/server-4.13.0.tgz",
|
||||
"integrity": "sha512-t4GzaRiYIcPwYy40db6QjZzgvTr9ztDKBddykUXmBb2SVjswMKXbkaJ5nPeHqmT3awr9PAaZdCZdZhRj55I/8A==",
|
||||
"requires": {
|
||||
"@apollo/cache-control-types": "^1.0.3",
|
||||
"@apollo/server-gateway-interface": "^1.1.1",
|
||||
@@ -23287,6 +23174,7 @@
|
||||
"@types/express-serve-static-core": "^4.17.30",
|
||||
"@types/node-fetch": "^2.6.1",
|
||||
"async-retry": "^1.2.1",
|
||||
"content-type": "^1.0.5",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.1",
|
||||
"loglevel": "^1.6.8",
|
||||
@@ -31232,67 +31120,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
|
||||
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"gaxios": "^5.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gaxios": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
|
||||
"integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9"
|
||||
}
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"generic-pool": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
|
||||
@@ -32053,13 +31880,6 @@
|
||||
"p-is-promise": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"ip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -37780,24 +37600,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"socks": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz",
|
||||
"integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"ip": "^2.0.0",
|
||||
"smart-buffer": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.59",
|
||||
"version": "8.6.87",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
@@ -20,7 +20,7 @@
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@apollo/server": "4.12.1",
|
||||
"@apollo/server": "4.13.0",
|
||||
"@graphql-tools/merge": "9.0.24",
|
||||
"@graphql-tools/schema": "10.0.23",
|
||||
"@graphql-tools/utils": "10.8.6",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
const ClientSDK = require('../lib/ClientSDK');
|
||||
|
||||
describe('ClientSDK', () => {
|
||||
it('should properly parse the SDK versions', () => {
|
||||
const clientSDKFromVersion = ClientSDK.fromString;
|
||||
expect(clientSDKFromVersion('i1.1.1')).toEqual({
|
||||
sdk: 'i',
|
||||
version: '1.1.1',
|
||||
});
|
||||
expect(clientSDKFromVersion('i1')).toEqual({
|
||||
sdk: 'i',
|
||||
version: '1',
|
||||
});
|
||||
expect(clientSDKFromVersion('apple-tv1.13.0')).toEqual({
|
||||
sdk: 'apple-tv',
|
||||
version: '1.13.0',
|
||||
});
|
||||
expect(clientSDKFromVersion('js1.9.0')).toEqual({
|
||||
sdk: 'js',
|
||||
version: '1.9.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly sastisfy', () => {
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})('js1.9.0')
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})('js2.0.0')
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})('js1.8.0')
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
ClientSDK.compatible({
|
||||
js: '>=1.9.0',
|
||||
})(undefined)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,6 @@ describe('middlewares', () => {
|
||||
});
|
||||
|
||||
const BodyParams = {
|
||||
clientVersion: '_ClientVersion',
|
||||
installationId: '_InstallationId',
|
||||
sessionToken: '_SessionToken',
|
||||
masterKey: '_MasterKey',
|
||||
|
||||
@@ -1396,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 () => {
|
||||
|
||||
@@ -1490,6 +1490,261 @@ describe('Parse.File testing', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('default should block non-standard extension variants preserving a dangerous content type', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const svgContent = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
).toString('base64');
|
||||
const filenames = [
|
||||
'malicious.svg~',
|
||||
'malicious.svg.tmp',
|
||||
'malicious.svg.bak',
|
||||
'malicious.svg.backup',
|
||||
'malicious.xhtml.bak',
|
||||
'malicious.xml.tmp',
|
||||
];
|
||||
for (const filename of filenames) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/files/${filename}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(
|
||||
Parse.Error.FILE_SAVE_ERROR,
|
||||
`File upload of extension svg+xml is disabled.`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block non-standard extension variants preserving a text/html content type', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<html><script>alert(1)</script></html>').toString('base64');
|
||||
const filenames = ['malicious.html.old', 'malicious.htm~', 'malicious.html.bak'];
|
||||
for (const filename of filenames) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/files/${filename}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'text/html',
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, `File upload of extension html is disabled.`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should allow a non-standard extension with a safe content type', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/archive.bak',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/png',
|
||||
base64: 'ParseA==',
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('default should block a malformed content type with no slash', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
for (const filename of ['note.foo', 'data.bar']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/files/${filename}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image',
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block a malformed content type with an empty subtype', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
for (const filename of ['note.foo', 'data.bar']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: `http://localhost:8378/1/files/${filename}`,
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/',
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('default should block a malformed content type when the filename has no extension', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image',
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
});
|
||||
|
||||
it('allows a malformed content type when all extensions are allowed', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
fileExtensions: ['*'],
|
||||
},
|
||||
});
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note.foo',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image',
|
||||
base64: 'ParseA==',
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('default should allow a valid custom content type the mime package does not recognize', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
// A well-formed `type/subtype` that `mime` does not recognize (e.g. a
|
||||
// vendor type) must still be accepted; only malformed or blocked
|
||||
// Content-Types are rejected.
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note.foo',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'application/vnd.api+json',
|
||||
base64: Buffer.from('{}').toString('base64'),
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('default should block a malformed content type with invalid token characters', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
|
||||
'base64'
|
||||
);
|
||||
// Non-empty but malformed media types (extra slash, comma-separated values,
|
||||
// whitespace) are not valid `type/subtype` tokens (RFC 9110 §5.6.2) and are
|
||||
// sniffed by browsers, so they must be rejected too.
|
||||
for (const contentType of ['image//svg+xml', 'text/plain,text/html', 'image/sv g']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/note.foo',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: contentType,
|
||||
base64: htmlContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(
|
||||
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('works with a period in the file name', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -715,6 +715,718 @@ describe('ParseGraphQLServer', () => {
|
||||
})
|
||||
expect(introspection.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should strip "Did you mean" field suggestions from validation errors without master or maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).not.toMatch(/Did you mean/);
|
||||
expect(message).not.toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip "Did you mean" argument suggestions from validation errors without master or maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query UnknownArg {
|
||||
users(wher: {}) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Unknown argument "wher"');
|
||||
expect(message).not.toMatch(/Did you mean/);
|
||||
expect(message).not.toContain('"where"');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" suggestions with master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).toMatch(/Did you mean/);
|
||||
expect(message).toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" suggestions with maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Maintenance-Key': 'test2',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).toMatch(/Did you mean/);
|
||||
expect(message).toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" suggestions when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Typo {
|
||||
healt
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const message = e.networkError.result.errors[0].message;
|
||||
expect(message).toContain('Cannot query field "healt"');
|
||||
expect(message).toMatch(/Did you mean/);
|
||||
expect(message).toContain('health');
|
||||
}
|
||||
});
|
||||
|
||||
const getReturnedError = e =>
|
||||
(e.networkError && e.networkError.result && e.networkError.result.errors[0]) ||
|
||||
(e.graphQLErrors && e.graphQLErrors[0]);
|
||||
|
||||
it('should strip "Did you mean" enum suggestions from variable-coercion errors without master or maintenance key', async () => {
|
||||
Parse.Cloud.define('secretAdminTask', () => 'ok');
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation LeakFunction($input: CallCloudCodeInput!) {
|
||||
callCloudCode(input: $input) {
|
||||
result
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { functionName: 'secretAdminTas', params: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('CloudCodeFunction');
|
||||
expect(error.message).not.toMatch(/Did you mean/);
|
||||
expect(error.message).not.toContain('secretAdminTask');
|
||||
// The cloud function name must not leak through any returned field
|
||||
// (e.g. a stacktrace duplicated from the original message in non-production).
|
||||
expect(JSON.stringify(error)).not.toContain('secretAdminTask');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip "Did you mean" field suggestions from variable-coercion errors without master or maintenance key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak($where: UserWhereInput) {
|
||||
users(where: $where) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { where: { usernme: { equalTo: 'victim' } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('UserWhereInput');
|
||||
expect(error.message).not.toMatch(/Did you mean/);
|
||||
// JSON.stringify escapes embedded quotes, so assert against the bare
|
||||
// identifier to reliably catch a leak duplicated into extensions.stacktrace.
|
||||
expect(error.message).not.toContain('username');
|
||||
expect(JSON.stringify(error)).not.toContain('username');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" enum suggestions in variable-coercion errors with master key', async () => {
|
||||
Parse.Cloud.define('secretAdminTask', () => 'ok');
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation LeakFunction($input: CallCloudCodeInput!) {
|
||||
callCloudCode(input: $input) {
|
||||
result
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { functionName: 'secretAdminTas', params: {} } },
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toMatch(/Did you mean/);
|
||||
expect(error.message).toContain('secretAdminTask');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep "Did you mean" enum suggestions in variable-coercion errors when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
Parse.Cloud.define('secretAdminTask', () => 'ok');
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation LeakFunction($input: CallCloudCodeInput!) {
|
||||
callCloudCode(input: $input) {
|
||||
result
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { functionName: 'secretAdminTas', params: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toMatch(/Did you mean/);
|
||||
expect(error.message).toContain('secretAdminTask');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip required-field names from base coercion errors without master or maintenance key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateTestReqClassInput!) {
|
||||
createTestReqClass(input: $input) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// The base graphql-js "... was not provided." coercion message carries no
|
||||
// "Did you mean" clause, so it discloses the required custom field name to a
|
||||
// caller who only has the public application id. It must be redacted.
|
||||
expect(error.message).not.toContain('secretRequiredField');
|
||||
// The message is duplicated into extensions.stacktrace in non-production;
|
||||
// ensure the identifier does not leak through any returned field.
|
||||
expect(JSON.stringify(error)).not.toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep required-field names in base coercion errors with master key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateTestReqClassInput!) {
|
||||
createTestReqClass(input: $input) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: {} } },
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep required-field names in base coercion errors when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateTestReqClassInput!) {
|
||||
createTestReqClass(input: $input) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: {} } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip required-field names from inline-literal coercion errors without master or maintenance key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('TestReqClass', {
|
||||
secretRequiredField: { type: 'String', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
// Input written inline in the operation (not via a variable) is validated by
|
||||
// ValuesOfCorrectTypeRule, which emits a type-qualified message
|
||||
// ('Field "<Type>.<field>" of required type ...'), disclosing both the generated
|
||||
// input type name (which embeds the class name) and the required field name.
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create {
|
||||
createTestReqClass(input: { fields: {} }) {
|
||||
testReqClass {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).not.toContain('secretRequiredField');
|
||||
expect(error.message).not.toContain('CreateTestReqClass');
|
||||
expect(JSON.stringify(error)).not.toContain('secretRequiredField');
|
||||
}
|
||||
});
|
||||
|
||||
// A Pointer/Relation field maps to a generated input type whose name embeds the
|
||||
// pointer's TARGET class (`<Target>PointerInput`, `<Target>RelationWhereInput`,
|
||||
// `Create<Target>FieldsInput`). graphql-js interpolates that type name into base
|
||||
// coercion/validation messages that the "Did you mean" and required-field strips do
|
||||
// not touch, disclosing the target class name to a caller who only supplied the
|
||||
// pointer field name (which does not reveal its target). Redact those identifiers
|
||||
// for callers that are not allowed to introspect.
|
||||
const setupPointerSchema = async _parseServer => {
|
||||
const schemaController = await _parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('SecretAuthor', {
|
||||
name: { type: 'String' },
|
||||
});
|
||||
await schemaController.addClassIfNotExists('DiagBook', {
|
||||
writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor' },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
};
|
||||
|
||||
it('should strip pointer target class names from where-clause validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: { writtenBy: 123 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Expected value of type "SecretAuthorRelationWhereInput", found 123.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from non-object variable-coercion errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateDiagBookInput!) {
|
||||
createDiagBook(input: $input) {
|
||||
diagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: { writtenBy: { createAndLink: 5 } } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Expected type "CreateSecretAuthorFieldsInput" to be an object.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from unknown-field variable-coercion errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateDiagBookInput!) {
|
||||
createDiagBook(input: $input) {
|
||||
diagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: { writtenBy: { bogusKey: 1 } } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Field "bogusKey" is not defined by type "SecretAuthorPointerInput".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep pointer target class names in errors with master key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: { writtenBy: 123 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep pointer target class names in errors when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: { writtenBy: 123 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from non-nullable variable-coercion errors without master or maintenance key', async () => {
|
||||
const schemaController = await parseServer.config.databaseController.loadSchema();
|
||||
await schemaController.addClassIfNotExists('SecretAuthor', { name: { type: 'String' } });
|
||||
await schemaController.addClassIfNotExists('ReqDiagBook', {
|
||||
writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor', required: true },
|
||||
});
|
||||
await resetGraphQLCache();
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($input: CreateReqDiagBookInput!) {
|
||||
createReqDiagBook(input: $input) {
|
||||
reqDiagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { fields: { writtenBy: null } } },
|
||||
});
|
||||
fail('should have thrown a coercion error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Expected non-nullable type "SecretAuthorPointerInput!" not to be null.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from variable-position validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak($x: String) {
|
||||
diagBooks(where: { writtenBy: $x }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { x: 'anything' },
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Variable "$x" of type "String" used in position expecting type "SecretAuthorRelationWhereInput".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from mutation variable-position validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.mutate({
|
||||
mutation: gql`
|
||||
mutation Create($x: String) {
|
||||
createDiagBook(input: { fields: { writtenBy: { createAndLink: $x } } }) {
|
||||
diagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { x: 'anything' },
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Variable "$x" of type "String" used in position expecting type "CreateSecretAuthorFieldsInput".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from output-field validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy {
|
||||
bogusSubField
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Cannot query field "bogusSubField" on type "SecretAuthor".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from scalar-leaf validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Field "writtenBy" of type "SecretAuthor" must have a selection of subfields.
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep pointer target class names in output-field errors with master key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
expect(error.message).toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should strip pointer target class names from fragment-spread validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak {
|
||||
diagBooks(where: {}) {
|
||||
edges {
|
||||
node {
|
||||
writtenBy {
|
||||
... on DiagBook {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// Fragment cannot be spread here as objects of type "SecretAuthor" can never be of type "DiagBook".
|
||||
expect(error.message).not.toContain('SecretAuthor');
|
||||
expect(JSON.stringify(error)).not.toContain('SecretAuthor');
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep caller-referenced input type names in validation errors without master or maintenance key', async () => {
|
||||
await setupPointerSchema(parseServer);
|
||||
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query Leak($where: DiagBookWhereInput) {
|
||||
diagBooks(where: $where) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { where: { nonexistentField: { equalTo: 1 } } },
|
||||
});
|
||||
fail('should have thrown a validation error');
|
||||
} catch (e) {
|
||||
const error = getReturnedError(e);
|
||||
// The caller referenced DiagBookWhereInput in the operation text, so it is not a
|
||||
// schema disclosure and must be preserved to keep validation feedback useful.
|
||||
expect(error.message).toContain('DiagBookWhereInput');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -1469,3 +1469,193 @@ describe('ParseLiveQuery', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParseLiveQuery ACL transition disclosure', function () {
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const waitFor = async predicate => {
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) {
|
||||
return;
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('timed out waiting for condition');
|
||||
};
|
||||
|
||||
let sockets;
|
||||
|
||||
beforeEach(() => {
|
||||
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
|
||||
sockets = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const socket of sockets) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
sockets = [];
|
||||
});
|
||||
|
||||
// Opens a raw LiveQuery WebSocket client authenticated with the given session
|
||||
// token so the exact wire payload of each event can be asserted directly.
|
||||
const openClient = async sessionToken => {
|
||||
const socket = new WebSocket('ws://localhost:8378/1');
|
||||
sockets.push(socket);
|
||||
const messages = [];
|
||||
socket.on('message', data => messages.push(JSON.parse(data.toString())));
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.on('open', resolve);
|
||||
socket.on('error', reject);
|
||||
});
|
||||
socket.send(
|
||||
JSON.stringify({ op: 'connect', applicationId: Parse.applicationId, sessionToken })
|
||||
);
|
||||
const client = {
|
||||
socket,
|
||||
messages,
|
||||
subscribe(requestId, className, where) {
|
||||
socket.send(
|
||||
JSON.stringify({ op: 'subscribe', requestId, query: { className, where }, sessionToken })
|
||||
);
|
||||
},
|
||||
messagesForOp(op) {
|
||||
return messages.filter(message => message.op === op);
|
||||
},
|
||||
waitForOpCount(op, count) {
|
||||
return waitFor(() => this.messagesForOp(op).length >= count);
|
||||
},
|
||||
};
|
||||
await waitFor(() => messages.some(message => message.op === 'connected'));
|
||||
return client;
|
||||
};
|
||||
|
||||
it('does not leak the post-revocation object body in a leave event when a save revokes the subscriber ACL read access', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestObject'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('leave-acl-user');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
// Object readable by the user, with an initial value.
|
||||
const obj = new Parse.Object('TestObject');
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(false);
|
||||
acl.setReadAccess(user, true);
|
||||
obj.setACL(acl);
|
||||
obj.set('secretField', 'INITIAL');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const client = await openClient(user.getSessionToken());
|
||||
client.subscribe(1, 'TestObject', {});
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
|
||||
// Control update: keep the user's ACL read access, only change the field. The
|
||||
// user is still authorized and receives the new value via an update event.
|
||||
await obj.save({ secretField: 'BENIGN_VISIBLE' }, { useMasterKey: true });
|
||||
await client.waitForOpCount('update', 1);
|
||||
expect(client.messagesForOp('update')[0].object.secretField).toBe('BENIGN_VISIBLE');
|
||||
|
||||
// Attack update: change the field AND remove the user's read access in the same save.
|
||||
const revokedACL = new Parse.ACL();
|
||||
revokedACL.setPublicReadAccess(false);
|
||||
obj.setACL(revokedACL);
|
||||
obj.set('secretField', 'POST_REVOCATION_SECRET');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
await client.waitForOpCount('leave', 1);
|
||||
|
||||
const leave = client.messagesForOp('leave')[0];
|
||||
// The subscriber must not receive the post-revocation value they can no longer read.
|
||||
expect(leave.object.secretField).not.toBe('POST_REVOCATION_SECRET');
|
||||
// They receive the last value they were authorized to see.
|
||||
expect(leave.object.secretField).toBe('BENIGN_VISIBLE');
|
||||
});
|
||||
|
||||
it('does not leak the pre-grant original object body in an enter event when a save grants the subscriber ACL read access', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestObject'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('enter-acl-user');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
// Object NOT readable by the user, with a pre-grant value.
|
||||
const obj = new Parse.Object('TestObject');
|
||||
const noAccessACL = new Parse.ACL();
|
||||
noAccessACL.setPublicReadAccess(false);
|
||||
obj.setACL(noAccessACL);
|
||||
obj.set('secretField', 'PRE_GRANT_SECRET');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const client = await openClient(user.getSessionToken());
|
||||
client.subscribe(1, 'TestObject', {});
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
|
||||
// Grant update: change the field AND add the user's read access in the same save.
|
||||
const grantedACL = new Parse.ACL();
|
||||
grantedACL.setPublicReadAccess(false);
|
||||
grantedACL.setReadAccess(user, true);
|
||||
obj.setACL(grantedACL);
|
||||
obj.set('secretField', 'GRANTED_VALUE');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
await client.waitForOpCount('enter', 1);
|
||||
|
||||
const enter = client.messagesForOp('enter')[0];
|
||||
// The current (now-authorized) value is delivered.
|
||||
expect(enter.object.secretField).toBe('GRANTED_VALUE');
|
||||
// The pre-grant state the user was never authorized to read must not be delivered.
|
||||
expect(enter.original).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still delivers the current object in a leave event caused by a query mismatch when the subscriber retains read access', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['TestObject'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const user = new Parse.User();
|
||||
user.setUsername('leave-query-user');
|
||||
user.setPassword('password');
|
||||
await user.signUp();
|
||||
|
||||
// Object readable by the user that matches the subscription query.
|
||||
const obj = new Parse.Object('TestObject');
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(false);
|
||||
acl.setReadAccess(user, true);
|
||||
obj.setACL(acl);
|
||||
obj.set('status', 'active');
|
||||
obj.set('secretField', 'INITIAL');
|
||||
await obj.save(null, { useMasterKey: true });
|
||||
|
||||
const client = await openClient(user.getSessionToken());
|
||||
client.subscribe(1, 'TestObject', { status: 'active' });
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
|
||||
// Update the field so the object no longer matches the query (query-mismatch leave)
|
||||
// while preserving the user's ACL read access. The user is still authorized to read
|
||||
// the current object, so the current state is delivered as designed.
|
||||
await obj.save({ status: 'archived', secretField: 'VISIBLE_NEW' }, { useMasterKey: true });
|
||||
await client.waitForOpCount('leave', 1);
|
||||
|
||||
const leave = client.messagesForOp('leave')[0];
|
||||
expect(leave.object.status).toBe('archived');
|
||||
expect(leave.object.secretField).toBe('VISIBLE_NEW');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -318,6 +318,76 @@ describe('ParseLiveQueryServer', function () {
|
||||
expect(Client.pushError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects field-wrapped deeply nested operators exceeding the query depth limit', async () => {
|
||||
await reconfigureServer({ requestComplexity: { queryDepth: 3 } });
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const clientId = 1;
|
||||
addMockClient(parseLiveQueryServer, clientId);
|
||||
const parseWebSocket = { clientId };
|
||||
// A deep $or hidden inside a field-level $elemMatch must still be counted by the
|
||||
// LiveQuery query depth guard (parity with the REST validateQueryDepth fix).
|
||||
let nested = { name: 'x' };
|
||||
for (let i = 0; i < 4; i++) {
|
||||
nested = { $or: [nested] };
|
||||
}
|
||||
const request = {
|
||||
query: { className: 'test', where: { tags: { $elemMatch: nested } }, keys: ['x'] },
|
||||
requestId: 2,
|
||||
sessionToken: 'sessionToken',
|
||||
};
|
||||
await parseLiveQueryServer._handleSubscribe(parseWebSocket, request);
|
||||
|
||||
const Client = require('../lib/LiveQuery/Client').Client;
|
||||
expect(Client.pushError).toHaveBeenCalledWith(
|
||||
jasmine.anything(),
|
||||
Parse.Error.INVALID_QUERY,
|
||||
jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/),
|
||||
false,
|
||||
2
|
||||
);
|
||||
expect(parseLiveQueryServer.subscriptions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a non-array value for a logical operator on subscribe', async () => {
|
||||
await reconfigureServer({ requestComplexity: { queryDepth: 3 } });
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const clientId = 1;
|
||||
addMockClient(parseLiveQueryServer, clientId);
|
||||
const parseWebSocket = { clientId };
|
||||
const request = {
|
||||
query: { className: 'test', where: { $or: 'not-an-array' }, keys: ['x'] },
|
||||
requestId: 3,
|
||||
sessionToken: 'sessionToken',
|
||||
};
|
||||
await parseLiveQueryServer._handleSubscribe(parseWebSocket, request);
|
||||
|
||||
const Client = require('../lib/LiveQuery/Client').Client;
|
||||
expect(Client.pushError).toHaveBeenCalledWith(
|
||||
jasmine.anything(),
|
||||
Parse.Error.INVALID_QUERY,
|
||||
jasmine.stringMatching(/\$or must be an array/),
|
||||
false,
|
||||
3
|
||||
);
|
||||
expect(parseLiveQueryServer.subscriptions.size).toBe(0);
|
||||
});
|
||||
|
||||
it('allows null values nested in the query within the depth limit', async () => {
|
||||
await reconfigureServer({ requestComplexity: { queryDepth: 3 } });
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
const clientId = 1;
|
||||
addMockClient(parseLiveQueryServer, clientId);
|
||||
const parseWebSocket = { clientId };
|
||||
const request = {
|
||||
query: { className: 'test', where: { $or: [{ name: null }] }, keys: ['x'] },
|
||||
requestId: 4,
|
||||
sessionToken: 'sessionToken',
|
||||
};
|
||||
await parseLiveQueryServer._handleSubscribe(parseWebSocket, request);
|
||||
|
||||
expect(parseLiveQueryServer.subscriptions.size).toBe(1);
|
||||
});
|
||||
|
||||
it('can handle subscribe command with new query', async () => {
|
||||
const parseLiveQueryServer = new ParseLiveQueryServer({});
|
||||
// Add mock client
|
||||
|
||||
@@ -393,4 +393,156 @@ describe('Parse.Session', () => {
|
||||
});
|
||||
expect(verifyRes.data.expiresAt.iso).toBe(farFuture);
|
||||
});
|
||||
|
||||
it('should reject null expiresAt when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull1', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalExpiresAt = sessionRes.data.expiresAt;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
expiresAt: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.expiresAt).toEqual(originalExpiresAt);
|
||||
});
|
||||
|
||||
it('should reject null createdWith when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull2', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
const originalCreatedWith = sessionRes.data.createdWith;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
createdWith: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
|
||||
const verifyRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
expect(verifyRes.data.createdWith).toEqual(originalCreatedWith);
|
||||
});
|
||||
|
||||
it('should reject null installationId when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull3', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
installationId: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
});
|
||||
|
||||
it('should reject null sessionToken when updating a session via PUT', async () => {
|
||||
const user = await Parse.User.signUp('sessionupdatenull4', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
|
||||
const sessionRes = await request({
|
||||
method: 'GET',
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
});
|
||||
const sessionId = sessionRes.data.objectId;
|
||||
|
||||
const updateRes = await request({
|
||||
method: 'PUT',
|
||||
url: `http://localhost:8378/1/sessions/${sessionId}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
sessionToken: null,
|
||||
},
|
||||
}).catch(e => e);
|
||||
|
||||
expect(updateRes.data.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,29 @@ describe('Parse.User testing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes login response time for non-existent and existing users', async () => {
|
||||
const passwordCrypto = require('../lib/password');
|
||||
const compareSpy = spyOn(passwordCrypto, 'compare').and.callThrough();
|
||||
await Parse.User.signUp('existinguser', 'password123');
|
||||
compareSpy.calls.reset();
|
||||
|
||||
// Login with non-existent user — should use dummy hash
|
||||
await expectAsync(
|
||||
Parse.User.logIn('nonexistentuser', 'wrongpassword')
|
||||
).toBeRejected();
|
||||
expect(compareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(compareSpy).toHaveBeenCalledWith('wrongpassword', passwordCrypto.dummyHash);
|
||||
compareSpy.calls.reset();
|
||||
|
||||
// Login with existing user but wrong password — should use real hash
|
||||
await expectAsync(
|
||||
Parse.User.logIn('existinguser', 'wrongpassword')
|
||||
).toBeRejected();
|
||||
expect(compareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(compareSpy.calls.mostRecent().args[0]).toBe('wrongpassword');
|
||||
expect(compareSpy.calls.mostRecent().args[1]).not.toBe(passwordCrypto.dummyHash);
|
||||
});
|
||||
|
||||
it('user login with context', async () => {
|
||||
let hit = 0;
|
||||
const context = { foo: 'bar' };
|
||||
|
||||
@@ -6,7 +6,7 @@ function createProduct() {
|
||||
{
|
||||
base64: new Buffer('download_file', 'utf-8').toString('base64'),
|
||||
},
|
||||
'text'
|
||||
'text/plain'
|
||||
);
|
||||
return file.save().then(function () {
|
||||
const product = new Parse.Object('_Product');
|
||||
|
||||
@@ -344,6 +344,95 @@ describe('request complexity', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('query depth bypass via field-wrapped operators', () => {
|
||||
let config;
|
||||
|
||||
function buildDeepOr(depth) {
|
||||
let where = { username: 'test' };
|
||||
for (let i = 0; i < depth; i++) {
|
||||
where = { $or: [where] };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
requestComplexity: { queryDepth: 3 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
});
|
||||
|
||||
it('should reject a deeply nested $or wrapped in $elemMatch exceeding depth limit', async () => {
|
||||
const where = { username: { $elemMatch: buildDeepOr(4) } };
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a deeply nested $or wrapped in $not exceeding depth limit', async () => {
|
||||
const where = { username: { $not: buildDeepOr(4) } };
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a deeply nested $or wrapped under a plain field name exceeding depth limit', async () => {
|
||||
const where = { metadata: buildDeepOr(4) };
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejectedWith(
|
||||
jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/Query condition nesting depth exceeds maximum allowed depth of 3/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow field-wrapped logical operators within depth limit', async () => {
|
||||
const where = {
|
||||
username: {
|
||||
$inQuery: {
|
||||
className: '_User',
|
||||
where: { $or: [{ username: 'a' }, { username: 'b' }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should not count field-level operators that do not nest logical operators toward depth', async () => {
|
||||
const where = { username: { $in: ['a', 'b'] } };
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeResolved();
|
||||
});
|
||||
|
||||
it('should not exponentially process field-wrapped deeply nested operators when queryDepth is disabled', async () => {
|
||||
// With queryDepth disabled, the depth guard does not run; the walk over the
|
||||
// nested $or arrays must still be linear (not O(2^n)) so a single small request
|
||||
// cannot hang the event loop.
|
||||
await reconfigureServer({
|
||||
requestComplexity: { queryDepth: -1 },
|
||||
});
|
||||
config = Config.get('test');
|
||||
const where = { username: { $elemMatch: buildDeepOr(26) } };
|
||||
const start = Date.now();
|
||||
await expectAsync(
|
||||
rest.find(config, auth.nobody(config), '_User', where)
|
||||
).toBeRejected();
|
||||
expect(Date.now() - start).toBeLessThan(5000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe('include limits', () => {
|
||||
let config;
|
||||
|
||||
|
||||
@@ -212,4 +212,25 @@ describe('Utils', () => {
|
||||
expect(error.message).toBe('Detailed error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
const cases = [
|
||||
['file.txt', 'txt'],
|
||||
['file.tar.gz', 'gz'],
|
||||
['.hidden', 'hidden'],
|
||||
['file.', ''],
|
||||
['file..', ''],
|
||||
['file', ''],
|
||||
['', ''],
|
||||
[null, ''],
|
||||
[undefined, ''],
|
||||
['poc.svg.', ''],
|
||||
['archive.tar.gz.', ''],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
it(`returns ${JSON.stringify(expected)} for ${JSON.stringify(input)}`, () => {
|
||||
expect(Utils.getFileExtension(input)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+4770
-3025
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,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) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Apply optimistic locking for authData provider field changes. For each lockable
|
||||
// top-level field in the original authData whose value differs from the incoming
|
||||
// value, add an equality constraint for the original value to the update WHERE
|
||||
// clause. Concurrent requests racing the same single-use token will only allow the
|
||||
// first update to match; subsequent updates miss and surface as OBJECT_NOT_FOUND.
|
||||
//
|
||||
// Only fields whose values round-trip cleanly through both storage adapters are
|
||||
// locked: primitives (string, number, boolean) and arrays. Date values and nested
|
||||
// objects are skipped because their JSON representation differs between the
|
||||
// MongoDB and Postgres adapters, and because Parse Server's query-key validator
|
||||
// rejects deeper paths containing characters like `+` (e.g. phone-number keys).
|
||||
// Locking the consumed single-use credential (the MFA token string or the
|
||||
// recovery-code array) is sufficient — its removal invalidates the WHERE clause
|
||||
// for concurrent writers.
|
||||
export function applyAuthDataOptimisticLock(query, originalAuthData, newAuthData) {
|
||||
if (!originalAuthData) {
|
||||
return;
|
||||
}
|
||||
for (const provider of Object.keys(newAuthData)) {
|
||||
const original = originalAuthData[provider];
|
||||
if (!original || typeof original !== 'object') {
|
||||
continue;
|
||||
}
|
||||
for (const [field, value] of Object.entries(original)) {
|
||||
if (!isLockableAuthDataValue(value)) {
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(value) !== JSON.stringify(newAuthData[provider]?.[field])) {
|
||||
query[`authData.${provider}.${field}`] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isLockableAuthDataValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
const t = typeof value;
|
||||
if (t === 'string' || t === 'number' || t === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
var semver = require('semver');
|
||||
|
||||
function compatible(compatibleSDK) {
|
||||
return function (clientSDK) {
|
||||
if (typeof clientSDK === 'string') {
|
||||
clientSDK = fromString(clientSDK);
|
||||
}
|
||||
// REST API, or custom SDK
|
||||
if (!clientSDK) {
|
||||
return true;
|
||||
}
|
||||
const clientVersion = clientSDK.version;
|
||||
const compatiblityVersion = compatibleSDK[clientSDK.sdk];
|
||||
return semver.satisfies(clientVersion, compatiblityVersion);
|
||||
};
|
||||
}
|
||||
|
||||
function supportsForwardDelete(clientSDK) {
|
||||
return compatible({
|
||||
js: '>=1.9.0',
|
||||
})(clientSDK);
|
||||
}
|
||||
|
||||
function fromString(version) {
|
||||
const versionRE = /([-a-zA-Z]+)([0-9\.]+)/;
|
||||
const match = version.toLowerCase().match(versionRE);
|
||||
if (match && match.length === 3) {
|
||||
return {
|
||||
sdk: match[1],
|
||||
version: match[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compatible,
|
||||
supportsForwardDelete,
|
||||
fromString,
|
||||
};
|
||||
@@ -1074,38 +1074,169 @@ class DatabaseController {
|
||||
|
||||
// Modifies query so that it no longer has $relatedTo
|
||||
// Returns a promise that resolves when query is mutated
|
||||
reduceRelationKeys(className: string, query: any, queryOptions: any): ?Promise<void> {
|
||||
reduceRelationKeys(
|
||||
className: string,
|
||||
query: any,
|
||||
queryOptions: any,
|
||||
auth: any = {},
|
||||
aclGroup: any[] = [],
|
||||
isMaster: boolean = false,
|
||||
schemaController: ?SchemaController.SchemaController
|
||||
): ?Promise<void> {
|
||||
if (query['$or']) {
|
||||
return Promise.all(
|
||||
query['$or'].map(aQuery => {
|
||||
return this.reduceRelationKeys(className, aQuery, queryOptions);
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
aQuery,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (query['$and']) {
|
||||
return Promise.all(
|
||||
query['$and'].map(aQuery => {
|
||||
return this.reduceRelationKeys(className, aQuery, queryOptions);
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
aQuery,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (Array.isArray(query['$nor'])) {
|
||||
// Guard with Array.isArray (unlike the legacy $or/$and checks above) so a
|
||||
// malformed non-array $nor still falls through to validateQuery and yields
|
||||
// the existing INVALID_QUERY error instead of throwing here.
|
||||
return Promise.all(
|
||||
query['$nor'].map(aQuery => {
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
aQuery,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
var relatedTo = query['$relatedTo'];
|
||||
if (relatedTo) {
|
||||
return this.relatedIds(
|
||||
relatedTo.object.className,
|
||||
relatedTo.key,
|
||||
relatedTo.object.objectId,
|
||||
queryOptions
|
||||
)
|
||||
.then(ids => {
|
||||
return this.authorizeRelatedToQuery(relatedTo, auth, aclGroup, isMaster, schemaController)
|
||||
.then(canReadOwningObject => {
|
||||
delete query['$relatedTo'];
|
||||
this.addInObjectIdsIds(ids, query);
|
||||
return this.reduceRelationKeys(className, query, queryOptions);
|
||||
if (!canReadOwningObject) {
|
||||
// The caller is not allowed to read the owning object, so the
|
||||
// relation must not disclose any linked objects (and must not act
|
||||
// as a membership oracle for a known related id).
|
||||
this.addInObjectIdsIds([], query);
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
query,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
}
|
||||
return this.relatedIds(
|
||||
relatedTo.object.className,
|
||||
relatedTo.key,
|
||||
relatedTo.object.objectId,
|
||||
queryOptions
|
||||
).then(ids => {
|
||||
this.addInObjectIdsIds(ids, query);
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
query,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
});
|
||||
})
|
||||
.then(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Authorizes a `$relatedTo` relation query against the owning object before
|
||||
// its join table is read by `relatedIds`. Without this check, `$relatedTo`
|
||||
// bypasses both `protectedFields` and the owning object's ACL/CLP, because
|
||||
// the downstream protected-field and ACL filters only apply to the queried
|
||||
// (target) class, never to the owning class referenced by `$relatedTo`.
|
||||
//
|
||||
// - Throws `OPERATION_FORBIDDEN` if the relation key is a protected field on
|
||||
// the owning class for the caller's auth context (mirrors the protected
|
||||
// WHERE-field denial in `RestQuery.denyProtectedFields`).
|
||||
// - Resolves to `true` if the caller may read the owning object (so the join
|
||||
// table read may proceed), or `false` otherwise (so the relation yields no
|
||||
// results and cannot be used as a membership oracle).
|
||||
//
|
||||
// Master and maintenance requests bypass both checks by design.
|
||||
authorizeRelatedToQuery(
|
||||
relatedTo: any,
|
||||
auth: any = {},
|
||||
aclGroup: any[] = [],
|
||||
isMaster: boolean = false,
|
||||
schemaController: ?SchemaController.SchemaController
|
||||
): Promise<boolean> {
|
||||
if (isMaster) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const owningClassName = relatedTo && relatedTo.object && relatedTo.object.className;
|
||||
const owningId = relatedTo && relatedTo.object && relatedTo.object.objectId;
|
||||
const relationKey = relatedTo && relatedTo.key;
|
||||
return this.loadSchemaIfNeeded(schemaController).then(loadedSchema => {
|
||||
// 1. The relation key must not be a protected field on the owning class.
|
||||
const protectedFields =
|
||||
this.addProtectedFields(loadedSchema, owningClassName, {}, aclGroup, auth) || [];
|
||||
const rootField = typeof relationKey === 'string' ? relationKey.split('.')[0] : relationKey;
|
||||
if (protectedFields.includes(relationKey) || protectedFields.includes(rootField)) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`This user is not allowed to query ${relationKey} on class ${owningClassName}`,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
// 2. The caller must be able to read the owning object itself. A read with
|
||||
// the caller's auth context applies the owning class CLP, the object
|
||||
// ACL and pointer permissions. Any "not authorized" or "not found"
|
||||
// outcome maps to "cannot read", so the relation returns no results.
|
||||
return this.find(
|
||||
owningClassName,
|
||||
{ objectId: owningId },
|
||||
{ acl: aclGroup, limit: 1, keys: ['objectId'], op: 'get' },
|
||||
auth,
|
||||
loadedSchema
|
||||
)
|
||||
.then(results => Array.isArray(results) && results.length > 0)
|
||||
.catch(error => {
|
||||
if (
|
||||
error instanceof Parse.Error &&
|
||||
(error.code === Parse.Error.OPERATION_FORBIDDEN ||
|
||||
error.code === Parse.Error.OBJECT_NOT_FOUND)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addInObjectIdsIds(ids: ?Array<string> = null, query: any) {
|
||||
const idsFromString: ?Array<string> =
|
||||
typeof query.objectId === 'string' ? [query.objectId] : null;
|
||||
@@ -1269,7 +1400,17 @@ class DatabaseController {
|
||||
? Promise.resolve()
|
||||
: schemaController.validatePermission(className, aclGroup, op)
|
||||
)
|
||||
.then(() => this.reduceRelationKeys(className, query, queryOptions))
|
||||
.then(() =>
|
||||
this.reduceRelationKeys(
|
||||
className,
|
||||
query,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
)
|
||||
)
|
||||
.then(() => this.reduceInRelation(className, query, schemaController))
|
||||
.then(() => {
|
||||
let protectedFields;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { randomHexString } from '../cryptoUtils';
|
||||
import AdaptableController from './AdaptableController';
|
||||
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
|
||||
import path from 'path';
|
||||
const Parse = require('parse').Parse;
|
||||
const Utils = require('../Utils');
|
||||
|
||||
const legacyFilesRegex = new RegExp(
|
||||
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*'
|
||||
@@ -15,14 +15,15 @@ export class FilesController extends AdaptableController {
|
||||
}
|
||||
|
||||
async createFile(config, filename, data, contentType, options) {
|
||||
const extname = path.extname(filename);
|
||||
|
||||
const extname = Utils.getFileExtension(filename);
|
||||
const hasExtension = extname.length > 0;
|
||||
const mime = (await import('mime')).default
|
||||
if (!hasExtension && contentType && mime.getExtension(contentType)) {
|
||||
filename = filename + '.' + mime.getExtension(contentType);
|
||||
} else if (hasExtension && !contentType) {
|
||||
contentType = mime.getType(filename);
|
||||
// Avoid producing a doubled dot when the filename already ends in one
|
||||
const separator = filename.endsWith('.') ? '' : '.';
|
||||
filename = filename + separator + mime.getExtension(contentType);
|
||||
} else if (hasExtension) {
|
||||
contentType = mime.getType(filename) || contentType;
|
||||
}
|
||||
|
||||
if (!this.options.preserveFileName) {
|
||||
|
||||
@@ -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';
|
||||
@@ -51,6 +50,147 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
|
||||
});
|
||||
|
||||
// graphql-js embeds "Did you mean ...?" hints sourced from the live schema in
|
||||
// its error messages. They are produced in two distinct phases:
|
||||
// - validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
|
||||
// KnownTypeNamesRule, ...), and
|
||||
// - variable coercion (unknown enum values, unknown input-object fields),
|
||||
// which runs during execution, after validation.
|
||||
// All of these are returned to the caller and disclose schema identifiers (Cloud
|
||||
// Code function names, class and field names) that the introspection guard is
|
||||
// meant to hide. Strip the hint suffix from every returned error — including the
|
||||
// copy graphql-js duplicates into extensions.stacktrace in non-production — for
|
||||
// callers that are not allowed to introspect.
|
||||
const stripSchemaSuggestion = message =>
|
||||
typeof message === 'string' ? message.replace(/ ?Did you mean(.+?)\?$/, '') : message;
|
||||
|
||||
// graphql-js also emits a base input-coercion message that names a schema
|
||||
// identifier WITHOUT a "Did you mean" clause, so the suggestion strip above
|
||||
// cannot reach it: when a required custom input field is omitted, coerceInputValue
|
||||
// returns 'Field "<name>" of required type "<type>" was not provided.', disclosing
|
||||
// a field name the caller never supplied. Redact the quoted identifiers from this
|
||||
// template while preserving the error shape, for callers that are not allowed to
|
||||
// introspect. The sibling coercion messages ('... is not defined by type "<type>".',
|
||||
// 'Expected type "<type>" to be an object.') are intentionally left intact: they
|
||||
// only echo an input type name the caller already referenced in the operation, so
|
||||
// they disclose nothing the caller did not already provide.
|
||||
const stripSchemaCoercionIdentifiers = message =>
|
||||
typeof message === 'string'
|
||||
? message.replace(
|
||||
/Field "[^"]*" of required type "[^"]*" was not provided\./g,
|
||||
'Field of required type was not provided.'
|
||||
)
|
||||
: message;
|
||||
|
||||
// graphql-js also emits base coercion / validation messages that name a nested input
|
||||
// TYPE without a "Did you mean" clause, so neither strip above reaches them. For a
|
||||
// Pointer or Relation field the generated input type name embeds the pointer's TARGET
|
||||
// class (`<Target>PointerInput`, `<Target>RelationWhereInput`, `Create<Target>FieldsInput`)
|
||||
// — a class the caller never referenced and cannot derive from the field name they
|
||||
// supplied — so these templates disclose a schema class name to a caller who has only the
|
||||
// public application id. Redact the quoted type identifier from those templates UNLESS the
|
||||
// caller referenced it in the operation text: a type name the caller wrote in the operation
|
||||
// (e.g. `$where: UserWhereInput`) is not a disclosure, and preserving it keeps the message
|
||||
// ('... is not defined by type "UserWhereInput".') useful. When the operation text is
|
||||
// unavailable the identifier is redacted (fail closed).
|
||||
const stripSchemaTypeIdentifiers = (message, operationText) => {
|
||||
if (typeof message !== 'string') { return message; }
|
||||
// A generated type identifier counts as "referenced" (and therefore not a disclosure) only if
|
||||
// the caller wrote it as a whole token in the operation text. Tokenize the operation on
|
||||
// non-identifier characters and compare exact tokens rather than building a RegExp from the
|
||||
// captured name: this avoids substring false-matches (e.g. preserving "AuthorPointerInput"
|
||||
// because the operation contains "SecretAuthorPointerInput") and any regex injection/ReDoS from
|
||||
// an unusual captured name. GraphQL list/non-null wrappers ("[", "]", "!") are stripped from the
|
||||
// captured name so e.g. "SecretAuthorPointerInput!" still matches "$x: SecretAuthorPointerInput!".
|
||||
// When the operation text is unavailable the type is treated as not referenced (fail closed).
|
||||
const referencedTokens =
|
||||
typeof operationText === 'string'
|
||||
? new Set(operationText.split(/[^_A-Za-z0-9]+/).filter(Boolean))
|
||||
: new Set();
|
||||
const isReferenced = typeName => referencedTokens.has(typeName.replace(/[[\]!]/g, ''));
|
||||
return message
|
||||
// Input coercion / ValuesOfCorrectTypeRule (variables and inline literals).
|
||||
.replace(/Expected value of type "([^"]+)"/g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : 'Expected value of the correct type'
|
||||
)
|
||||
.replace(/Expected type "([^"]+)" to be an object\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : 'Expected an object.'
|
||||
)
|
||||
.replace(/Expected non-nullable type "([^"]+)" not to be null\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : 'Expected a non-null value.'
|
||||
)
|
||||
.replace(/ is not defined by type "([^"]+)"\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : ' is not defined.'
|
||||
)
|
||||
// VariablesInAllowedPositionRule: the position type is the pointer/relation target
|
||||
// input type; the caller only wrote their own variable's declared type.
|
||||
.replace(/ used in position expecting type "([^"]+)"\./g, (match, typeName) =>
|
||||
isReferenced(typeName) ? match : ' used in position expecting a different type.'
|
||||
)
|
||||
// FieldsOnCorrectTypeRule: descending into a Pointer/Relation output field names its
|
||||
// target output object type.
|
||||
.replace(/Cannot query field ("[^"]*") on type "([^"]+)"\./g, (match, fieldName, typeName) =>
|
||||
isReferenced(typeName) ? match : `Cannot query field ${fieldName}.`
|
||||
)
|
||||
// ScalarLeafsRule: selecting a Pointer/Relation output field with no sub-selection names
|
||||
// its target output object type.
|
||||
.replace(
|
||||
/Field ("[^"]*") of type "([^"]+)" must have a selection of subfields\./g,
|
||||
(match, fieldName, typeName) =>
|
||||
isReferenced(typeName) ? match : `Field ${fieldName} must have a selection of subfields.`
|
||||
)
|
||||
// PossibleFragmentSpreadsRule: an inline/named fragment on an incompatible type inside a
|
||||
// Pointer/Relation output field names the target output object type (the parent type).
|
||||
// Redact each type token the caller did not reference; when both are referenced the
|
||||
// reconstruction is identical to the original message.
|
||||
.replace(
|
||||
/objects of type "([^"]+)" can never be of type "([^"]+)"\./g,
|
||||
(match, parentType, fragType) => {
|
||||
const parent = isReferenced(parentType) ? `type "${parentType}"` : 'the parent type';
|
||||
const frag = isReferenced(fragType) ? `type "${fragType}"` : 'the given type';
|
||||
return `objects of ${parent} can never be of ${frag}.`;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const stripSchemaIdentifiers = (message, operationText) =>
|
||||
stripSchemaTypeIdentifiers(
|
||||
stripSchemaCoercionIdentifiers(stripSchemaSuggestion(message)),
|
||||
operationText
|
||||
);
|
||||
|
||||
const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
|
||||
requestDidStart: async (requestContext) => ({
|
||||
willSendResponse: async () => {
|
||||
if (publicIntrospection) {
|
||||
return;
|
||||
}
|
||||
const isMasterOrMaintenance =
|
||||
requestContext.contextValue.auth?.isMaster ||
|
||||
requestContext.contextValue.auth?.isMaintenance;
|
||||
if (isMasterOrMaintenance) {
|
||||
return;
|
||||
}
|
||||
const body = requestContext.response?.body;
|
||||
const errors =
|
||||
body?.kind === 'single'
|
||||
? body.singleResult.errors
|
||||
: body?.kind === 'incremental'
|
||||
? body.initialResult.errors
|
||||
: undefined;
|
||||
const operationText = requestContext.request?.query;
|
||||
errors?.forEach(error => {
|
||||
error.message = stripSchemaIdentifiers(error.message, operationText);
|
||||
if (Array.isArray(error.extensions?.stacktrace)) {
|
||||
error.extensions.stacktrace = error.extensions.stacktrace.map(message =>
|
||||
stripSchemaIdentifiers(message, operationText)
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
class ParseGraphQLServer {
|
||||
parseGraphQLController: ParseGraphQLController;
|
||||
|
||||
@@ -76,8 +216,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,
|
||||
@@ -113,7 +252,7 @@ class ParseGraphQLServer {
|
||||
requestHeaders: ['X-Parse-Application-Id'],
|
||||
},
|
||||
introspection: this.config.graphQLPublicIntrospection,
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
|
||||
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), SchemaSuggestionsControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)],
|
||||
schema,
|
||||
});
|
||||
await apollo.start();
|
||||
@@ -162,7 +301,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);
|
||||
|
||||
@@ -5,8 +5,7 @@ const createObject = async (className, fields, config, auth, info) => {
|
||||
fields = {};
|
||||
}
|
||||
|
||||
return (await rest.create(config, auth, className, fields, info.clientSDK, info.context))
|
||||
.response;
|
||||
return (await rest.create(config, auth, className, fields, info.context)).response;
|
||||
};
|
||||
|
||||
const updateObject = async (className, objectId, fields, config, auth, info) => {
|
||||
@@ -14,9 +13,7 @@ const updateObject = async (className, objectId, fields, config, auth, info) =>
|
||||
fields = {};
|
||||
}
|
||||
|
||||
return (
|
||||
await rest.update(config, auth, className, { objectId }, fields, info.clientSDK, info.context)
|
||||
).response;
|
||||
return (await rest.update(config, auth, className, { objectId }, fields, info.context)).response;
|
||||
};
|
||||
|
||||
const deleteObject = async (className, objectId, config, auth, info) => {
|
||||
|
||||
@@ -69,7 +69,6 @@ const getObject = async (
|
||||
className,
|
||||
objectId,
|
||||
options,
|
||||
info.clientSDK,
|
||||
info.context
|
||||
);
|
||||
|
||||
@@ -131,9 +130,8 @@ const findObjects = async (
|
||||
if (Object.keys(where).length > 0 && subqueryReadPreference) {
|
||||
preCountOptions.subqueryReadPreference = subqueryReadPreference;
|
||||
}
|
||||
preCount = (
|
||||
await rest.find(config, auth, className, where, preCountOptions, info.clientSDK, info.context)
|
||||
).count;
|
||||
preCount = (await rest.find(config, auth, className, where, preCountOptions, info.context))
|
||||
.count;
|
||||
if ((skip || 0) + limit < preCount) {
|
||||
skip = preCount - limit;
|
||||
}
|
||||
@@ -199,7 +197,6 @@ const findObjects = async (
|
||||
className,
|
||||
where,
|
||||
options,
|
||||
info.clientSDK,
|
||||
info.context
|
||||
);
|
||||
results = findResult.results;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -59,7 +59,6 @@ const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) =
|
||||
// Get the user it self from auth object
|
||||
{ objectId: context.auth.user.id },
|
||||
options,
|
||||
info.clientVersion,
|
||||
info.context
|
||||
);
|
||||
if (!response.results || response.results.length == 0) {
|
||||
|
||||
@@ -206,6 +206,8 @@ class ParseLiveQueryServer {
|
||||
continue;
|
||||
}
|
||||
requestIds.forEach(async requestId => {
|
||||
// Deep-clone shared object so each concurrent callback works on its own copy
|
||||
let localDeletedParseObject = JSON.parse(JSON.stringify(deletedParseObject));
|
||||
const acl = message.currentParseObject.getACL();
|
||||
// Check CLP
|
||||
const op = this._getCLPOperation(subscription.query);
|
||||
@@ -228,7 +230,7 @@ class ParseLiveQueryServer {
|
||||
res = {
|
||||
event: 'delete',
|
||||
sessionToken: client.sessionToken,
|
||||
object: deletedParseObject,
|
||||
object: localDeletedParseObject,
|
||||
clients: this.clients.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
useMasterKey: client.hasMasterKey,
|
||||
@@ -250,9 +252,9 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
deletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
localDeletedParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
res.object = deletedParseObject;
|
||||
res.object = localDeletedParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -261,8 +263,7 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
deletedParseObject = res.object;
|
||||
client.pushDelete(requestId, deletedParseObject);
|
||||
client.pushDelete(requestId, res.object);
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
Client.pushError(client.parseWebSocket, error.code, error.message, false, requestId);
|
||||
@@ -318,6 +319,13 @@ class ParseLiveQueryServer {
|
||||
continue;
|
||||
}
|
||||
requestIds.forEach(async requestId => {
|
||||
// Deep-clone shared objects so each concurrent callback works on its own copy.
|
||||
// Without cloning, _filterSensitiveData's in-place field deletion and afterEvent
|
||||
// trigger modifications corrupt the shared state across concurrent subscribers.
|
||||
let localCurrentParseObject = JSON.parse(JSON.stringify(currentParseObject));
|
||||
let localOriginalParseObject = originalParseObject
|
||||
? JSON.parse(JSON.stringify(originalParseObject))
|
||||
: null;
|
||||
// Set orignal ParseObject ACL checking promise, if the object does not match
|
||||
// subscription, we do not need to check ACL
|
||||
let originalACLCheckingPromise;
|
||||
@@ -358,8 +366,8 @@ class ParseLiveQueryServer {
|
||||
]);
|
||||
logger.verbose(
|
||||
'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s',
|
||||
originalParseObject,
|
||||
currentParseObject,
|
||||
localOriginalParseObject,
|
||||
localCurrentParseObject,
|
||||
isOriginalSubscriptionMatched,
|
||||
isCurrentSubscriptionMatched,
|
||||
isOriginalMatched,
|
||||
@@ -373,7 +381,7 @@ class ParseLiveQueryServer {
|
||||
} else if (isOriginalMatched && !isCurrentMatched) {
|
||||
type = 'leave';
|
||||
} else if (!isOriginalMatched && isCurrentMatched) {
|
||||
if (originalParseObject) {
|
||||
if (localOriginalParseObject) {
|
||||
type = 'enter';
|
||||
} else {
|
||||
type = 'create';
|
||||
@@ -385,11 +393,40 @@ class ParseLiveQueryServer {
|
||||
if (!watchFieldsChanged && (type === 'update' || type === 'create')) {
|
||||
return;
|
||||
}
|
||||
// A `leave` or `enter` transition can be caused either by the object's
|
||||
// query match changing (the subscriber keeps read access) or by the
|
||||
// subscriber's ACL read access being revoked or granted in the same save.
|
||||
// In the access-change case the subscriber is not authorized to read the
|
||||
// object state that triggered the transition, so that state must not be
|
||||
// sent over the channel. (CLP read denial is handled earlier by
|
||||
// `_matchesCLP`, which skips the event entirely.)
|
||||
if (type === 'leave') {
|
||||
// The post-update object is readable on a query-mismatch leave but not
|
||||
// on an ACL-loss leave. Only send the post-update body when the
|
||||
// subscriber can still read the current object; otherwise fall back to
|
||||
// the last authorized (original) state, which still carries the objectId.
|
||||
const currentReadable = isCurrentSubscriptionMatched
|
||||
? false
|
||||
: await this._matchesACL(message.currentParseObject.getACL(), client, requestId);
|
||||
if (!currentReadable) {
|
||||
localCurrentParseObject = JSON.parse(JSON.stringify(localOriginalParseObject));
|
||||
}
|
||||
} else if (type === 'enter') {
|
||||
// The pre-update object was readable on a query-match-gain enter but not
|
||||
// on an ACL-grant enter. Only send the pre-update body as `original`
|
||||
// when the subscriber could read the original object.
|
||||
const originalReadable = isOriginalSubscriptionMatched
|
||||
? false
|
||||
: await this._matchesACL(message.originalParseObject.getACL(), client, requestId);
|
||||
if (!originalReadable) {
|
||||
localOriginalParseObject = null;
|
||||
}
|
||||
}
|
||||
res = {
|
||||
event: type,
|
||||
sessionToken: client.sessionToken,
|
||||
object: currentParseObject,
|
||||
original: originalParseObject,
|
||||
object: localCurrentParseObject,
|
||||
original: localOriginalParseObject,
|
||||
clients: this.clients.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
useMasterKey: client.hasMasterKey,
|
||||
@@ -414,16 +451,16 @@ class ParseLiveQueryServer {
|
||||
return;
|
||||
}
|
||||
if (res.object && typeof res.object.toJSON === 'function') {
|
||||
currentParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
localCurrentParseObject = toJSONwithObjects(res.object, res.object.className || className);
|
||||
}
|
||||
if (res.original && typeof res.original.toJSON === 'function') {
|
||||
originalParseObject = toJSONwithObjects(
|
||||
localOriginalParseObject = toJSONwithObjects(
|
||||
res.original,
|
||||
res.original.className || className
|
||||
);
|
||||
}
|
||||
res.object = currentParseObject;
|
||||
res.original = originalParseObject;
|
||||
res.object = localCurrentParseObject;
|
||||
res.original = localOriginalParseObject;
|
||||
await this._filterSensitiveData(
|
||||
classLevelPermissions,
|
||||
res,
|
||||
@@ -432,11 +469,9 @@ class ParseLiveQueryServer {
|
||||
op,
|
||||
subscription.query
|
||||
);
|
||||
currentParseObject = res.object;
|
||||
originalParseObject = res.original ?? null;
|
||||
const functionName = 'push' + res.event.charAt(0).toUpperCase() + res.event.slice(1);
|
||||
if (client[functionName]) {
|
||||
client[functionName](requestId, currentParseObject, originalParseObject);
|
||||
client[functionName](requestId, res.object, res.original ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
const error = resolveError(e);
|
||||
@@ -549,6 +584,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) {
|
||||
@@ -576,18 +621,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -764,7 +797,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,
|
||||
@@ -1029,22 +1064,32 @@ class ParseLiveQueryServer {
|
||||
const rc = appConfig.requestComplexity;
|
||||
if (rc && rc.queryDepth !== -1) {
|
||||
const maxDepth = rc.queryDepth;
|
||||
const checkDepth = (where: any, depth: number) => {
|
||||
const checkDepth = (node: 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) {
|
||||
if (node === null || typeof node !== 'object') {
|
||||
return;
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
for (const subQuery of where[op]) {
|
||||
checkDepth(subQuery, depth + 1);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
checkDepth(item, depth);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Descend into every value so that logical operators ($or/$and/$nor)
|
||||
// nested under field-level operators (e.g. $elemMatch, $not) or plain
|
||||
// field names are still counted. Only logical operators increase the
|
||||
// depth, which preserves the documented meaning of `queryDepth`.
|
||||
for (const key of Object.keys(node)) {
|
||||
const isLogical = key === '$or' || key === '$and' || key === '$nor';
|
||||
if (isLogical && !Array.isArray(node[key])) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${key} must be an array`);
|
||||
}
|
||||
checkDepth(node[key], isLogical ? depth + 1 : depth);
|
||||
}
|
||||
};
|
||||
checkDepth(request.query.where, 0);
|
||||
@@ -1103,6 +1148,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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+26
-15
@@ -31,7 +31,6 @@ const { createSanitizedError } = require('./Error');
|
||||
* @param options.className {string} The name of the class to query
|
||||
* @param options.restWhere {object} The where object for the query
|
||||
* @param options.restOptions {object} The options object for the query
|
||||
* @param options.clientSDK {string} The client SDK that is performing the query
|
||||
* @param options.runAfterFind {boolean} Whether to run the afterFind trigger
|
||||
* @param options.runBeforeFind {boolean} Whether to run the beforeFind trigger
|
||||
* @param options.context {object} The context object for the query
|
||||
@@ -44,7 +43,6 @@ async function RestQuery({
|
||||
className,
|
||||
restWhere = {},
|
||||
restOptions = {},
|
||||
clientSDK,
|
||||
runAfterFind = true,
|
||||
runBeforeFind = true,
|
||||
context,
|
||||
@@ -73,7 +71,6 @@ async function RestQuery({
|
||||
className,
|
||||
result.restWhere || restWhere,
|
||||
result.restOptions || restOptions,
|
||||
clientSDK,
|
||||
runAfterFind,
|
||||
context,
|
||||
isGet
|
||||
@@ -93,7 +90,6 @@ RestQuery.Method = Object.freeze({
|
||||
* @param className
|
||||
* @param restWhere
|
||||
* @param restOptions
|
||||
* @param clientSDK
|
||||
* @param runAfterFind
|
||||
* @param context
|
||||
*/
|
||||
@@ -103,7 +99,6 @@ function _UnsafeRestQuery(
|
||||
className,
|
||||
restWhere = {},
|
||||
restOptions = {},
|
||||
clientSDK,
|
||||
runAfterFind = true,
|
||||
context,
|
||||
isGet
|
||||
@@ -113,7 +108,6 @@ function _UnsafeRestQuery(
|
||||
this.className = className;
|
||||
this.restWhere = restWhere;
|
||||
this.restOptions = restOptions;
|
||||
this.clientSDK = clientSDK;
|
||||
this.runAfterFind = runAfterFind;
|
||||
this.response = null;
|
||||
this.findOptions = {};
|
||||
@@ -320,7 +314,7 @@ _UnsafeRestQuery.prototype.execute = function (executeOptions) {
|
||||
};
|
||||
|
||||
_UnsafeRestQuery.prototype.each = function (callback) {
|
||||
const { config, auth, className, restWhere, restOptions, clientSDK } = this;
|
||||
const { config, auth, className, restWhere, restOptions } = this;
|
||||
// if the limit is set, use it
|
||||
restOptions.limit = restOptions.limit || 100;
|
||||
restOptions.order = 'objectId';
|
||||
@@ -339,7 +333,6 @@ _UnsafeRestQuery.prototype.each = function (callback) {
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
this.runAfterFind,
|
||||
this.context
|
||||
);
|
||||
@@ -364,22 +357,29 @@ _UnsafeRestQuery.prototype.validateQueryDepth = function () {
|
||||
return;
|
||||
}
|
||||
const maxDepth = rc.queryDepth;
|
||||
const checkDepth = (where, depth) => {
|
||||
const checkDepth = (node, 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) {
|
||||
if (node === null || typeof node !== 'object') {
|
||||
return;
|
||||
}
|
||||
for (const op of ['$or', '$and', '$nor']) {
|
||||
if (Array.isArray(where[op])) {
|
||||
for (const subQuery of where[op]) {
|
||||
checkDepth(subQuery, depth + 1);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
checkDepth(item, depth);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Descend into every value so that logical operators ($or/$and/$nor) nested
|
||||
// under field-level operators (e.g. $elemMatch, $not) or plain field names are
|
||||
// still counted. Only logical operators increase the depth, which preserves the
|
||||
// documented meaning of `queryDepth`.
|
||||
for (const key of Object.keys(node)) {
|
||||
const isLogical = key === '$or' || key === '$and' || key === '$nor';
|
||||
checkDepth(node[key], isLogical ? depth + 1 : depth);
|
||||
}
|
||||
};
|
||||
checkDepth(this.restWhere, 0);
|
||||
@@ -904,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));
|
||||
}
|
||||
@@ -1311,6 +1318,10 @@ function findObjectWithKey(root, key) {
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
// Arrays are fully traversed above; returning here avoids re-walking the same
|
||||
// elements through the `for (subkey in root)` loop below, which would make this
|
||||
// function O(2^n) for nested arrays (e.g. deeply nested $or/$and/$nor).
|
||||
return;
|
||||
}
|
||||
if (root && root[key]) {
|
||||
return root;
|
||||
|
||||
+31
-11
@@ -11,11 +11,11 @@ var cryptoUtils = require('./cryptoUtils');
|
||||
var passwordCrypto = require('./password');
|
||||
var Parse = require('parse/node');
|
||||
var triggers = require('./triggers');
|
||||
var ClientSDK = require('./ClientSDK');
|
||||
const util = require('util');
|
||||
import RestQuery from './RestQuery';
|
||||
import _ from 'lodash';
|
||||
import logger from './logger';
|
||||
import { applyAuthDataOptimisticLock } from './AuthDataLock';
|
||||
import { requiredColumns } from './Controllers/SchemaController';
|
||||
import { createSanitizedError } from './Error';
|
||||
|
||||
@@ -28,7 +28,7 @@ import { createSanitizedError } from './Error';
|
||||
// RestWrite will handle objectId, createdAt, and updatedAt for
|
||||
// everything. It also knows to use triggers and special modifications
|
||||
// for the _User class.
|
||||
function RestWrite(config, auth, className, query, data, originalData, clientSDK, context, action) {
|
||||
function RestWrite(config, auth, className, query, data, originalData, context, action) {
|
||||
if (auth.isReadOnly) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
@@ -39,7 +39,6 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK
|
||||
this.config = config;
|
||||
this.auth = auth;
|
||||
this.className = className;
|
||||
this.clientSDK = clientSDK;
|
||||
this.storage = {};
|
||||
this.runOptions = {};
|
||||
this.context = context || {};
|
||||
@@ -645,6 +644,15 @@ RestWrite.prototype.handleAuthData = async function (authData) {
|
||||
this.authDataResponse = res.authDataResponse;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// IF we are in login we'll skip the database operation / beforeSave / afterSave etc...
|
||||
// we need to set it up there.
|
||||
// We are supposed to have a response only on LOGIN with authData, so we skip those
|
||||
@@ -660,18 +668,32 @@ 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 each changed original field 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
|
||||
// as arrays, or MFA SMS OTP tokens as strings).
|
||||
applyAuthDataOptimisticLock(query, originalAuthData, this.data.authData);
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else if (this.query && this.data.authData && Object.keys(this.data.authData).length) {
|
||||
// UPDATE path (e.g. PUT /users/:id during linked-provider re-auth): apply
|
||||
// the same optimistic lock to the subsequent runDatabaseOperation update so
|
||||
// concurrent single-use token consumers cannot both succeed.
|
||||
applyAuthDataOptimisticLock(this.query, originalAuthData, this.data.authData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1143,13 +1165,13 @@ RestWrite.prototype.handleSession = function () {
|
||||
if (this.query) {
|
||||
if (this.data.user && !this.auth.isMaster && this.data.user.objectId != this.auth.user.id) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.installationId) {
|
||||
} else if ('installationId' in this.data) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.sessionToken) {
|
||||
} else if ('sessionToken' in this.data) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.expiresAt && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
} else if ('expiresAt' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
} else if (this.data.createdWith && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
} else if ('createdWith' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME);
|
||||
}
|
||||
if (!this.auth.isMaster) {
|
||||
@@ -1873,7 +1895,6 @@ RestWrite.prototype._updateResponseWithData = function (response, data) {
|
||||
if (_.isEmpty(this.storage.fieldsChangedByTrigger)) {
|
||||
return response;
|
||||
}
|
||||
const clientSupportsDelete = ClientSDK.supportsForwardDelete(this.clientSDK);
|
||||
this.storage.fieldsChangedByTrigger.forEach(fieldName => {
|
||||
const dataValue = data[fieldName];
|
||||
|
||||
@@ -1881,10 +1902,9 @@ RestWrite.prototype._updateResponseWithData = function (response, data) {
|
||||
response[fieldName] = dataValue;
|
||||
}
|
||||
|
||||
// Strips operations from responses
|
||||
if (response[fieldName] && response[fieldName].__op) {
|
||||
delete response[fieldName];
|
||||
if (clientSupportsDelete && dataValue.__op == 'Delete') {
|
||||
if (dataValue.__op == 'Delete') {
|
||||
response[fieldName] = dataValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export class AggregateRouter extends ClassesRouter {
|
||||
this.className(req),
|
||||
body.where,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
for (const result of response.results) {
|
||||
|
||||
@@ -18,7 +18,6 @@ export class AudiencesRouter extends ClassesRouter {
|
||||
'_Audience',
|
||||
body.where,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
|
||||
@@ -39,7 +39,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
this.className(req),
|
||||
body.where,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
@@ -84,7 +83,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
this.className(req),
|
||||
req.params.objectId,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
@@ -119,7 +117,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
req.auth,
|
||||
this.className(req),
|
||||
req.body || {},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
}
|
||||
@@ -132,7 +129,6 @@ export class ClassesRouter extends PromiseRouter {
|
||||
this.className(req),
|
||||
where,
|
||||
req.body || {},
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
}
|
||||
|
||||
+91
-15
@@ -5,6 +5,7 @@ import Config from '../Config';
|
||||
import logger from '../logger';
|
||||
const triggers = require('../triggers');
|
||||
const Utils = require('../Utils');
|
||||
const auth = require('../Auth');
|
||||
import { createSanitizedHttpError } from '../Error';
|
||||
|
||||
export class FilesRouter {
|
||||
@@ -40,6 +41,22 @@ export class FilesRouter {
|
||||
return router;
|
||||
}
|
||||
|
||||
static async _resolveAuth(req, config) {
|
||||
const sessionToken = req.get('X-Parse-Session-Token');
|
||||
if (!sessionToken) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await auth.getAuthForSessionToken({
|
||||
config,
|
||||
sessionToken,
|
||||
installationId: req.get('X-Parse-Installation-Id'),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getHandler(req, res) {
|
||||
const config = Config.get(req.params.appId);
|
||||
if (!config) {
|
||||
@@ -54,11 +71,12 @@ export class FilesRouter {
|
||||
const mime = (await import('mime')).default;
|
||||
let contentType = mime.getType(filename);
|
||||
let file = new Parse.File(filename, { base64: '' }, contentType);
|
||||
const fileAuth = await FilesRouter._resolveAuth(req, config);
|
||||
const triggerResult = await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.beforeFind,
|
||||
{ file },
|
||||
config,
|
||||
req.auth
|
||||
fileAuth
|
||||
);
|
||||
if (triggerResult?.file?._name) {
|
||||
filename = triggerResult?.file?._name;
|
||||
@@ -66,6 +84,15 @@ export class FilesRouter {
|
||||
}
|
||||
|
||||
if (isFileStreamable(req, filesController)) {
|
||||
const afterFind = await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.afterFind,
|
||||
{ file, forceDownload: false },
|
||||
config,
|
||||
fileAuth
|
||||
);
|
||||
if (afterFind?.forceDownload) {
|
||||
res.set('Content-Disposition', `attachment;filename=${afterFind.file?._name || filename}`);
|
||||
}
|
||||
filesController.handleFileStream(config, filename, req, res, contentType).catch(() => {
|
||||
res.status(404);
|
||||
res.set('Content-Type', 'text/plain');
|
||||
@@ -87,7 +114,7 @@ export class FilesRouter {
|
||||
triggers.Types.afterFind,
|
||||
{ file, forceDownload: false },
|
||||
config,
|
||||
req.auth
|
||||
fileAuth
|
||||
);
|
||||
|
||||
if (afterFind?.file) {
|
||||
@@ -159,6 +186,7 @@ export class FilesRouter {
|
||||
|
||||
const fileExtensions = config.fileUpload?.fileExtensions;
|
||||
if (!isMaster && fileExtensions) {
|
||||
const mime = (await import('mime')).default;
|
||||
const isValidExtension = extension => {
|
||||
return fileExtensions.some(ext => {
|
||||
if (ext === '*') {
|
||||
@@ -170,24 +198,71 @@ export class FilesRouter {
|
||||
}
|
||||
});
|
||||
};
|
||||
let extension = contentType;
|
||||
if (filename && filename.includes('.')) {
|
||||
extension = filename.substring(filename.lastIndexOf('.') + 1);
|
||||
} else if (contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1];
|
||||
}
|
||||
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
|
||||
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
|
||||
|
||||
if (extension && !isValidExtension(extension)) {
|
||||
const rejectExtension = ext => {
|
||||
next(
|
||||
new Parse.Error(
|
||||
Parse.Error.FILE_SAVE_ERROR,
|
||||
`File upload of extension ${extension} is disabled.`
|
||||
`File upload of extension ${ext} is disabled.`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Parse the filename extension token, stripping MIME parameters and whitespace.
|
||||
let extension = Utils.getFileExtension(filename);
|
||||
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
|
||||
|
||||
const isExtensionRecognized = extension && mime.getType(filename);
|
||||
if (extension && !isValidExtension(extension)) {
|
||||
rejectExtension(extension);
|
||||
return;
|
||||
}
|
||||
|
||||
// When the filename extension is not recognized by `mime`,
|
||||
// `FilesController.createFile` cannot derive a Content-Type from the
|
||||
// filename and preserves the client-supplied Content-Type verbatim, so the
|
||||
// type the file is actually served as must be validated. Skip this when
|
||||
// extension filtering is disabled (`*`).
|
||||
const allowsAllExtensions = fileExtensions.includes('*');
|
||||
if (!isExtensionRecognized && contentType && !allowsAllExtensions) {
|
||||
const slashIndex = contentType.indexOf('/');
|
||||
const type = slashIndex > 0 ? contentType.slice(0, slashIndex).trim() : '';
|
||||
const subtype =
|
||||
slashIndex > 0 ? contentType.slice(slashIndex + 1).split(';')[0].trim() : '';
|
||||
// A valid media type is `type/subtype` where both are non-empty `token`s
|
||||
// (RFC 9110 §5.6.2). Reject anything else.
|
||||
const token = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/;
|
||||
if (!token.test(type) || !token.test(subtype)) {
|
||||
// A Content-Type that does not parse as `type/subtype` with valid,
|
||||
// non-empty type AND subtype tokens is malformed: there is no valid MIME
|
||||
// type without a subtype (RFC 9110 §8.3.1), and malformed tokens such as
|
||||
// `image//svg+xml` or `text/plain,text/html` are equally unparseable.
|
||||
// Browsers cannot parse such values and fall back to MIME-sniffing the
|
||||
// file body, which can render HTML/script markers as active content on
|
||||
// storage adapters that serve the stored Content-Type (e.g. `image`,
|
||||
// `image/`). Surface the precise blocklist message when the bare token
|
||||
// names a blocked extension (e.g. a no-slash `svg`), otherwise reject the
|
||||
// unparseable Content-Type.
|
||||
const bareToken = (slashIndex < 0 ? contentType.split(';')[0] : type).replace(
|
||||
/\s+/g,
|
||||
''
|
||||
);
|
||||
if (bareToken && !isValidExtension(bareToken)) {
|
||||
rejectExtension(bareToken);
|
||||
return;
|
||||
}
|
||||
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.'));
|
||||
return;
|
||||
}
|
||||
// Validate the well-formed Content-Type subtype against the blocklist, e.g.
|
||||
// "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
|
||||
// Valid custom/vendor types (e.g. "application/vnd.api+json") parse and are
|
||||
// allowed; only blocked subtypes are rejected.
|
||||
const contentTypeExtension = subtype.replace(/\s+/g, '');
|
||||
if (!isValidExtension(contentTypeExtension)) {
|
||||
rejectExtension(contentTypeExtension);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const base64 = req.body.toString('base64');
|
||||
@@ -326,11 +401,12 @@ export class FilesRouter {
|
||||
const { filesController } = config;
|
||||
let { filename } = req.params;
|
||||
const file = new Parse.File(filename, { base64: '' });
|
||||
const fileAuth = await FilesRouter._resolveAuth(req, config);
|
||||
const triggerResult = await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.beforeFind,
|
||||
{ file },
|
||||
config,
|
||||
req.auth
|
||||
fileAuth
|
||||
);
|
||||
if (triggerResult?.file?._name) {
|
||||
filename = triggerResult.file._name;
|
||||
@@ -346,7 +422,7 @@ export class FilesRouter {
|
||||
triggers.Types.afterFind,
|
||||
{ file },
|
||||
config,
|
||||
req.auth
|
||||
fileAuth
|
||||
);
|
||||
res.status(200);
|
||||
res.json(data);
|
||||
|
||||
@@ -51,7 +51,6 @@ function getFileForProductIdentifier(productIdentifier, req) {
|
||||
'_Product',
|
||||
{ productIdentifier: productIdentifier },
|
||||
undefined,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(function (result) {
|
||||
|
||||
@@ -19,7 +19,6 @@ export class InstallationsRouter extends ClassesRouter {
|
||||
'_Installation',
|
||||
body.where,
|
||||
options,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
|
||||
@@ -555,6 +555,16 @@ export class PagesRouter extends PromiseRouter {
|
||||
(req.body || {})[pageParams.locale] ||
|
||||
(req.params || {})[pageParams.locale] ||
|
||||
(req.headers || {})[pageParamHeaderPrefix + pageParams.locale];
|
||||
|
||||
// Validate locale format to prevent path traversal and invalid
|
||||
// HTTP header characters; only allow standard locale patterns
|
||||
// like "en", "en-US", "de-AT", "zh-Hans-CN"
|
||||
if (locale !== undefined && typeof locale !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof locale === 'string' && !/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(locale)) {
|
||||
return undefined;
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,29 +9,51 @@ export class SessionsRouter extends ClassesRouter {
|
||||
return '_Session';
|
||||
}
|
||||
|
||||
handleMe(req) {
|
||||
// TODO: Verify correct behavior
|
||||
async handleMe(req) {
|
||||
if (!req.info || !req.info.sessionToken) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token required.');
|
||||
}
|
||||
return rest
|
||||
.find(
|
||||
req.config,
|
||||
Auth.master(req.config),
|
||||
'_Session',
|
||||
{ sessionToken: req.info.sessionToken },
|
||||
undefined,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
)
|
||||
.then(response => {
|
||||
if (!response.results || response.results.length == 0) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token not found.');
|
||||
}
|
||||
return {
|
||||
response: response.results[0],
|
||||
};
|
||||
});
|
||||
const sessionToken = req.info.sessionToken;
|
||||
// Query with master key to validate the session token and get the session objectId
|
||||
const sessionResponse = await rest.find(
|
||||
req.config,
|
||||
Auth.master(req.config),
|
||||
'_Session',
|
||||
{ sessionToken },
|
||||
{},
|
||||
req.info.context
|
||||
);
|
||||
if (
|
||||
!sessionResponse.results ||
|
||||
sessionResponse.results.length == 0 ||
|
||||
!sessionResponse.results[0].user
|
||||
) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token not found.');
|
||||
}
|
||||
const sessionObjectId = sessionResponse.results[0].objectId;
|
||||
const userId = sessionResponse.results[0].user.objectId;
|
||||
// Re-fetch the session with the caller's auth context so that
|
||||
// protectedFields and CLP apply correctly
|
||||
const userAuth = new Auth.Auth({
|
||||
config: req.config,
|
||||
isMaster: false,
|
||||
user: Parse.Object.fromJSON({ className: '_User', objectId: userId }),
|
||||
installationId: req.info.installationId,
|
||||
});
|
||||
const response = await rest.get(
|
||||
req.config,
|
||||
userAuth,
|
||||
'_Session',
|
||||
sessionObjectId,
|
||||
{},
|
||||
req.info.context
|
||||
);
|
||||
if (!response.results || response.results.length == 0) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token not found.');
|
||||
}
|
||||
return {
|
||||
response: response.results[0],
|
||||
};
|
||||
}
|
||||
|
||||
handleUpdateToRevocableSession(req) {
|
||||
|
||||
+66
-35
@@ -18,6 +18,7 @@ import { promiseEnsureIdempotency } from '../middlewares';
|
||||
import RestWrite from '../RestWrite';
|
||||
import { logger } from '../logger';
|
||||
import { createSanitizedError } from '../Error';
|
||||
import { applyAuthDataOptimisticLock } from '../AuthDataLock';
|
||||
|
||||
export class UsersRouter extends ClassesRouter {
|
||||
className() {
|
||||
@@ -108,7 +109,13 @@ export class UsersRouter extends ClassesRouter {
|
||||
.find('_User', query, {}, Auth.maintenance(req.config))
|
||||
.then(results => {
|
||||
if (!results.length) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
|
||||
// Perform a dummy bcrypt compare to normalize response timing,
|
||||
// preventing user enumeration via timing side-channel
|
||||
return passwordCrypto
|
||||
.compare(password, passwordCrypto.dummyHash)
|
||||
.then(() => {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
|
||||
});
|
||||
}
|
||||
|
||||
if (results.length > 1) {
|
||||
@@ -121,6 +128,11 @@ export class UsersRouter extends ClassesRouter {
|
||||
user = results[0];
|
||||
}
|
||||
|
||||
if (typeof user.password !== 'string' || user.password.length === 0) {
|
||||
// Passwordless account (e.g. OAuth-only): run dummy compare for
|
||||
// timing normalization, discard result, always reject
|
||||
return passwordCrypto.compare(password, passwordCrypto.dummyHash).then(() => false);
|
||||
}
|
||||
return passwordCrypto.compare(password, user.password);
|
||||
})
|
||||
.then(correct => {
|
||||
@@ -170,34 +182,48 @@ 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.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.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) {
|
||||
@@ -223,7 +249,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
{ objectId: user.objectId },
|
||||
req.body || {},
|
||||
user,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
),
|
||||
user
|
||||
@@ -280,12 +305,19 @@ 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 };
|
||||
// Prevent concurrent requests from both succeeding when consuming single-use
|
||||
// tokens (e.g. MFA recovery codes or SMS OTP tokens) by extending the update
|
||||
// WHERE clause with the original values of changed primitive/array fields.
|
||||
applyAuthDataOptimisticLock(query, user.authData, validatedAuthData);
|
||||
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 +415,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 => {
|
||||
@@ -403,7 +435,6 @@ export class UsersRouter extends ClassesRouter {
|
||||
'_Session',
|
||||
{ sessionToken: req.info.sessionToken },
|
||||
undefined,
|
||||
req.info.clientSDK,
|
||||
req.info.context
|
||||
);
|
||||
if (records.results && records.results.length) {
|
||||
|
||||
@@ -478,6 +478,23 @@ class Utils {
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file extension as the substring after the last dot in the
|
||||
* filename. A trailing dot or a filename without a dot yields an empty
|
||||
* string. Callers apply any further normalization (whitespace, MIME
|
||||
* parameters, etc.) for their use case — this is a pure parser, not a
|
||||
* policy.
|
||||
*
|
||||
* @param {string} filename
|
||||
* @returns {string} the extension, or `''` if none
|
||||
*/
|
||||
static getFileExtension(filename) {
|
||||
if (!filename || !filename.includes('.')) {
|
||||
return '';
|
||||
}
|
||||
return filename.substring(filename.lastIndexOf('.') + 1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Utils;
|
||||
|
||||
+1
-10
@@ -2,7 +2,6 @@ import AppCache from './cache';
|
||||
import Parse from 'parse/node';
|
||||
import auth from './Auth';
|
||||
import Config from './Config';
|
||||
import ClientSDK from './ClientSDK';
|
||||
import defaultLogger from './logger';
|
||||
import rest from './rest';
|
||||
import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter';
|
||||
@@ -94,7 +93,6 @@ export async function handleParseHeaders(req, res, next) {
|
||||
javascriptKey: req.get('X-Parse-Javascript-Key'),
|
||||
dotNetKey: req.get('X-Parse-Windows-Key'),
|
||||
restAPIKey: req.get('X-Parse-REST-API-Key'),
|
||||
clientVersion: req.get('X-Parse-Client-Version'),
|
||||
context: context,
|
||||
};
|
||||
|
||||
@@ -149,10 +147,7 @@ export async function handleParseHeaders(req, res, next) {
|
||||
delete req.body._JavaScriptKey;
|
||||
// TODO: test that the REST API formats generated by the other
|
||||
// SDKs are handled ok
|
||||
if (req.body._ClientVersion) {
|
||||
info.clientVersion = req.body._ClientVersion;
|
||||
delete req.body._ClientVersion;
|
||||
}
|
||||
delete req.body._ClientVersion;
|
||||
if (req.body._InstallationId) {
|
||||
info.installationId = req.body._InstallationId;
|
||||
delete req.body._InstallationId;
|
||||
@@ -193,10 +188,6 @@ export async function handleParseHeaders(req, res, next) {
|
||||
info.sessionToken = info.sessionToken.toString();
|
||||
}
|
||||
|
||||
if (info.clientVersion) {
|
||||
info.clientSDK = ClientSDK.fromString(info.clientVersion);
|
||||
}
|
||||
|
||||
if (fileViaJSON && req.body) {
|
||||
req.fileData = req.body.fileData;
|
||||
// We need to repopulate req.body with a buffer
|
||||
|
||||
@@ -27,7 +27,13 @@ function compare(password, hashedPassword) {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
// Pre-computed bcrypt hash (cost factor 10) used for timing normalization.
|
||||
// The actual value is irrelevant; it ensures bcrypt.compare() runs with
|
||||
// realistic cost even when no real password hash is available.
|
||||
const dummyHash = '$2b$10$Wd1gvrMYPnQv5pHBbXCwCehxXmJSEzRqNON0ev98L6JJP5296S35i';
|
||||
|
||||
module.exports = {
|
||||
hash: hash,
|
||||
compare: compare,
|
||||
dummyHash: dummyHash,
|
||||
};
|
||||
|
||||
+5
-11
@@ -30,7 +30,6 @@ async function runFindTriggers(
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
options = {}
|
||||
) {
|
||||
@@ -89,7 +88,6 @@ async function runFindTriggers(
|
||||
className,
|
||||
restWhere: refilterWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
runBeforeFind: false,
|
||||
runAfterFind: false,
|
||||
@@ -125,7 +123,6 @@ async function runFindTriggers(
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
runBeforeFind: false,
|
||||
});
|
||||
@@ -134,7 +131,7 @@ async function runFindTriggers(
|
||||
}
|
||||
|
||||
// Returns a promise for an object with optional keys 'results' and 'count'.
|
||||
const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
|
||||
const find = async (config, auth, className, restWhere, restOptions, context) => {
|
||||
enforceRoleSecurity('find', className, auth, config);
|
||||
return runFindTriggers(
|
||||
config,
|
||||
@@ -142,14 +139,13 @@ const find = async (config, auth, className, restWhere, restOptions, clientSDK,
|
||||
className,
|
||||
restWhere,
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
{ isGet: false }
|
||||
);
|
||||
};
|
||||
|
||||
// get is just like find but only queries an objectId.
|
||||
const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => {
|
||||
const get = async (config, auth, className, objectId, restOptions, context) => {
|
||||
enforceRoleSecurity('get', className, auth, config);
|
||||
return runFindTriggers(
|
||||
config,
|
||||
@@ -157,7 +153,6 @@ const get = async (config, auth, className, objectId, restOptions, clientSDK, co
|
||||
className,
|
||||
{ objectId },
|
||||
restOptions,
|
||||
clientSDK,
|
||||
context,
|
||||
{ isGet: true }
|
||||
);
|
||||
@@ -263,16 +258,16 @@ function del(config, auth, className, objectId, context) {
|
||||
}
|
||||
|
||||
// Returns a promise for a {response, status, location} object.
|
||||
function create(config, auth, className, restObject, clientSDK, context) {
|
||||
function create(config, auth, className, restObject, context) {
|
||||
enforceRoleSecurity('create', className, auth, config);
|
||||
var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK, context);
|
||||
var write = new RestWrite(config, auth, className, null, restObject, null, context);
|
||||
return write.execute();
|
||||
}
|
||||
|
||||
// Returns a promise that contains the fields of the update that the
|
||||
// REST API is supposed to return.
|
||||
// Usually, this is just updatedAt.
|
||||
function update(config, auth, className, restWhere, restObject, clientSDK, context) {
|
||||
function update(config, auth, className, restWhere, restObject, context) {
|
||||
enforceRoleSecurity('update', className, auth, config);
|
||||
|
||||
return Promise.resolve()
|
||||
@@ -309,7 +304,6 @@ function update(config, auth, className, restWhere, restObject, clientSDK, conte
|
||||
restWhere,
|
||||
restObject,
|
||||
originalRestObject,
|
||||
clientSDK,
|
||||
context,
|
||||
'update'
|
||||
).execute();
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ function getStore(category, name, applicationId) {
|
||||
return createStore();
|
||||
}
|
||||
store = store[component];
|
||||
if (!store) {
|
||||
if (!store || Object.getPrototypeOf(store) !== null) {
|
||||
return createStore();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user