mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4d6c5a16d | ||
|
|
7e9d53a083 | ||
|
|
cce91e5548 | ||
|
|
4d3465c1b9 | ||
|
|
37039b09e7 | ||
|
|
816078fff7 | ||
|
|
6ed35dbfbd | ||
|
|
e9c85dfe40 | ||
|
|
3de7aa3fd1 | ||
|
|
1103c7a890 | ||
|
|
ccf85f95b3 | ||
|
|
be12a60d65 | ||
|
|
30f1612a2d | ||
|
|
576f4f6712 | ||
|
|
f8612109e3 | ||
|
|
0644675f37 | ||
|
|
880e8e6929 | ||
|
|
ca55aafe27 | ||
|
|
3fad4fb1c4 | ||
|
|
c700ebd285 | ||
|
|
f12e1c3e31 | ||
|
|
78859a9bc7 | ||
|
|
07478de5e9 | ||
|
|
43658f1fd8 |
@@ -829,6 +829,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
|
||||
*/
|
||||
@@ -856,6 +890,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,73 @@
|
||||
# [9.10.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.1...9.10.0-alpha.2) (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)) ([#10521](https://github.com/parse-community/parse-server/issues/10521)) ([cce91e5](https://github.com/parse-community/parse-server/commit/cce91e554818492d1b153c46dc3b91fa6e0309bc))
|
||||
|
||||
# [9.10.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.13...9.10.0-alpha.1) (2026-06-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add option to disallow aggregation pipelines for the read-only master key ([#10517](https://github.com/parse-community/parse-server/issues/10517)) ([816078f](https://github.com/parse-community/parse-server/commit/816078fff7f95f333a99c1e2d7166a585742d466))
|
||||
|
||||
## [9.9.1-alpha.13](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.12...9.9.1-alpha.13) (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)) ([#10515](https://github.com/parse-community/parse-server/issues/10515)) ([e9c85df](https://github.com/parse-community/parse-server/commit/e9c85dfe40a866a55ebae3b6ae56285ac0a22e64))
|
||||
|
||||
## [9.9.1-alpha.12](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.11...9.9.1-alpha.12) (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)) ([#10511](https://github.com/parse-community/parse-server/issues/10511)) ([1103c7a](https://github.com/parse-community/parse-server/commit/1103c7a890e0455ba3dccd4bc5db17efe1789c9a))
|
||||
|
||||
## [9.9.1-alpha.11](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.10...9.9.1-alpha.11) (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)) ([#10505](https://github.com/parse-community/parse-server/issues/10505)) ([be12a60](https://github.com/parse-community/parse-server/commit/be12a60d65b6e140481882037fb896b1f951df50))
|
||||
|
||||
## [9.9.1-alpha.10](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.9...9.9.1-alpha.10) (2026-06-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Middleware route checks do not match routing-equivalent path variants (trailing slash, case) ([#10501](https://github.com/parse-community/parse-server/issues/10501)) ([f861210](https://github.com/parse-community/parse-server/commit/f8612109e3175399b4f814efcc961128de6143a5))
|
||||
|
||||
## [9.9.1-alpha.9](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.8...9.9.1-alpha.9) (2026-06-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* rateLimit on exact static routes is bypassed by appending a query string ([#10500](https://github.com/parse-community/parse-server/issues/10500)) ([880e8e6](https://github.com/parse-community/parse-server/commit/880e8e6929fd62ed3680b138613bcfdcd572db07))
|
||||
|
||||
## [9.9.1-alpha.8](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.7...9.9.1-alpha.8) (2026-06-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* LiveQuery subscriptions leak when a client reuses a subscribe requestId ([#10499](https://github.com/parse-community/parse-server/issues/10499)) ([3fad4fb](https://github.com/parse-community/parse-server/commit/3fad4fb1c4b41f51dab96532245bb302d2be30e6))
|
||||
|
||||
## [9.9.1-alpha.7](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.6...9.9.1-alpha.7) (2026-06-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud Function multipart requests bypass the maxUploadSize limit ([#10498](https://github.com/parse-community/parse-server/issues/10498)) ([f12e1c3](https://github.com/parse-community/parse-server/commit/f12e1c3e31fb211bb7fe106a9a295ac7d0dd4ea7))
|
||||
|
||||
## [9.9.1-alpha.6](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.5...9.9.1-alpha.6) (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)) ([#10493](https://github.com/parse-community/parse-server/issues/10493)) ([43658f1](https://github.com/parse-community/parse-server/commit/43658f1fd83689b24a4350094f1071ac555ac9b3))
|
||||
|
||||
## [9.9.1-alpha.5](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.4...9.9.1-alpha.5) (2026-06-03)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.5",
|
||||
"version": "9.10.0-alpha.2",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.5",
|
||||
"version": "9.10.0-alpha.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.5",
|
||||
"version": "9.10.0-alpha.2",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
|
||||
@@ -363,4 +363,62 @@ describe('Cloud Code Multipart', () => {
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data.result.isMaster).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject multipart request with many empty parts whose wire size exceeds maxUploadSize', async () => {
|
||||
await reconfigureServer({ maxUploadSize: '1kb' });
|
||||
|
||||
Parse.Cloud.define('multipartManyEmptyParts', req => {
|
||||
return { count: Object.keys(req.params).length };
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryManyEmptyParts';
|
||||
const parts = [];
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
parts.push({ name: `f${i}`, value: '' });
|
||||
}
|
||||
const body = buildMultipartBody(boundary, parts);
|
||||
// The wire body is far larger than maxUploadSize even though every field
|
||||
// value is empty, so the value/chunk byte counters alone never trip.
|
||||
expect(body.length).toBeGreaterThan(100 * 1024);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartManyEmptyParts`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE);
|
||||
});
|
||||
|
||||
it('should reject multipart request whose Content-Length exceeds maxUploadSize', async () => {
|
||||
await reconfigureServer({ maxUploadSize: '1kb' });
|
||||
|
||||
Parse.Cloud.define('multipartContentLength', req => {
|
||||
return { count: Object.keys(req.params).length };
|
||||
});
|
||||
|
||||
const boundary = '----TestBoundaryContentLength';
|
||||
const parts = [];
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
parts.push({ name: `f${i}`, value: '' });
|
||||
}
|
||||
const body = buildMultipartBody(boundary, parts);
|
||||
|
||||
const result = await postMultipart(
|
||||
`http://localhost:8378/1/functions/multipartContentLength`,
|
||||
{
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(body.length),
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
body
|
||||
);
|
||||
|
||||
expect(result.data.code).toBe(Parse.Error.OBJECT_TOO_LARGE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1518,6 +1518,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: {
|
||||
|
||||
@@ -1485,3 +1485,523 @@ describe('ParseLiveQuery', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParseLiveQuery duplicate requestId handling', function () {
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const waitFor = async predicate => {
|
||||
const deadline = Date.now() + 4000;
|
||||
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 = [];
|
||||
});
|
||||
|
||||
const configureServer = async () => {
|
||||
const parseServer = await reconfigureServer({
|
||||
liveQuery: { classNames: ['LQDupA', 'LQDupB'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
return parseServer.liveQueryServer;
|
||||
};
|
||||
|
||||
// Opens a raw LiveQuery WebSocket client and returns a small protocol helper.
|
||||
const openClient = async () => {
|
||||
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 }));
|
||||
const client = {
|
||||
socket,
|
||||
messages,
|
||||
subscribe(requestId, className, where) {
|
||||
socket.send(JSON.stringify({ op: 'subscribe', requestId, query: { className, where } }));
|
||||
},
|
||||
update(requestId, className, where) {
|
||||
socket.send(JSON.stringify({ op: 'update', requestId, query: { className, where } }));
|
||||
},
|
||||
countOp(op) {
|
||||
return messages.filter(message => message.op === op).length;
|
||||
},
|
||||
waitForOpCount(op, count) {
|
||||
return waitFor(() => this.countOp(op) === count);
|
||||
},
|
||||
};
|
||||
await waitFor(() => messages.some(message => message.op === 'connected'));
|
||||
return client;
|
||||
};
|
||||
|
||||
it('replaces rather than leaks subscriptions when a client reuses a requestId with different queries', async () => {
|
||||
const lqServer = await configureServer();
|
||||
const client = await openClient();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.subscribe(7, 'LQDupA', { marker: `ws-${i}` });
|
||||
}
|
||||
await client.waitForOpCount('subscribed', 5);
|
||||
|
||||
// Reusing one requestId must keep a single active subscription, not one per frame.
|
||||
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
|
||||
|
||||
client.socket.close();
|
||||
await waitFor(() => lqServer.clients.size === 0);
|
||||
|
||||
// No stale subscriptions may survive the disconnect.
|
||||
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('does not leak subscriptions when a client reuses a requestId with the same query', async () => {
|
||||
const lqServer = await configureServer();
|
||||
const client = await openClient();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.subscribe(7, 'LQDupA', { marker: 'same' });
|
||||
}
|
||||
await client.waitForOpCount('subscribed', 5);
|
||||
|
||||
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
|
||||
|
||||
client.socket.close();
|
||||
await waitFor(() => lqServer.clients.size === 0);
|
||||
|
||||
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('cleans up the prior subscription when a client reuses a requestId on a different class', async () => {
|
||||
const lqServer = await configureServer();
|
||||
const client = await openClient();
|
||||
|
||||
client.subscribe(7, 'LQDupA', { marker: 'a' });
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
client.subscribe(7, 'LQDupB', { marker: 'b' });
|
||||
await client.waitForOpCount('subscribed', 2);
|
||||
client.subscribe(7, 'LQDupA', { marker: 'a2' });
|
||||
await client.waitForOpCount('subscribed', 3);
|
||||
|
||||
// Only the most recent subscription survives; the prior class entry is pruned.
|
||||
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
|
||||
expect(lqServer.subscriptions.has('LQDupB')).toBe(false);
|
||||
|
||||
client.socket.close();
|
||||
await waitFor(() => lqServer.clients.size === 0);
|
||||
|
||||
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
|
||||
expect(lqServer.subscriptions.has('LQDupB')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not tear down a subscription still held by another client when a client reuses a requestId', async () => {
|
||||
const lqServer = await configureServer();
|
||||
const clientA = await openClient();
|
||||
const clientB = await openClient();
|
||||
|
||||
// Both clients share the same query, so they share one Subscription.
|
||||
clientA.subscribe(7, 'LQDupA', { marker: 'shared' });
|
||||
await clientA.waitForOpCount('subscribed', 1);
|
||||
clientB.subscribe(9, 'LQDupA', { marker: 'shared' });
|
||||
await clientB.waitForOpCount('subscribed', 1);
|
||||
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
|
||||
|
||||
// Client A reuses its requestId with a different query.
|
||||
clientA.subscribe(7, 'LQDupA', { marker: 'other' });
|
||||
await clientA.waitForOpCount('subscribed', 2);
|
||||
|
||||
// The shared subscription must survive (B still holds it), alongside A's new one.
|
||||
expect(lqServer.subscriptions.get('LQDupA').size).toBe(2);
|
||||
|
||||
// The shared subscription still delivers events to B, but not to A anymore.
|
||||
const shared = new Parse.Object('LQDupA');
|
||||
shared.set('marker', 'shared');
|
||||
await shared.save(null, { useMasterKey: true });
|
||||
await clientB.waitForOpCount('create', 1);
|
||||
expect(clientB.countOp('create')).toBe(1);
|
||||
expect(clientA.countOp('create')).toBe(0);
|
||||
|
||||
clientA.socket.close();
|
||||
clientB.socket.close();
|
||||
await waitFor(() => lqServer.clients.size === 0);
|
||||
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('delivers events only for the replacement query after a client reuses a requestId', async () => {
|
||||
const lqServer = await configureServer();
|
||||
const client = await openClient();
|
||||
|
||||
client.subscribe(7, 'LQDupA', { marker: 'old' });
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
client.subscribe(7, 'LQDupA', { marker: 'new' });
|
||||
await client.waitForOpCount('subscribed', 2);
|
||||
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
|
||||
|
||||
const oldObject = new Parse.Object('LQDupA');
|
||||
oldObject.set('marker', 'old');
|
||||
await oldObject.save(null, { useMasterKey: true });
|
||||
|
||||
const newObject = new Parse.Object('LQDupA');
|
||||
newObject.set('marker', 'new');
|
||||
await newObject.save(null, { useMasterKey: true });
|
||||
|
||||
await client.waitForOpCount('create', 1);
|
||||
// Only the replacement query (marker 'new') may produce an event.
|
||||
expect(client.countOp('create')).toBe(1);
|
||||
expect(client.messages.find(message => message.op === 'create').object.marker).toBe('new');
|
||||
});
|
||||
|
||||
it('keeps the update op working after the duplicate-subscribe cleanup', async () => {
|
||||
const lqServer = await configureServer();
|
||||
const client = await openClient();
|
||||
|
||||
client.subscribe(7, 'LQDupA', { marker: 'old' });
|
||||
await client.waitForOpCount('subscribed', 1);
|
||||
client.update(7, 'LQDupA', { marker: 'new' });
|
||||
await client.waitForOpCount('subscribed', 2);
|
||||
|
||||
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
|
||||
|
||||
const updated = new Parse.Object('LQDupA');
|
||||
updated.set('marker', 'new');
|
||||
await updated.save(null, { useMasterKey: true });
|
||||
await client.waitForOpCount('create', 1);
|
||||
expect(client.countOp('create')).toBe(1);
|
||||
|
||||
client.socket.close();
|
||||
await waitFor(() => lqServer.clients.size === 0);
|
||||
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParseLiveQuery cross-origin connection authorization', function () {
|
||||
// CSWSH report (WSAdapter): LiveQuery auth is bound to the sessionToken in the
|
||||
// `connect` message body, not to ambient/cookie credentials. A cross-origin page
|
||||
// cannot read the victim's sessionToken, so its connection is anonymous and ACL
|
||||
// filtering limits it to public-read objects only.
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const waitFor = async predicate => {
|
||||
const deadline = Date.now() + 4000;
|
||||
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 with no session token, modeling a
|
||||
// cross-origin page that has no access to the victim's session.
|
||||
const openClient = async () => {
|
||||
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 }));
|
||||
const client = {
|
||||
socket,
|
||||
messages,
|
||||
subscribe(requestId, className, where) {
|
||||
socket.send(JSON.stringify({ op: 'subscribe', requestId, query: { className, where } }));
|
||||
},
|
||||
countOp(op) {
|
||||
return messages.filter(message => message.op === op).length;
|
||||
},
|
||||
createdIds() {
|
||||
return messages.filter(m => m.op === 'create').map(m => m.object && m.object.objectId);
|
||||
},
|
||||
waitForOpCount(op, count) {
|
||||
return waitFor(() => this.countOp(op) === count);
|
||||
},
|
||||
};
|
||||
await waitFor(() => messages.some(message => message.op === 'connected'));
|
||||
return client;
|
||||
};
|
||||
|
||||
it('does not deliver ACL-protected objects to a connection that presents no session token', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: { classNames: ['CrossOriginChat'] },
|
||||
startLiveQueryServer: true,
|
||||
verbose: false,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
const victim = new Parse.User();
|
||||
victim.setUsername('victim');
|
||||
victim.setPassword('password');
|
||||
await victim.signUp();
|
||||
|
||||
// The attacker page connects with no session token and subscribes to everything.
|
||||
const attacker = await openClient();
|
||||
attacker.subscribe(1, 'CrossOriginChat', {});
|
||||
await attacker.waitForOpCount('subscribed', 1);
|
||||
|
||||
// A public-read object is delivered to the anonymous connection (proves the socket
|
||||
// and subscription are live — the missing protected object below is a real denial,
|
||||
// not a dead connection).
|
||||
const publicObj = new Parse.Object('CrossOriginChat');
|
||||
const publicACL = new Parse.ACL();
|
||||
publicACL.setPublicReadAccess(true);
|
||||
publicObj.setACL(publicACL);
|
||||
publicObj.set('body', 'public');
|
||||
await publicObj.save(null, { useMasterKey: true });
|
||||
await attacker.waitForOpCount('create', 1);
|
||||
expect(attacker.createdIds()).toEqual([publicObj.id]);
|
||||
|
||||
// The victim's private object (readable only by the victim) must never reach the
|
||||
// attacker's session-less connection.
|
||||
const secretObj = new Parse.Object('CrossOriginChat');
|
||||
const secretACL = new Parse.ACL();
|
||||
secretACL.setPublicReadAccess(false);
|
||||
secretACL.setReadAccess(victim, true);
|
||||
secretObj.setACL(secretACL);
|
||||
secretObj.set('body', 'secret');
|
||||
await secretObj.save(null, { useMasterKey: true });
|
||||
|
||||
// A second public save acts as an ordering barrier: LiveQuery delivers events on a
|
||||
// subscription in publish order, so once this later object's `create` arrives, the
|
||||
// earlier `secret` save has already had its chance. Asserting the exact id list is
|
||||
// then deterministic rather than relying on a wall-clock window.
|
||||
const publicObj2 = new Parse.Object('CrossOriginChat');
|
||||
const publicACL2 = new Parse.ACL();
|
||||
publicACL2.setPublicReadAccess(true);
|
||||
publicObj2.setACL(publicACL2);
|
||||
publicObj2.set('body', 'public-2');
|
||||
await publicObj2.save(null, { useMasterKey: true });
|
||||
await attacker.waitForOpCount('create', 2);
|
||||
expect(attacker.createdIds()).toEqual([publicObj.id, publicObj2.id]);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,6 +306,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
|
||||
|
||||
@@ -1774,3 +1774,66 @@ describe('Parse.Query Aggregate testing', () => {
|
||||
expect(results[0].total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parse.Query Aggregate readOnlyMasterKey', () => {
|
||||
const readOnlyMasterKeyOptions = {
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Rest-API-Key': 'test',
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json: true,
|
||||
};
|
||||
|
||||
it('allows the read-only master key to run aggregation pipelines by default', async () => {
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, readOnlyMasterKeyOptions, {
|
||||
body: { $group: { _id: '$name' } },
|
||||
});
|
||||
const resp = await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
expect(resp.results.length).toBe(1);
|
||||
expect(resp.results[0].objectId).toBe('foo');
|
||||
});
|
||||
|
||||
it('blocks the read-only master key from running aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => {
|
||||
await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, readOnlyMasterKeyOptions, {
|
||||
body: { $group: { _id: '$name' } },
|
||||
});
|
||||
try {
|
||||
await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
fail('aggregation should be forbidden for the read-only master key');
|
||||
} catch (e) {
|
||||
expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a write-capable $out stage for the read-only master key when allowAggregationForReadOnlyMasterKey is false', async () => {
|
||||
await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, readOnlyMasterKeyOptions, {
|
||||
body: {
|
||||
pipeline: [{ $match: { name: 'foo' } }, { $out: 'CreatedByReadOnlyAggregate' }],
|
||||
},
|
||||
});
|
||||
try {
|
||||
await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
fail('aggregation should be forbidden for the read-only master key');
|
||||
} catch (e) {
|
||||
expect(e.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
}
|
||||
});
|
||||
|
||||
it('still allows the full master key to run aggregation pipelines when allowAggregationForReadOnlyMasterKey is false', async () => {
|
||||
await reconfigureServer({ allowAggregationForReadOnlyMasterKey: false });
|
||||
await new TestObject({ name: 'foo' }).save(null, { useMasterKey: true });
|
||||
const options = Object.assign({}, masterKeyOptions, {
|
||||
body: { $group: { _id: '$name' } },
|
||||
});
|
||||
const resp = await get(Parse.serverURL + '/aggregate/TestObject', options);
|
||||
expect(resp.results.length).toBe(1);
|
||||
expect(resp.results[0].objectId).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -863,6 +863,336 @@ describe('rate limit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('query string', () => {
|
||||
it('enforces rate limit on an exact static path when a query string is appended', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('rluser', 'password');
|
||||
// First login attempt carrying a query string — reaches /login and consumes the single token.
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?bypass=1',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(404);
|
||||
expect(res1.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
|
||||
// Second login attempt with a different query string — must be rate limited, not bypassed.
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?bypass=2',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many login requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('enforces rate limit on GET login when credentials are sent as query parameters', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestMethods: ['GET'],
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('rluser', 'password');
|
||||
// GET login carries credentials in the query string; the limiter must still match.
|
||||
const res1 = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=rluser&password=wrong&r=1',
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(404);
|
||||
const res2 = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=rluser&password=wrong&r=2',
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many login requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('counts query-string and plain requests against the same rate limit window', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('rluser', 'password');
|
||||
// A plain request consumes the single token.
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(404);
|
||||
// A subsequent request that appends a query string must draw from the same window.
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?bypass=1',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many login requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a batch sub-request reach an exact static route by appending a query string', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('rluser', 'password');
|
||||
// A query-string sub-request path is not normalized to /login: it fails to route
|
||||
// (the limiter check and the router agree), so it cannot bypass the limiter.
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/batch',
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{ method: 'POST', path: '/1/login?bypass=1', body: { username: 'rluser', password: 'wrong' } },
|
||||
{ method: 'POST', path: '/1/login?bypass=2', body: { username: 'rluser', password: 'wrong' } },
|
||||
],
|
||||
}),
|
||||
}).catch(e => e);
|
||||
// The query string is preserved in the sub-request path (path.posix.join does not
|
||||
// strip it), so the router finds no route for `/login?bypass=1`; tryRouteRequest
|
||||
// throws synchronously and aborts the whole batch instead of reaching /login. The
|
||||
// sub-request therefore cannot bypass the limiter.
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.data.code).toBe(Parse.Error.INVALID_JSON);
|
||||
expect(response.data.error).toContain('cannot route');
|
||||
});
|
||||
|
||||
it('enforces rate limit on requestPasswordReset when a query string is appended', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/requestPasswordReset',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many reset requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
// First reset request carrying a query string reaches the handler and consumes the
|
||||
// single token; the handler's own outcome is irrelevant — only that it is counted.
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/requestPasswordReset?bypass=1',
|
||||
body: JSON.stringify({ email: 'nobody@example.com' }),
|
||||
}).catch(e => e);
|
||||
expect(res1.status).not.toBe(429);
|
||||
// Second reset request with a different query string must be rate limited.
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/requestPasswordReset?bypass=2',
|
||||
body: JSON.stringify({ email: 'nobody@example.com' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many reset requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not split the user-zone rate limit window for /sessions/me via a query string', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/sessions/me',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
zone: Parse.Server.RateLimitZone.user,
|
||||
errorResponseMessage: 'Too many session requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = await Parse.User.signUp('rluser', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
const authHeaders = { ...headers, 'X-Parse-Session-Token': sessionToken };
|
||||
// First read consumes the single token. The user-zone key resolves to the caller's IP
|
||||
// here because the /sessions/me GET branch skips session resolution in the keyGenerator.
|
||||
const res1 = await request({
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(200);
|
||||
// Appending a query string must not move the request into a separate window keyed by
|
||||
// user id; it must draw from the same window and be rate limited.
|
||||
const res2 = await request({
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/sessions/me?bypass=1',
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many session requests',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('exact static route variants', () => {
|
||||
// Express routing is case-insensitive and trailing-slash-tolerant by default, so `/login/`
|
||||
// and `/LOGIN` reach the same handler as `/login`. The login session-token deletion (used
|
||||
// for rate-limit zone keying) must recognize those routing-equivalent variants too, or a
|
||||
// session/user-zone `/login` limiter can be keyed by a rotated token instead of the IP.
|
||||
it('does not split the session-zone /login rate limit window via a trailing slash', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
zone: Parse.Server.RateLimitZone.session,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = await Parse.User.signUp('rluser', 'password');
|
||||
const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() };
|
||||
// Plain /login deletes the session token, so the session zone keys by IP and the window
|
||||
// is consumed.
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(404);
|
||||
// The trailing-slash variant routes to the same handler and must also drop the token,
|
||||
// keying by IP so it draws from the same window instead of a token-keyed one.
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/login/',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many login requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not split the session-zone /login rate limit window via path casing', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
zone: Parse.Server.RateLimitZone.session,
|
||||
errorResponseMessage: 'Too many login requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = await Parse.User.signUp('rluser', 'password');
|
||||
const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() };
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(404);
|
||||
// The upper-case variant routes to the same handler and must be rate limited too.
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/LOGIN',
|
||||
body: JSON.stringify({ username: 'rluser', password: 'wrong' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many login requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not split the user-zone /sessions/me rate limit window via a trailing slash', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/sessions/me',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
zone: Parse.Server.RateLimitZone.user,
|
||||
errorResponseMessage: 'Too many session requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = await Parse.User.signUp('rluser', 'password');
|
||||
const authHeaders = { ...headers, 'X-Parse-Session-Token': user.getSessionToken() };
|
||||
const res1 = await request({
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/sessions/me',
|
||||
}).catch(e => e);
|
||||
expect(res1.status).toBe(200);
|
||||
// The trailing-slash variant routes to the same handler and must key identically, drawing
|
||||
// from the same window instead of a separate user-id-keyed one.
|
||||
const res2 = await request({
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
url: 'http://localhost:8378/1/sessions/me/',
|
||||
}).catch(e => e);
|
||||
expect(res2.status).toBe(429);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many session requests',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('method override bypass', () => {
|
||||
it('should enforce rate limit when _method override attempts to change POST to GET', async () => {
|
||||
Parse.Cloud.beforeLogin(() => {}, {
|
||||
@@ -895,6 +1225,132 @@ describe('rate limit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not apply a requestMethods POST-only limit to direct GET login requests', async () => {
|
||||
// `requestMethods` scopes a limit to the listed request methods. `/login` is
|
||||
// reachable via both GET and POST, so a POST-only limit intentionally does not
|
||||
// apply to GET login requests; operators must list all methods or omit
|
||||
// `requestMethods` (default is all methods) to cover the endpoint.
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: ['POST'],
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=testuser&password=password',
|
||||
});
|
||||
expect(res.data.username).toBe('testuser');
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the rate limit to direct GET login requests when requestMethods includes GET', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: ['POST', 'GET'],
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
const res1 = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=testuser&password=password',
|
||||
});
|
||||
expect(res1.data.username).toBe('testuser');
|
||||
const res2 = await request({
|
||||
method: 'GET',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login?username=testuser&password=password',
|
||||
}).catch(e => e);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the rate limit to GET login requests sent via _method override when requestMethods includes GET', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
requestMethods: ['POST', 'GET'],
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }),
|
||||
});
|
||||
expect(res1.data.username).toBe('testuser');
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the rate limit to login requests of any method when requestMethods is omitted', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
{
|
||||
requestPath: '/login',
|
||||
requestTimeWindow: 10000,
|
||||
requestCount: 1,
|
||||
errorResponseMessage: 'Too many requests',
|
||||
includeInternalRequests: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await Parse.User.signUp('testuser', 'password');
|
||||
// First login (POST) consumes the single allowed request across all methods.
|
||||
const res1 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ username: 'testuser', password: 'password' }),
|
||||
});
|
||||
expect(res1.data.username).toBe('testuser');
|
||||
// A subsequent GET login (sent via _method override) is still rate limited.
|
||||
const res2 = await request({
|
||||
method: 'POST',
|
||||
headers,
|
||||
url: 'http://localhost:8378/1/login',
|
||||
body: JSON.stringify({ _method: 'GET', username: 'testuser', password: 'password' }),
|
||||
}).catch(e => e);
|
||||
expect(res2.data).toEqual({
|
||||
code: Parse.Error.CONNECTION_FAILED,
|
||||
error: 'Too many requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow _method override with PUT', async () => {
|
||||
await reconfigureServer({
|
||||
rateLimit: [
|
||||
|
||||
@@ -444,6 +444,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;
|
||||
|
||||
|
||||
@@ -57,6 +57,56 @@ describe_only_db('mongo')('revocable sessions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should upgrade a legacy session token via a trailing-slash path variant', async () => {
|
||||
// `/upgradeToRevocableSession/` routes to the same handler as `/upgradeToRevocableSession`,
|
||||
// so the legacy-token branch must recognize it; otherwise the legacy token is sent to the
|
||||
// revocable-session lookup and the upgrade fails.
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/upgradeToRevocableSession/',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Rest-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(400);
|
||||
expect(response.data.sessionToken).toBeDefined();
|
||||
expect(response.data.sessionToken.indexOf('r:')).toBe(0);
|
||||
});
|
||||
|
||||
it('should upgrade a legacy session token when the request includes a query string', async () => {
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/upgradeToRevocableSession?foo=bar',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Rest-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(400);
|
||||
expect(response.data.sessionToken).toBeDefined();
|
||||
expect(response.data.sessionToken.indexOf('r:')).toBe(0);
|
||||
});
|
||||
|
||||
it('should upgrade a legacy session token via a differently-cased path', async () => {
|
||||
// handleParseSession matches the route case-insensitively (matchesExactRoute), mirroring
|
||||
// Express routing, so a differently-cased path still takes the legacy-token branch.
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/UpgradeToRevocableSession',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Rest-API-Key': 'rest',
|
||||
'X-Parse-Session-Token': sessionToken,
|
||||
},
|
||||
}).catch(e => e);
|
||||
expect(response.status).not.toBe(400);
|
||||
expect(response.data.sessionToken).toBeDefined();
|
||||
expect(response.data.sessionToken.indexOf('r:')).toBe(0);
|
||||
});
|
||||
|
||||
it('should be able to become with revocable session token', done => {
|
||||
const user = Parse.Object.fromJSON({
|
||||
className: '_User',
|
||||
|
||||
+8
-2
@@ -657,12 +657,18 @@ global.fdescribe_only = validator => {
|
||||
|
||||
const libraryCache = {};
|
||||
jasmine.mockLibrary = function (library, name, mock) {
|
||||
const original = require(library)[name];
|
||||
if (!libraryCache[library]) {
|
||||
libraryCache[library] = {};
|
||||
}
|
||||
// Cache the original implementation only the first time an export is mocked.
|
||||
// Re-mocking the same export (e.g. swapping the mock mid-test) must not
|
||||
// overwrite the cached original with another mock, otherwise restoreLibrary
|
||||
// would restore a mock instead of the real implementation and leak it into
|
||||
// later specs.
|
||||
if (!(name in libraryCache[library])) {
|
||||
libraryCache[library][name] = require(library)[name];
|
||||
}
|
||||
require(library)[name] = mock;
|
||||
libraryCache[library][name] = original;
|
||||
};
|
||||
|
||||
jasmine.restoreLibrary = function (library, name) {
|
||||
|
||||
@@ -2307,6 +2307,207 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-wmwx-jr2p-4j4r) $relatedTo bypasses protectedFields and parent ACL for Relation fields', () => {
|
||||
let childLinked;
|
||||
let parentProtectedKey;
|
||||
let parentPrivate;
|
||||
let parentPublic;
|
||||
|
||||
const relatedToWhere = (parentId, key, extra = {}) => ({
|
||||
$relatedTo: {
|
||||
object: { __type: 'Pointer', className: 'RelParent', objectId: parentId },
|
||||
key,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const queryChild = (where, headers = {}) =>
|
||||
request({
|
||||
method: 'GET',
|
||||
url: `${Parse.serverURL}/classes/RelChild`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
...headers,
|
||||
},
|
||||
qs: { where: JSON.stringify(where) },
|
||||
}).catch(e => e);
|
||||
|
||||
beforeEach(async () => {
|
||||
const schema = new Parse.Schema('RelParent');
|
||||
schema.addString('name');
|
||||
schema.addRelation('secretRel', 'RelChild');
|
||||
schema.addRelation('openRel', 'RelChild');
|
||||
schema.setCLP({
|
||||
find: { '*': true },
|
||||
get: { '*': true },
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: { '*': true },
|
||||
addField: {},
|
||||
// secretRel is a protected Relation field for public clients
|
||||
protectedFields: { '*': ['secretRel'] },
|
||||
});
|
||||
await schema.save();
|
||||
|
||||
childLinked = new Parse.Object('RelChild', { value: 'linked child' });
|
||||
await childLinked.save(null, { useMasterKey: true });
|
||||
|
||||
const publicAcl = new Parse.ACL();
|
||||
publicAcl.setPublicReadAccess(true);
|
||||
|
||||
const privateAcl = new Parse.ACL();
|
||||
privateAcl.setPublicReadAccess(false);
|
||||
privateAcl.setPublicWriteAccess(false);
|
||||
|
||||
// Publicly readable parent whose relation key is protected (isolates the
|
||||
// protectedFields facet).
|
||||
parentProtectedKey = new Parse.Object('RelParent', { name: 'protected-key parent' });
|
||||
parentProtectedKey.setACL(publicAcl);
|
||||
parentProtectedKey.relation('secretRel').add(childLinked);
|
||||
await parentProtectedKey.save(null, { useMasterKey: true });
|
||||
|
||||
// Parent that is not readable by the public, queried via a non-protected
|
||||
// relation key (isolates the parent-ACL facet).
|
||||
parentPrivate = new Parse.Object('RelParent', { name: 'private parent' });
|
||||
parentPrivate.setACL(privateAcl);
|
||||
parentPrivate.relation('openRel').add(childLinked);
|
||||
await parentPrivate.save(null, { useMasterKey: true });
|
||||
|
||||
// Publicly readable parent with a non-protected relation key (legitimate
|
||||
// use that must keep working).
|
||||
parentPublic = new Parse.Object('RelParent', { name: 'public parent' });
|
||||
parentPublic.setACL(publicAcl);
|
||||
parentPublic.relation('openRel').add(childLinked);
|
||||
await parentPublic.save(null, { useMasterKey: true });
|
||||
});
|
||||
|
||||
it('denies $relatedTo query that references a protected relation field', async () => {
|
||||
const res = await queryChild(relatedToWhere(parentProtectedKey.id, 'secretRel'));
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('denies $relatedTo on a protected relation field nested in $or', async () => {
|
||||
const res = await queryChild({
|
||||
$or: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
|
||||
});
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('denies $relatedTo on a protected relation field nested in $and', async () => {
|
||||
const res = await queryChild({
|
||||
$and: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
|
||||
});
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('denies $relatedTo on a protected relation field nested in $nor', async () => {
|
||||
const res = await queryChild({
|
||||
$nor: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
|
||||
});
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('returns no results when the owning object is not readable by the caller', async () => {
|
||||
const res = await queryChild(relatedToWhere(parentPrivate.id, 'openRel'));
|
||||
expect(res.data.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not act as a membership oracle for an unreadable owning object', async () => {
|
||||
const res = await queryChild(
|
||||
relatedToWhere(parentPrivate.id, 'openRel', { objectId: childLinked.id })
|
||||
);
|
||||
expect(res.data.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('still returns related objects for a readable parent and non-protected key', async () => {
|
||||
const res = await queryChild(relatedToWhere(parentPublic.id, 'openRel'));
|
||||
expect(res.data.results.length).toBe(1);
|
||||
expect(res.data.results[0].objectId).toBe(childLinked.id);
|
||||
});
|
||||
|
||||
it('allows master key to query a protected relation and an unreadable parent', async () => {
|
||||
const masterHeaders = { 'X-Parse-Master-Key': Parse.masterKey };
|
||||
const resProtected = await queryChild(
|
||||
relatedToWhere(parentProtectedKey.id, 'secretRel'),
|
||||
masterHeaders
|
||||
);
|
||||
expect(resProtected.data.results.length).toBe(1);
|
||||
const resPrivate = await queryChild(
|
||||
relatedToWhere(parentPrivate.id, 'openRel'),
|
||||
masterHeaders
|
||||
);
|
||||
expect(resPrivate.data.results.length).toBe(1);
|
||||
});
|
||||
|
||||
it('respects user-level read access to the owning object', async () => {
|
||||
const userA = await Parse.User.signUp('relUserA', 'pw');
|
||||
const userB = await Parse.User.signUp('relUserB', 'pw');
|
||||
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(userA, true);
|
||||
const parent = new Parse.Object('RelParent', { name: 'user-scoped parent' });
|
||||
parent.setACL(acl);
|
||||
parent.relation('openRel').add(childLinked);
|
||||
await parent.save(null, { useMasterKey: true });
|
||||
|
||||
const resA = await queryChild(relatedToWhere(parent.id, 'openRel'), {
|
||||
'X-Parse-Session-Token': userA.getSessionToken(),
|
||||
});
|
||||
expect(resA.data.results.length).toBe(1);
|
||||
|
||||
const resB = await queryChild(relatedToWhere(parent.id, 'openRel'), {
|
||||
'X-Parse-Session-Token': userB.getSessionToken(),
|
||||
});
|
||||
expect(resB.data.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no results when the owning class denies get permission (CLP)', async () => {
|
||||
// Owning class denies public `get`, so the owning-object read throws
|
||||
// OPERATION_FORBIDDEN; the relation must then return no results.
|
||||
const schema = new Parse.Schema('RelParentNoGet');
|
||||
schema.addRelation('members', 'RelChild');
|
||||
schema.setCLP({
|
||||
find: { '*': true },
|
||||
get: {},
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: { '*': true },
|
||||
addField: {},
|
||||
});
|
||||
await schema.save();
|
||||
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(true);
|
||||
const parent = new Parse.Object('RelParentNoGet', { name: 'no-get parent' });
|
||||
parent.setACL(acl);
|
||||
parent.relation('members').add(childLinked);
|
||||
await parent.save(null, { useMasterKey: true });
|
||||
|
||||
const res = await request({
|
||||
method: 'GET',
|
||||
url: `${Parse.serverURL}/classes/RelChild`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
qs: {
|
||||
where: JSON.stringify({
|
||||
$relatedTo: {
|
||||
object: { __type: 'Pointer', className: 'RelParentNoGet', objectId: parent.id },
|
||||
key: 'members',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}).catch(e => e);
|
||||
expect(res.data.results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => {
|
||||
let obj;
|
||||
|
||||
|
||||
@@ -1144,38 +1144,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;
|
||||
@@ -1341,7 +1472,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;
|
||||
|
||||
@@ -113,4 +113,9 @@ module.exports = [
|
||||
changeNewDefault: 'true',
|
||||
solution: "Set 'installation.duplicateDeviceTokenActionEnforceAuth' to 'true' to enforce the caller's auth context (and the resulting ACL and CLP) when Parse Server deduplicates _Installation records sharing the same deviceToken. Set to 'false' to keep the current behavior of bypassing permissions on the dedup operation.",
|
||||
},
|
||||
{
|
||||
optionKey: 'allowAggregationForReadOnlyMasterKey',
|
||||
changeNewDefault: 'false',
|
||||
solution: "Set 'allowAggregationForReadOnlyMasterKey' to 'false' to prevent the read-only master key from running aggregation pipelines, which can include write-capable stages (e.g. '$out', '$merge'). Set to 'true' to keep the current behavior where the read-only master key can run aggregation pipelines.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -393,6 +393,35 @@ 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,
|
||||
@@ -523,12 +552,14 @@ class ParseLiveQueryServer {
|
||||
|
||||
// If there is no client which is subscribing this subscription, remove it from subscriptions
|
||||
const classSubscriptions = this.subscriptions.get(subscription.className);
|
||||
if (!subscription.hasSubscribingClient()) {
|
||||
classSubscriptions.delete(subscription.hash);
|
||||
}
|
||||
// If there is no subscriptions under this class, remove it from subscriptions
|
||||
if (classSubscriptions.size === 0) {
|
||||
this.subscriptions.delete(subscription.className);
|
||||
if (classSubscriptions) {
|
||||
if (!subscription.hasSubscribingClient()) {
|
||||
classSubscriptions.delete(subscription.hash);
|
||||
}
|
||||
// If there is no subscriptions under this class, remove it from subscriptions
|
||||
if (classSubscriptions.size === 0) {
|
||||
this.subscriptions.delete(subscription.className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,25 +1066,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 (where[op] !== undefined && !Array.isArray(where[op])) {
|
||||
throw new Parse.Error(Parse.Error.INVALID_QUERY, `${op} must be an array`);
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
checkDepth(item, depth);
|
||||
}
|
||||
if (Array.isArray(where[op])) {
|
||||
for (const subQuery of where[op]) {
|
||||
checkDepth(subQuery, depth + 1);
|
||||
}
|
||||
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);
|
||||
@@ -1164,6 +1202,28 @@ class ParseLiveQueryServer {
|
||||
// Validate regex patterns in the subscription query
|
||||
this._validateQueryConstraints(request.query.where);
|
||||
|
||||
// If this client already has a subscription registered under this
|
||||
// requestId, replace it by tearing down the previous subscription before
|
||||
// creating the new one. The client-side metadata map is keyed only by
|
||||
// requestId, so a duplicate `subscribe` frame would otherwise overwrite it
|
||||
// while the previous Subscription stays in the server-wide map, leaking it
|
||||
// for the lifetime of the process (disconnect cleanup only walks the
|
||||
// surviving client metadata and never reaches the orphaned subscription).
|
||||
const previousSubscriptionInfo = client.getSubscriptionInfo(request.requestId);
|
||||
if (previousSubscriptionInfo) {
|
||||
const previousSubscription = previousSubscriptionInfo.subscription;
|
||||
previousSubscription.deleteClientSubscription(parseWebsocket.clientId, request.requestId);
|
||||
const previousClassSubscriptions = this.subscriptions.get(previousSubscription.className);
|
||||
if (previousClassSubscriptions) {
|
||||
if (!previousSubscription.hasSubscribingClient()) {
|
||||
previousClassSubscriptions.delete(previousSubscription.hash);
|
||||
}
|
||||
if (previousClassSubscriptions.size === 0) {
|
||||
this.subscriptions.delete(previousSubscription.className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription from subscriptions, create one if necessary
|
||||
const subscriptionHash = queryHash(request.query);
|
||||
// Add className to subscriptions if necessary
|
||||
@@ -1286,12 +1346,14 @@ class ParseLiveQueryServer {
|
||||
subscription.deleteClientSubscription(parseWebsocket.clientId, requestId);
|
||||
// If there is no client which is subscribing this subscription, remove it from subscriptions
|
||||
const classSubscriptions = this.subscriptions.get(className);
|
||||
if (!subscription.hasSubscribingClient()) {
|
||||
classSubscriptions.delete(subscription.hash);
|
||||
}
|
||||
// If there is no subscriptions under this class, remove it from subscriptions
|
||||
if (classSubscriptions.size === 0) {
|
||||
this.subscriptions.delete(className);
|
||||
if (classSubscriptions) {
|
||||
if (!subscription.hasSubscribingClient()) {
|
||||
classSubscriptions.delete(subscription.hash);
|
||||
}
|
||||
// If there is no subscriptions under this class, remove it from subscriptions
|
||||
if (classSubscriptions.size === 0) {
|
||||
this.subscriptions.delete(className);
|
||||
}
|
||||
}
|
||||
runLiveQueryEventHandlers({
|
||||
client,
|
||||
|
||||
@@ -22,7 +22,11 @@ class Subscription {
|
||||
this.clientRequestIds.set(clientId, []);
|
||||
}
|
||||
const requestIds = this.clientRequestIds.get(clientId);
|
||||
requestIds.push(requestId);
|
||||
// Keep (clientId, requestId) pairs unique so a duplicate registration cannot
|
||||
// leave a residual entry that survives cleanup.
|
||||
if (!requestIds.includes(requestId)) {
|
||||
requestIds.push(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
deleteClientSubscription(clientId: number, requestId: number): void {
|
||||
|
||||
@@ -58,6 +58,12 @@ module.exports.ParseServerOptions = {
|
||||
action: parsers.objectParser,
|
||||
type: 'AccountLockoutOptions',
|
||||
},
|
||||
allowAggregationForReadOnlyMasterKey: {
|
||||
env: 'PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY',
|
||||
help: 'Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.',
|
||||
action: parsers.booleanParser,
|
||||
default: true,
|
||||
},
|
||||
allowClientClassCreation: {
|
||||
env: 'PARSE_SERVER_ALLOW_CLIENT_CLASS_CREATION',
|
||||
help: 'Enable (or disable) client class creation, defaults to false',
|
||||
@@ -528,14 +534,14 @@ module.exports.ParseServerOptions = {
|
||||
},
|
||||
rateLimit: {
|
||||
env: 'PARSE_SERVER_RATE_LIMIT',
|
||||
help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>\u2139\uFE0F Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.",
|
||||
help: "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>\u2139\uFE0F Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.<br>- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits.",
|
||||
action: parsers.arrayParser,
|
||||
type: 'RateLimitOptions[]',
|
||||
default: [],
|
||||
},
|
||||
readOnlyMasterKey: {
|
||||
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
|
||||
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
|
||||
help: 'The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.<br><br>It is intended strictly for internal, server-side use \u2014 for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used.',
|
||||
},
|
||||
readOnlyMasterKeyIps: {
|
||||
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY_IPS',
|
||||
@@ -698,7 +704,7 @@ module.exports.RateLimitOptions = {
|
||||
},
|
||||
requestMethods: {
|
||||
env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_METHODS',
|
||||
help: 'Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.',
|
||||
help: "Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods.",
|
||||
action: parsers.arrayParser,
|
||||
},
|
||||
requestPath: {
|
||||
|
||||
+4
-3
@@ -13,6 +13,7 @@
|
||||
/**
|
||||
* @interface ParseServerOptions
|
||||
* @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts.<br><br>Note: Setting a user's ACL to an empty object `{}` via master key is a separate mechanism that only prevents new logins; it does not invalidate existing session tokens. To immediately revoke a user's access, destroy their sessions via master key in addition to setting the ACL.
|
||||
* @property {Boolean} allowAggregationForReadOnlyMasterKey Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.
|
||||
* @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to false
|
||||
* @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId
|
||||
* @property {Boolean} allowExpiredAuthDataToken Deprecated. This option will be removed in a future version. Auth providers are always validated on login. On update, if this is set to `true`, auth providers are only re-validated when the auth data has changed. If this is set to `false`, auth providers are re-validated on every update. Defaults to `false`.
|
||||
@@ -97,8 +98,8 @@
|
||||
* @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
|
||||
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
|
||||
* @property {QueryServerOptions} query Query-related server defaults.
|
||||
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
|
||||
* @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes
|
||||
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.<br>- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits.
|
||||
* @property {String} readOnlyMasterKey The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.<br><br>It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used.
|
||||
* @property {String[]} readOnlyMasterKeyIps (Optional) Restricts the use of read-only master key permissions to a list of IP addresses or ranges.<br><br>This option accepts a list of single IP addresses, for example `['10.0.0.1', '10.0.0.2']`. You can also use CIDR notation to specify an IP address range, for example `['10.0.1.0/24']`.<br><br><b>Special scenarios:</b><br>- Setting an empty array `[]` means that the read-only master key cannot be used even in Parse Server Cloud Code. This value cannot be set via an environment variable as there is no way to pass an empty array to Parse Server via an environment variable.<br>- Setting `['0.0.0.0/0', '::0']` means to allow any IPv4 and IPv6 address to use the read-only master key and effectively disables the IP filter.<br><br><b>Considerations:</b><br>- IPv4 and IPv6 addresses are not compared against each other. Each IP version (IPv4 and IPv6) needs to be considered separately. For example, `['0.0.0.0/0']` allows any IPv4 address and blocks every IPv6 address. Conversely, `['::0']` allows any IPv6 address and blocks every IPv4 address.<br>- Keep in mind that the IP version in use depends on the network stack of the environment in which Parse Server runs. A local environment may use a different IP version than a remote environment. For example, it's possible that locally the value `['0.0.0.0/0']` allows the request IP because the environment is using IPv4, but when Parse Server is deployed remotely the request IP is blocked because the remote environment is using IPv6.<br>- When setting the option via an environment variable the notation is a comma-separated string, for example `"0.0.0.0/0,::0"`.<br>- IPv6 zone indices (`%` suffix) are not supported, for example `fe80::1%eth0`, `fe80::1%1` or `::1%lo`.<br><br>Defaults to `['0.0.0.0/0', '::0']` which means that any IP address is allowed to use the read-only master key. It is recommended to set this option to `['127.0.0.1', '::1']` to restrict access to `localhost`.
|
||||
* @property {RequestComplexityOptions} requestComplexity Options to limit the complexity of requests to prevent denial-of-service attacks. Limits are enforced for all requests except those using the master or maintenance key. Each property can be set to `-1` to disable that specific limit.
|
||||
* @property {Function} requestContextMiddleware Options to customize the request context using inversion of control/dependency injection.
|
||||
@@ -130,7 +131,7 @@
|
||||
* @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.
|
||||
* @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests.
|
||||
* @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.
|
||||
* @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.
|
||||
* @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods.
|
||||
* @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following <a href="https://github.com/pillarjs/path-to-regexp">path-to-regexp v8</a> syntax.
|
||||
* @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.
|
||||
* @property {String} zone The type of rate limit to apply. The following types are supported:<ul><li>`global`: rate limit based on the number of requests made by all users</li><li>`ip`: rate limit based on the IP address of the request</li><li>`user`: rate limit based on the user ID of the request</li><li>`session`: rate limit based on the session token of the request</li></ul>Default is `ip`.
|
||||
|
||||
@@ -158,8 +158,12 @@ export interface ParseServerOptions {
|
||||
/* Key for REST calls
|
||||
:ENV: PARSE_SERVER_REST_API_KEY */
|
||||
restAPIKey: ?string;
|
||||
/* Read-only key, which has the same capabilities as MasterKey without writes */
|
||||
/* The read-only master key is a secret key with the same read capabilities as the `masterKey`, but without the ability to perform writes. Like the `masterKey`, it bypasses all security mechanisms (Class Level Permissions, object ACLs, `protectedFields`), so it grants full read access to all data.<br><br>It is intended strictly for internal, server-side use — for example to give a trusted internal process read access while guarding against accidental writes during development or operations. It is not a credential for untrusted contexts: it must never be shipped, distributed, published, embedded in a client application, or otherwise exposed to untrusted parties, because anyone who obtains it can read all data in the database. Use `readOnlyMasterKeyIps` to restrict the IP addresses from which it may be used. */
|
||||
readOnlyMasterKey: ?string;
|
||||
/* Whether the `readOnlyMasterKey` is allowed to run aggregation pipelines via the aggregate endpoint. An aggregation pipeline can contain write-capable stages (for example MongoDB `$out` and `$merge`), so allowing aggregation effectively gives the read-only master key a way to perform writes, contrary to its read-only intent. If `true` (default), the read-only master key can run aggregation pipelines. If `false`, the read-only master key cannot run aggregation pipelines at all. Note that the `readOnlyMasterKey` is a secret key for internal server-side use only and must never be distributed; this option is an additional safeguard, not a substitute for keeping the key confidential. Defaults to `true`.
|
||||
:ENV: PARSE_SERVER_ALLOW_AGGREGATION_FOR_READ_ONLY_MASTER_KEY
|
||||
:DEFAULT: true */
|
||||
allowAggregationForReadOnlyMasterKey: ?boolean;
|
||||
/* Key sent with outgoing webhook calls */
|
||||
webhookKey: ?string;
|
||||
/* Key for your files */
|
||||
@@ -411,7 +415,7 @@ export interface ParseServerOptions {
|
||||
/* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns.
|
||||
:DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */
|
||||
requestKeywordDenylist: ?(RequestKeywordDenylist[]);
|
||||
/* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
|
||||
/* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.<br>- rate limits are matched against the REST API URL path (`requestPath`) and therefore apply to REST API routes only; they do not apply to GraphQL operations, which are all served under the single GraphQL endpoint path (`graphQLPath`, default `/graphql`) and are identified by the request payload rather than the URL. To rate limit GraphQL, either set a `requestPath` for the GraphQL endpoint path to throttle the entire GraphQL API, or use a GraphQL-aware rate limiting solution (for example a schema-directive-based rate limiter) for per-operation limits.
|
||||
:DEFAULT: [] */
|
||||
rateLimit: ?(RateLimitOptions[]);
|
||||
/* Options to customize the request context using inversion of control/dependency injection.*/
|
||||
@@ -431,7 +435,7 @@ export interface RateLimitOptions {
|
||||
/* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`.
|
||||
:DEFAULT: Too many requests. */
|
||||
errorResponseMessage: ?string;
|
||||
/* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. */
|
||||
/* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. The method is matched after any `_method` body override has been resolved, i.e. it is the method used to route the request. Note that some endpoints are reachable via more than one HTTP method (for example `/login` and `/verifyPassword` are available via both `GET` and `POST`); to rate limit such an endpoint reliably, include all relevant methods (e.g. `['GET', 'POST']`) or omit this option to apply the limit to all methods. */
|
||||
requestMethods: ?(string[]);
|
||||
/* Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.
|
||||
:DEFAULT: false */
|
||||
|
||||
+18
-7
@@ -359,22 +359,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);
|
||||
@@ -1361,6 +1368,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;
|
||||
|
||||
@@ -6,6 +6,12 @@ import UsersRouter from './UsersRouter';
|
||||
|
||||
export class AggregateRouter extends ClassesRouter {
|
||||
async handleFind(req) {
|
||||
if (req.auth && req.auth.isReadOnly && req.config && !req.config.allowAggregationForReadOnlyMasterKey) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
'Cannot run an aggregation pipeline when using the readOnlyMasterKey'
|
||||
);
|
||||
}
|
||||
const body = Object.assign(req.body || {}, ClassesRouter.JSONFromQuery(req.query));
|
||||
const options = {};
|
||||
if (body.distinct) {
|
||||
|
||||
+59
-17
@@ -412,6 +412,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 === '*') {
|
||||
@@ -423,30 +424,71 @@ export class FilesRouter {
|
||||
}
|
||||
});
|
||||
};
|
||||
let extension = Utils.getFileExtension(filename);
|
||||
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
|
||||
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
|
||||
// If the filename has no usable extension (no dot, trailing dot, or
|
||||
// whitespace-only suffix), fall back to the Content-Type subtype — same
|
||||
// as a dotless filename.
|
||||
if (!extension && contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
// Last resort for malformed inputs (e.g. Content-Type without a slash):
|
||||
// use the raw Content-Type so the existing rejection path still fires.
|
||||
if (!extension && contentType) {
|
||||
extension = contentType.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For streaming uploads, read file data from headers since the body is the raw stream
|
||||
|
||||
@@ -201,6 +201,16 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const maxBytes = Utils.parseSizeToBytes(req.config.maxUploadSize);
|
||||
// Reject early when the declared request size already exceeds the limit.
|
||||
const contentLength = Number(req.headers['content-length']);
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
return Promise.reject(
|
||||
new Parse.Error(
|
||||
Parse.Error.OBJECT_TOO_LARGE,
|
||||
'Multipart request exceeds maximum upload size.'
|
||||
)
|
||||
);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const fields = Object.create(null);
|
||||
let totalBytes = 0;
|
||||
@@ -213,11 +223,12 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`)
|
||||
);
|
||||
}
|
||||
const safeReject = (err) => {
|
||||
const safeReject = err => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
req.unpipe(busboy);
|
||||
busboy.destroy();
|
||||
reject(err);
|
||||
};
|
||||
@@ -280,6 +291,23 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
new Parse.Error(Parse.Error.INVALID_JSON, `Invalid multipart request: ${err.message}`)
|
||||
);
|
||||
});
|
||||
// Enforce `maxUploadSize` against the raw request bytes (multipart
|
||||
// boundaries, part headers, field names and part count included), not only
|
||||
// the parsed field values and file contents. This mirrors how
|
||||
// `express.json` bounds non-multipart bodies and stops a request composed
|
||||
// of many empty parts from exceeding the limit on the wire.
|
||||
let rawBytes = 0;
|
||||
req.on('data', chunk => {
|
||||
rawBytes += chunk.length;
|
||||
if (rawBytes > maxBytes) {
|
||||
safeReject(
|
||||
new Parse.Error(
|
||||
Parse.Error.OBJECT_TOO_LARGE,
|
||||
'Multipart request exceeds maximum upload size.'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
req.pipe(busboy);
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
const Parse = require('parse/node').Parse;
|
||||
const path = require('path');
|
||||
const { isRouteAllowed } = require('./middlewares');
|
||||
const { isRouteAllowed, matchesExactRoute } = require('./middlewares');
|
||||
const { createSanitizedError } = require('./Error');
|
||||
// These methods handle batch requests.
|
||||
const batchPath = '/batch';
|
||||
@@ -123,7 +123,7 @@ async function handleBatch(router, req) {
|
||||
continue;
|
||||
}
|
||||
const info = { ...req.info };
|
||||
if (routablePath === '/login') {
|
||||
if (matchesExactRoute(routablePath, '/login')) {
|
||||
delete info.sessionToken;
|
||||
}
|
||||
const fakeReq = {
|
||||
|
||||
+28
-4
@@ -264,7 +264,7 @@ export async function handleParseHeaders(req, res, next) {
|
||||
return invalidRequest(req, res);
|
||||
}
|
||||
|
||||
if (req.url == '/login') {
|
||||
if (matchesExactRoute(req.path, '/login')) {
|
||||
delete info.sessionToken;
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ const handleRateLimit = async (req, res, next) => {
|
||||
await Promise.all(
|
||||
rateLimits.map(async limit => {
|
||||
const pathExp = limit.path.regexp || limit.path;
|
||||
if (pathExp.test(req.url)) {
|
||||
if (pathExp.test(req.path)) {
|
||||
await limit.handler(req, res, err => {
|
||||
if (err) {
|
||||
if (err.code === Parse.Error.CONNECTION_FAILED) {
|
||||
@@ -320,14 +320,14 @@ const handleRateLimit = async (req, res, next) => {
|
||||
export const handleParseSession = async (req, res, next) => {
|
||||
try {
|
||||
const info = req.info;
|
||||
if (req.auth || (req.url === '/sessions/me' && req.method === 'GET')) {
|
||||
if (req.auth || (matchesExactRoute(req.path, '/sessions/me') && req.method === 'GET')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
let requestAuth = null;
|
||||
if (
|
||||
info.sessionToken &&
|
||||
req.url === '/upgradeToRevocableSession' &&
|
||||
matchesExactRoute(req.path, '/upgradeToRevocableSession') &&
|
||||
info.sessionToken.indexOf('r:') != 0
|
||||
) {
|
||||
requestAuth = await auth.getAuthForLegacySessionToken({
|
||||
@@ -540,6 +540,30 @@ function normalizeRouteAllowListPath(path, mount) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Cache of compiled exact-route matchers, keyed by route. Mirrors how `addRateLimit` compiles a
|
||||
// route's `pathToRegexp` once and reuses it, avoiding recompilation on every request.
|
||||
const exactRouteRegexpCache = Object.create(null);
|
||||
|
||||
/**
|
||||
* Returns true if `path` resolves to the given exact static `route`, using the same
|
||||
* `path-to-regexp` matching that the Express router and the rate limiter use (case-insensitive
|
||||
* and trailing-slash-tolerant by default). Path-literal checks — such as detecting `/login` to
|
||||
* drop the inbound session token — must use this so they stay consistent with how the router
|
||||
* actually dispatches the request, instead of re-deriving the matching rules by hand.
|
||||
* @param {string} path The request path (e.g. `req.path` or a batch sub-request routable path).
|
||||
* @param {string} route The exact static route to match (e.g. `/login`).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function matchesExactRoute(path, route) {
|
||||
if (typeof path !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (!exactRouteRegexpCache[route]) {
|
||||
exactRouteRegexpCache[route] = pathToRegexp(route).regexp;
|
||||
}
|
||||
return exactRouteRegexpCache[route].test(path);
|
||||
}
|
||||
|
||||
export function isRouteAllowed(path, config, auth) {
|
||||
if (!config || config.routeAllowList === undefined || config.routeAllowList === null) {
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user