mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f376abb60e | ||
|
|
5c2d60a2f3 | ||
|
|
8c8eb46022 | ||
|
|
2b52feb064 | ||
|
|
aa7c57e917 | ||
|
|
42bd2f07bd | ||
|
|
72af528d53 | ||
|
|
1da3123118 | ||
|
|
79bd170d53 | ||
|
|
a7358b1e0c | ||
|
|
cf030af7f5 | ||
|
|
d5a057d1a7 | ||
|
|
2c43ddb74e | ||
|
|
07870f59ee | ||
|
|
0994d6e9b8 | ||
|
|
0c940b7089 | ||
|
|
bfe11c4917 | ||
|
|
07bddc0850 | ||
|
|
d39387f44d | ||
|
|
6c79da91fc | ||
|
|
9f28ff5663 | ||
|
|
9b94083acc | ||
|
|
035b9b549e | ||
|
|
155c6ad92d | ||
|
|
0b032a3f87 | ||
|
|
12d8b502a2 | ||
|
|
9896817df6 |
+2
-3
@@ -34,7 +34,7 @@ async function config() {
|
||||
console.log(`Running on branch: ${branch}`);
|
||||
|
||||
// Set changelog file
|
||||
const changelogFile = `./changelogs/CHANGELOG_${branch}.md`;
|
||||
const changelogFile = `./changelogs/CHANGELOG_release.md`;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Changelog file output to: ${changelogFile}`);
|
||||
|
||||
@@ -46,9 +46,8 @@ async function config() {
|
||||
'release',
|
||||
{ name: 'alpha', prerelease: true },
|
||||
// { name: 'beta', prerelease: true },
|
||||
'next-major',
|
||||
// Long-Term-Support branch
|
||||
'release-8.x.x',
|
||||
{ name: 'release-8.x.x', range: '8.x.x', channel: '8.x.x' },
|
||||
],
|
||||
dryRun: false,
|
||||
debug: true,
|
||||
|
||||
@@ -525,6 +525,38 @@ async function benchmarkQueryWithIncludeNested(name) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: Object.save with nested data (denylist scanning)
|
||||
*
|
||||
* Measures create latency for objects with deeply nested structures containing
|
||||
* multiple sibling objects at each level. This exercises the requestKeywordDenylist
|
||||
* scanner (objectContainsKeyValue) which must traverse all keys and nested values.
|
||||
*/
|
||||
async function benchmarkObjectCreateNestedDenylist(name) {
|
||||
let counter = 0;
|
||||
|
||||
return measureOperation({
|
||||
name,
|
||||
iterations: 1_000,
|
||||
operation: async () => {
|
||||
const TestObject = Parse.Object.extend('BenchmarkDenylist');
|
||||
const obj = new TestObject();
|
||||
const idx = counter++;
|
||||
obj.set('nested', {
|
||||
meta1: { info: { detail: `value-${idx}` } },
|
||||
meta2: { info: { detail: `value-${idx}` } },
|
||||
meta3: { info: { detail: `value-${idx}` } },
|
||||
tags: ['a', 'b', 'c'],
|
||||
config: {
|
||||
setting1: { enabled: true, params: { x: 1 } },
|
||||
setting2: { enabled: false, params: { y: 2 } },
|
||||
},
|
||||
});
|
||||
await obj.save();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all benchmarks
|
||||
*/
|
||||
@@ -554,6 +586,7 @@ async function runBenchmarks() {
|
||||
{ name: 'User.login', fn: benchmarkUserLogin },
|
||||
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
|
||||
{ name: 'Query.include (nested pointers)', fn: benchmarkQueryWithIncludeNested },
|
||||
{ name: 'Object.save (nested data, denylist scan)', fn: benchmarkObjectCreateNestedDenylist },
|
||||
];
|
||||
|
||||
// Run each benchmark with database cleanup
|
||||
|
||||
@@ -1,3 +1,94 @@
|
||||
## [8.6.13](https://github.com/parse-community/parse-server/compare/8.6.12...8.6.13) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denial of Service (DoS) and Cloud Function Dispatch Bypass via Prototype Chain Resolution ([GHSA-5j86-7r7m-p8h6](https://github.com/parse-community/parse-server/security/advisories/GHSA-5j86-7r7m-p8h6)) ([#10124](https://github.com/parse-community/parse-server/issues/10124)) ([5c2d60a](https://github.com/parse-community/parse-server/commit/5c2d60a2f3733ca3a4cb782d552ba38c526aee0b))
|
||||
|
||||
## [8.6.12](https://github.com/parse-community/parse-server/compare/8.6.11...8.6.12) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Denylist `requestKeywordDenylist` keyword scan bypass through nested object placement ([GHSA-q342-9w2p-57fp](https://github.com/parse-community/parse-server/security/advisories/GHSA-q342-9w2p-57fp)) ([#10122](https://github.com/parse-community/parse-server/issues/10122)) ([2b52feb](https://github.com/parse-community/parse-server/commit/2b52feb06448e5c683017fa2e3d2038a5d8d6085))
|
||||
|
||||
## [8.6.11](https://github.com/parse-community/parse-server/compare/8.6.10...8.6.11) (2026-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Regular Expression Denial of Service (ReDoS) via `$regex` query in LiveQuery ([GHSA-mf3j-86qx-cq5j](https://github.com/parse-community/parse-server/security/advisories/GHSA-mf3j-86qx-cq5j)) ([#10120](https://github.com/parse-community/parse-server/issues/10120)) ([42bd2f0](https://github.com/parse-community/parse-server/commit/42bd2f07bd3b425cac1e3c48161688cc4d54ef41))
|
||||
|
||||
## [8.6.10](https://github.com/parse-community/parse-server/compare/8.6.9...8.6.10) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* JWT audience validation bypass in Google, Apple, and Facebook authentication adapters ([GHSA-x6fw-778m-wr9v](https://github.com/parse-community/parse-server/security/advisories/GHSA-x6fw-778m-wr9v)) ([#10114](https://github.com/parse-community/parse-server/issues/10114)) ([1da3123](https://github.com/parse-community/parse-server/commit/1da312311827a7790ad97852e8672119d40d529a))
|
||||
|
||||
## [8.6.9](https://github.com/parse-community/parse-server/compare/8.6.8...8.6.9) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* File metadata endpoint bypasses `beforeFind` / `afterFind` trigger authorization ([GHSA-hwx8-q9cg-mqmc](https://github.com/parse-community/parse-server/security/advisories/GHSA-hwx8-q9cg-mqmc)) ([#10107](https://github.com/parse-community/parse-server/issues/10107)) ([a7358b1](https://github.com/parse-community/parse-server/commit/a7358b1e0c58ef4c6b5e0ade772bf673b5f78fd0))
|
||||
|
||||
## [8.6.8](https://github.com/parse-community/parse-server/compare/8.6.7...8.6.8) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `PagesRouter` path traversal allows reading files outside configured pages directory ([GHSA-hm3f-q6rw-m6wh](https://github.com/parse-community/parse-server/security/advisories/GHSA-hm3f-q6rw-m6wh)) ([#10105](https://github.com/parse-community/parse-server/issues/10105)) ([d5a057d](https://github.com/parse-community/parse-server/commit/d5a057d1a7cd5f6713d93afa3ad6f764f74b6ed2))
|
||||
|
||||
## [8.6.7](https://github.com/parse-community/parse-server/compare/8.6.6...8.6.7) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Malformed `$regex` query leaks database error details in API response (GHSA-9cp7-3q5w-j92g) ([#10102](https://github.com/parse-community/parse-server/issues/10102)) ([07870f5](https://github.com/parse-community/parse-server/commit/07870f59eec03f5c2a5fb1732cb28787ca3f8152))
|
||||
|
||||
## [8.6.6](https://github.com/parse-community/parse-server/compare/8.6.5...8.6.6) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Endpoint `/loginAs` allows `readOnlyMasterKey` to gain full read and write access as any user ([GHSA-79wj-8rqv-jvp5](https://github.com/parse-community/parse-server/security/advisories/GHSA-79wj-8rqv-jvp5)) ([#10099](https://github.com/parse-community/parse-server/issues/10099)) ([0c940b7](https://github.com/parse-community/parse-server/commit/0c940b70891c947fbf6c55536ed95ae300c23350))
|
||||
|
||||
## [8.6.5](https://github.com/parse-community/parse-server/compare/8.6.4...8.6.5) (2026-03-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* File creation and deletion bypasses `readOnlyMasterKey` write restriction (GHSA-xfh7-phr7-gr2x) ([#10096](https://github.com/parse-community/parse-server/issues/10096)) ([07bddc0](https://github.com/parse-community/parse-server/commit/07bddc0850c0eebb51219fe1d5d342f4412461ba))
|
||||
|
||||
## [8.6.4](https://github.com/parse-community/parse-server/compare/8.6.3...8.6.4) (2026-03-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction (GHSA-vc89-5g3r-cmhh) ([#10089](https://github.com/parse-community/parse-server/issues/10089)) ([6c79da9](https://github.com/parse-community/parse-server/commit/6c79da91fc5ec6f2a0bb69a0ca6a886c1585754f))
|
||||
|
||||
## [8.6.3](https://github.com/parse-community/parse-server/compare/8.6.2...8.6.3) (2026-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10073](https://github.com/parse-community/parse-server/issues/10073)) ([9b94083](https://github.com/parse-community/parse-server/commit/9b94083accb7f3e72c6b8126c195c7a03dd2dfd7))
|
||||
|
||||
## [8.6.2](https://github.com/parse-community/parse-server/compare/8.6.1...8.6.2) (2025-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Server-Side Request Forgery (SSRF) in Instagram auth adapter [GHSA-3f5f-xgrj-97pf](https://github.com/parse-community/parse-server/security/advisories/GHSA-3f5f-xgrj-97pf) ([#9989](https://github.com/parse-community/parse-server/issues/9989)) ([155c6ad](https://github.com/parse-community/parse-server/commit/155c6ad92d2375652c9720d7deed129a9e8f74ff))
|
||||
|
||||
## [8.6.1](https://github.com/parse-community/parse-server/compare/8.6.0...8.6.1) (2025-12-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9986](https://github.com/parse-community/parse-server/issues/9986)) ([12d8b50](https://github.com/parse-community/parse-server/commit/12d8b502a2f99098d177d095842b07d55f62313a))
|
||||
|
||||
# [8.6.0](https://github.com/parse-community/parse-server/compare/8.5.0...8.6.0) (2025-12-10)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.0",
|
||||
"version": "8.6.13",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "8.6.0",
|
||||
"version": "8.6.13",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "8.6.0",
|
||||
"version": "8.6.13",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
<body>
|
||||
<h1>{{appName}}</h1>
|
||||
<h1>Expired verification link!</h1>
|
||||
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
|
||||
<input name="token" type="hidden" value="{{{token}}}">
|
||||
<input name="locale" type="hidden" value="{{{locale}}}">
|
||||
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
|
||||
<input name="token" type="hidden" value="{{token}}">
|
||||
<input name="locale" type="hidden" value="{{locale}}">
|
||||
<button type="submit">Resend Link</button>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
<p>You can set a new Password for your account: {{username}}</p>
|
||||
<br />
|
||||
<p>{{error}}</p>
|
||||
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
|
||||
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
|
||||
<input name='utf-8' type='hidden' value='✓' />
|
||||
<input name="username" type="hidden" id="username" value="{{{username}}}" />
|
||||
<input name="token" type="hidden" id="token" value="{{{token}}}" />
|
||||
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
|
||||
<input name="username" type="hidden" id="username" value="{{username}}" />
|
||||
<input name="token" type="hidden" id="token" value="{{token}}" />
|
||||
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
|
||||
|
||||
<p>New Password</p>
|
||||
<input name="new_password" type="password" id="password" />
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
<body>
|
||||
<h1>{{appName}}</h1>
|
||||
<h1>Expired verification link!</h1>
|
||||
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
|
||||
<input name="token" type="hidden" value="{{{token}}}">
|
||||
<input name="locale" type="hidden" value="{{{locale}}}">
|
||||
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
|
||||
<input name="token" type="hidden" value="{{token}}">
|
||||
<input name="locale" type="hidden" value="{{locale}}">
|
||||
<button type="submit">Resend Link</button>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
<p>You can set a new Password for your account: {{username}}</p>
|
||||
<br />
|
||||
<p>{{error}}</p>
|
||||
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
|
||||
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
|
||||
<input name='utf-8' type='hidden' value='✓' />
|
||||
<input name="username" type="hidden" id="username" value="{{{username}}}" />
|
||||
<input name="token" type="hidden" id="token" value="{{{token}}}" />
|
||||
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
|
||||
<input name="username" type="hidden" id="username" value="{{username}}" />
|
||||
<input name="token" type="hidden" id="token" value="{{token}}" />
|
||||
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
|
||||
|
||||
<p>New Password</p>
|
||||
<input name="new_password" type="password" id="password" />
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
<body>
|
||||
<h1>{{appName}}</h1>
|
||||
<h1>Expired verification link!</h1>
|
||||
<form method="POST" action="{{{publicServerUrl}}}/apps/{{{appId}}}/resend_verification_email">
|
||||
<input name="token" type="hidden" value="{{{token}}}">
|
||||
<input name="locale" type="hidden" value="{{{locale}}}">
|
||||
<form method="POST" action="{{publicServerUrl}}/apps/{{appId}}/resend_verification_email">
|
||||
<input name="token" type="hidden" value="{{token}}">
|
||||
<input name="locale" type="hidden" value="{{locale}}">
|
||||
<button type="submit">Resend Link</button>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
<p>You can set a new Password for your account: {{username}}</p>
|
||||
<br />
|
||||
<p>{{error}}</p>
|
||||
<form id='form' action='{{{publicServerUrl}}}/apps/{{{appId}}}/request_password_reset' method='POST'>
|
||||
<form id='form' action='{{publicServerUrl}}/apps/{{appId}}/request_password_reset' method='POST'>
|
||||
<input name='utf-8' type='hidden' value='✓' />
|
||||
<input name="username" type="hidden" id="username" value="{{{username}}}" />
|
||||
<input name="token" type="hidden" id="token" value="{{{token}}}" />
|
||||
<input name="locale" type="hidden" id="locale" value="{{{locale}}}" />
|
||||
<input name="username" type="hidden" id="username" value="{{username}}" />
|
||||
<input name="token" type="hidden" id="token" value="{{token}}" />
|
||||
<input name="locale" type="hidden" id="locale" value="{{locale}}" />
|
||||
|
||||
<p>New Password</p>
|
||||
<input name="new_password" type="password" id="password" />
|
||||
|
||||
@@ -101,6 +101,31 @@ describe('InstagramAdapter', function () {
|
||||
'Instagram auth is invalid for this user.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore client-provided apiURL and use hardcoded endpoint', async () => {
|
||||
const accessToken = 'mockAccessToken';
|
||||
const authData = {
|
||||
id: 'mockUserId',
|
||||
apiURL: 'https://example.com/',
|
||||
};
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken',
|
||||
method: 'GET',
|
||||
response: {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'mockUserId',
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const user = await adapter.getUserFromAccessToken(accessToken, authData);
|
||||
expect(user).toEqual({ id: 'mockUserId' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstagramAdapter E2E Test', function () {
|
||||
|
||||
+153
-152
@@ -484,7 +484,7 @@ describe('google auth adapter', () => {
|
||||
|
||||
it('should throw error with missing id_token', async () => {
|
||||
try {
|
||||
await google.validateAuthData({}, {});
|
||||
await google.validateAuthData({}, { clientId: 'secret' });
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('id token is invalid for this user.');
|
||||
@@ -493,26 +493,67 @@ describe('google auth adapter', () => {
|
||||
|
||||
it('should not decode invalid id_token', async () => {
|
||||
try {
|
||||
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
|
||||
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, { clientId: 'secret' });
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('provided token does not decode as JWT');
|
||||
}
|
||||
});
|
||||
|
||||
// it('should throw error if public key used to encode token is not available', async () => {
|
||||
// const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } };
|
||||
// try {
|
||||
// spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
it('should reject forged alg:none JWT from advisory PoC (GHSA-4q3h-vp4r-prv2)', async () => {
|
||||
const header = Buffer.from('{"alg":"none","kid":"nonexistent-key","typ":"JWT"}').toString('base64url');
|
||||
const payload = Buffer.from('{"sub":"the_user_id","iss":"accounts.google.com","aud":"secret","exp":9999999999}').toString('base64url');
|
||||
const forgedToken = `${header}.${payload}.`;
|
||||
|
||||
// await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
|
||||
// fail();
|
||||
// } catch (e) {
|
||||
// expect(e.message).toBe(
|
||||
// `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}`
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
|
||||
try {
|
||||
await google.validateAuthData(
|
||||
{ id: 'the_user_id', id_token: forgedToken },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail('should have rejected forged token');
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://accounts.google.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'ES256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
await google.validateAuthData(
|
||||
{ id: 'the_user_id', id_token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should throw error if Google signing key is not found', async () => {
|
||||
const fakeDecodedToken = { kid: '789', alg: 'RS256' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.rejectWith(new Error('key not found'));
|
||||
|
||||
try {
|
||||
await google.validateAuthData(
|
||||
{ id: 'the_user_id', id_token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Unable to find matching key for Key ID: 789');
|
||||
}
|
||||
});
|
||||
|
||||
it('(using client id as string) should verify id_token (google.com)', async () => {
|
||||
const fakeClaim = {
|
||||
@@ -521,8 +562,10 @@ describe('google auth adapter', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const result = await google.validateAuthData(
|
||||
@@ -537,8 +580,10 @@ describe('google auth adapter', () => {
|
||||
iss: 'https://not.google.com',
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
@@ -561,8 +606,10 @@ describe('google auth adapter', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
@@ -583,8 +630,10 @@ describe('google auth adapter', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
@@ -597,6 +646,15 @@ describe('google auth adapter', () => {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when clientId is not configured', async () => {
|
||||
try {
|
||||
await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Google auth is not configured.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('keycloak auth adapter', () => {
|
||||
@@ -897,7 +955,27 @@ describe('apple signin auth adapter', () => {
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://appleid.apple.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'none' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
await apple.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should not verify invalid id_token', async () => {
|
||||
@@ -1133,6 +1211,15 @@ describe('apple signin auth adapter', () => {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when clientId is not configured', async () => {
|
||||
try {
|
||||
await apple.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Apple auth is not configured.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('phant auth adapter', () => {
|
||||
@@ -1168,19 +1255,9 @@ describe('facebook limited auth adapter', () => {
|
||||
const authUtils = require('../lib/Adapters/Auth/utils');
|
||||
|
||||
// TODO: figure out a way to run this test alongside facebook classic tests
|
||||
xit('(using client id as string) should throw error with missing id_token', async () => {
|
||||
xit('should throw error with missing id_token', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData({}, { clientId: 'secret' });
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Facebook auth is not configured.');
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: figure out a way to run this test alongside facebook classic tests
|
||||
xit('(using client id as array) should throw error with missing id_token', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData({}, { clientId: ['secret'] });
|
||||
await facebook.validateAuthData({}, { appIds: ['secret'] });
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Facebook auth is not configured.');
|
||||
@@ -1191,7 +1268,7 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1208,7 +1285,7 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1233,10 +1310,30 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'none' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should not verify invalid id_token', async () => {
|
||||
@@ -1248,7 +1345,7 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1256,19 +1353,7 @@ describe('facebook limited auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('(using client id as array) should not verify invalid id_token', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('provided token does not decode as JWT');
|
||||
}
|
||||
});
|
||||
|
||||
it_id('4bcb1a1a-11f8-4e12-a3f6-73f7e25e355a')(it)('using client id as string) should verify id_token (facebook.com)', async () => {
|
||||
it_id('4bcb1a1a-11f8-4e12-a3f6-73f7e25e355a')(it)('should verify id_token (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
@@ -1283,12 +1368,12 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
});
|
||||
|
||||
it_id('c521a272-2ac2-4d8b-b5ed-ea250336d8b1')(it)('(using client id as array) should verify id_token (facebook.com)', async () => {
|
||||
it_id('e3f16404-18e9-4a87-a555-4710cfbdac67')(it)('(using multiple appIds) should verify id_token (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
@@ -1303,32 +1388,12 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: ['secret'] }
|
||||
{ appIds: ['secret', 'secret 123'] }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
});
|
||||
|
||||
it_id('e3f16404-18e9-4a87-a555-4710cfbdac67')(it)('(using client id as array with multiple items) should verify id_token (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const result = await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: ['secret', 'secret 123'] }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
});
|
||||
|
||||
it_id('549c33a1-3a6b-4732-8cf6-8f010ad4569c')(it)('(using client id as string) should throw error with with invalid jwt issuer (facebook.com)', async () => {
|
||||
it_id('549c33a1-3a6b-4732-8cf6-8f010ad4569c')(it)('should throw error with with invalid jwt issuer (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://not.facebook.com',
|
||||
sub: 'the_user_id',
|
||||
@@ -1342,7 +1407,7 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1354,87 +1419,14 @@ describe('facebook limited auth adapter', () => {
|
||||
|
||||
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
|
||||
// and a private key
|
||||
xit('(using client id as array) should throw error with with invalid jwt issuer', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://not.facebook.com',
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER',
|
||||
},
|
||||
{ clientId: ['INSERT CLIENT ID HERE'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'id token not issued by correct OpenID provider - expected: https://www.facebook.com | from: https://not.facebook.com'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('(using client id as string) with token', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://not.facebook.com',
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER',
|
||||
},
|
||||
{ clientId: 'INSERT CLIENT ID HERE' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe(
|
||||
'id token not issued by correct OpenID provider - expected: https://www.facebook.com | from: https://not.facebook.com'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
|
||||
// and a private key
|
||||
xit('(using client id as string) should throw error with invalid jwt clientId', async () => {
|
||||
xit('should throw error with invalid jwt audience', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE',
|
||||
},
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('jwt audience invalid. expected: secret');
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account
|
||||
// and a private key
|
||||
xit('(using client id as array) should throw error with invalid jwt clientId', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{
|
||||
id: 'INSERT ID HERE',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE',
|
||||
},
|
||||
{ clientId: ['secret'] }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1451,7 +1443,7 @@ describe('facebook limited auth adapter', () => {
|
||||
id: 'invalid user',
|
||||
token: 'INSERT FACEBOOK TOKEN HERE',
|
||||
},
|
||||
{ clientId: 'INSERT CLIENT ID HERE' }
|
||||
{ appIds: ['INSERT APP ID HERE'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
@@ -1462,7 +1454,7 @@ describe('facebook limited auth adapter', () => {
|
||||
it_id('c194d902-e697-46c9-a303-82c2d914473c')(it)('should throw error with with invalid user id (facebook.com)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'invalid_client_id',
|
||||
aud: 'invalid_app_id',
|
||||
sub: 'a_different_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
@@ -1474,13 +1466,22 @@ describe('facebook limited auth adapter', () => {
|
||||
try {
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
{ appIds: ['secret'] }
|
||||
);
|
||||
fail();
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('auth data is invalid for this user.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error when appIds is not configured for Limited Login', async () => {
|
||||
try {
|
||||
await facebook.validateAuthData({ id: 'the_user_id', token: 'the_token' }, {});
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Facebook auth is not configured.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OTP TOTP auth adatper', () => {
|
||||
|
||||
@@ -4473,6 +4473,21 @@ describe('Parse.File hooks', () => {
|
||||
});
|
||||
expect(response.headers['content-disposition']).toBe(`attachment;filename=${file._name}`);
|
||||
});
|
||||
|
||||
it('beforeFind blocks metadata endpoint', async () => {
|
||||
const file = new Parse.File('popeye.txt', [1, 2, 3], 'text/plain');
|
||||
await file.save({ useMasterKey: true });
|
||||
Parse.Cloud.beforeFind(Parse.File, () => {
|
||||
throw 'unauthorized';
|
||||
});
|
||||
await expectAsync(
|
||||
request({
|
||||
url: `http://localhost:8378/1/files/test/metadata/${file._name}`,
|
||||
}).catch(e => {
|
||||
throw new Parse.Error(e.data.code, e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(new Parse.Error(Parse.Error.SCRIPT_FAILED, 'unauthorized'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud Config hooks', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const request = require('../lib/request');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const mustache = require('mustache');
|
||||
const Utils = require('../lib/Utils');
|
||||
@@ -957,6 +958,50 @@ describe('Pages Router', () => {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.text).toBe('Not found.');
|
||||
});
|
||||
|
||||
it('rejects requesting file from sibling directory with prefix-colliding name via encoded path traversal', async () => {
|
||||
// Create a temporary pages directory and a sibling directory whose name
|
||||
// starts with the same prefix (e.g. "pages" vs "pages-secret"), which
|
||||
// would bypass a naive `startsWith` check without a path separator.
|
||||
const baseDir = path.join(__dirname, 'tmp-pages-exploit-test');
|
||||
const pagesDir = path.join(baseDir, 'pages');
|
||||
const siblingDir = path.join(baseDir, 'pages-secret');
|
||||
const marker = `SECRET_CONTENT_${Date.now()}`;
|
||||
|
||||
try {
|
||||
await fs.mkdir(pagesDir, { recursive: true });
|
||||
await fs.mkdir(siblingDir, { recursive: true });
|
||||
// Copy a required HTML file so the pages router initializes correctly
|
||||
const publicDir = path.resolve(__dirname, '../public');
|
||||
const htmlFile = await fs.readFile(
|
||||
path.join(publicDir, 'email_verification_link_invalid.html'),
|
||||
'utf-8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pagesDir, 'email_verification_link_invalid.html'),
|
||||
htmlFile
|
||||
);
|
||||
// Write a secret file in the sibling directory
|
||||
await fs.writeFile(path.join(siblingDir, 'secret.txt'), marker);
|
||||
|
||||
config.pages.pagesPath = pagesDir;
|
||||
await reconfigureServer(config);
|
||||
|
||||
// Use URL-encoded path traversal: %2e%2e%2f = ../
|
||||
// This reaches the sibling "pages-secret" directory which shares
|
||||
// the "pages" prefix with the configured pagesPath directory name.
|
||||
const url = `${config.publicServerURL}/apps/%2e%2e%2fpages-secret%2fsecret.txt`;
|
||||
const response = await request({
|
||||
url: url,
|
||||
followRedirects: false,
|
||||
}).catch(e => e);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.text).not.toContain(marker);
|
||||
} finally {
|
||||
await fs.rm(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom route', () => {
|
||||
@@ -1180,4 +1225,72 @@ describe('Pages Router', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSS Protection', () => {
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
appId: 'test',
|
||||
appName: 'exampleAppname',
|
||||
publicServerURL: 'http://localhost:8378/1',
|
||||
pages: { enableRouter: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should escape XSS payloads in token parameter', async () => {
|
||||
const xssPayload = '"><script>alert("XSS")</script>';
|
||||
const response = await request({
|
||||
url: `http://localhost:8378/1/apps/choose_password?token=${encodeURIComponent(xssPayload)}&username=test&appId=test`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).not.toContain('<script>alert("XSS")</script>');
|
||||
expect(response.text).toContain('"><script>');
|
||||
});
|
||||
|
||||
it('should escape XSS in username parameter', async () => {
|
||||
const xssUsername = '<img src=x onerror=alert(1)>';
|
||||
const response = await request({
|
||||
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(xssUsername)}&appId=test`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).not.toContain('<img src=x onerror=alert(1)>');
|
||||
expect(response.text).toContain('<img');
|
||||
});
|
||||
|
||||
it('should escape XSS in locale parameter', async () => {
|
||||
const xssLocale = '"><svg/onload=alert(1)>';
|
||||
const response = await request({
|
||||
url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).not.toContain('<svg/onload=alert(1)>');
|
||||
expect(response.text).toContain('"><svg');
|
||||
});
|
||||
|
||||
it('should handle legitimate usernames with quotes correctly', async () => {
|
||||
const username = "O'Brien";
|
||||
const response = await request({
|
||||
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should be properly escaped as HTML entity
|
||||
expect(response.text).toContain('O'Brien');
|
||||
// Should NOT contain unescaped quote that breaks HTML
|
||||
expect(response.text).not.toContain('value="O\'Brien"');
|
||||
});
|
||||
|
||||
it('should handle legitimate usernames with ampersands correctly', async () => {
|
||||
const username = 'Smith & Co';
|
||||
const response = await request({
|
||||
url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should be properly escaped
|
||||
expect(response.text).toContain('Smith & Co');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ const Id = require('../lib/LiveQuery/Id');
|
||||
const QueryTools = require('../lib/LiveQuery/QueryTools');
|
||||
const queryHash = QueryTools.queryHash;
|
||||
const matchesQuery = QueryTools.matchesQuery;
|
||||
const setRegexTimeout = QueryTools.setRegexTimeout;
|
||||
|
||||
const Item = Parse.Object.extend('Item');
|
||||
|
||||
@@ -445,6 +446,103 @@ describe('matchesQuery', function () {
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects $regex with catastrophic backtracking pattern (string)', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'a'.repeat(30),
|
||||
score: 12,
|
||||
};
|
||||
// (a+)+b - classic catastrophic backtracking
|
||||
expect(matchesQuery(player, { name: { $regex: '(a+)+b' } })).toBe(false);
|
||||
// (a|a)+b - alternation variant
|
||||
expect(matchesQuery(player, { name: { $regex: '(a|a)+b' } })).toBe(false);
|
||||
// (a+){2,}b - quantifier variant
|
||||
expect(matchesQuery(player, { name: { $regex: '(a+){2,}b' } })).toBe(false);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects $regex with catastrophic backtracking pattern (RegExp object)', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'a'.repeat(30),
|
||||
score: 12,
|
||||
};
|
||||
const q = new Parse.Query('Player');
|
||||
q.matches('name', /(a+)+b/);
|
||||
expect(matchesQuery(player, q)).toBe(false);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('still matches safe $regex patterns with regexTimeout enabled', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'Player 1',
|
||||
score: 12,
|
||||
};
|
||||
// startsWith
|
||||
let q = new Parse.Query('Player');
|
||||
q.startsWith('name', 'Play');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// endsWith
|
||||
q = new Parse.Query('Player');
|
||||
q.endsWith('name', ' 1');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// contains
|
||||
player.name = 'Android-7';
|
||||
q = new Parse.Query('Player');
|
||||
q.contains('name', 'd-7');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// matches
|
||||
q = new Parse.Query('Player');
|
||||
q.matches('name', /A.d/);
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
// case insensitive
|
||||
q = new Parse.Query('Player');
|
||||
q.matches('name', /android/i);
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('matches $regex with backreferences when regexTimeout is enabled', function () {
|
||||
setRegexTimeout(100);
|
||||
try {
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'aa',
|
||||
score: 12,
|
||||
};
|
||||
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(true);
|
||||
player.name = 'ab';
|
||||
expect(matchesQuery(player, { name: { $regex: '(a)\\1' } })).toBe(false);
|
||||
} finally {
|
||||
setRegexTimeout(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses native RegExp when regexTimeout is 0 (disabled)', function () {
|
||||
setRegexTimeout(0);
|
||||
const player = {
|
||||
id: new Id('Player', 'P1'),
|
||||
name: 'Player 1',
|
||||
score: 12,
|
||||
};
|
||||
const q = new Parse.Query('Player');
|
||||
q.startsWith('name', 'Play');
|
||||
expect(matchesQuery(player, q)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches $nearSphere queries', function () {
|
||||
let q = new Parse.Query('Checkin');
|
||||
q.near('location', new Parse.GeoPoint(20, 20));
|
||||
|
||||
@@ -338,6 +338,45 @@ describe('Security Check', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('warns when LiveQuery regex timeout is disabled', async () => {
|
||||
await reconfigureServer({
|
||||
security: { enableCheck: true, enableCheckLog: true },
|
||||
liveQuery: { classNames: ['TestObject'], regexTimeout: 0 },
|
||||
});
|
||||
const runner = new CheckRunner({ enableCheck: true });
|
||||
const report = await runner.run();
|
||||
const check = report.report.groups
|
||||
.flatMap(g => g.checks)
|
||||
.find(c => c.title === 'LiveQuery regex timeout enabled');
|
||||
expect(check).toBeDefined();
|
||||
expect(check.state).toBe(CheckState.fail);
|
||||
});
|
||||
|
||||
it('passes when LiveQuery regex timeout is enabled', async () => {
|
||||
await reconfigureServer({
|
||||
security: { enableCheck: true, enableCheckLog: true },
|
||||
liveQuery: { classNames: ['TestObject'], regexTimeout: 100 },
|
||||
});
|
||||
const runner = new CheckRunner({ enableCheck: true });
|
||||
const report = await runner.run();
|
||||
const check = report.report.groups
|
||||
.flatMap(g => g.checks)
|
||||
.find(c => c.title === 'LiveQuery regex timeout enabled');
|
||||
expect(check.state).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it('passes when LiveQuery is not configured', async () => {
|
||||
await reconfigureServer({
|
||||
security: { enableCheck: true, enableCheckLog: true },
|
||||
});
|
||||
const runner = new CheckRunner({ enableCheck: true });
|
||||
const report = await runner.run();
|
||||
const check = report.report.groups
|
||||
.flatMap(g => g.checks)
|
||||
.find(c => c.title === 'LiveQuery regex timeout enabled');
|
||||
expect(check.state).toBe(CheckState.success);
|
||||
});
|
||||
|
||||
it('does update featuresRouter', async () => {
|
||||
let response = await request({
|
||||
url: 'http://localhost:8378/1/serverInfo',
|
||||
|
||||
+5
-3
@@ -659,12 +659,12 @@ describe('server', () => {
|
||||
});
|
||||
|
||||
|
||||
it('should not fail when Google signin is introduced without the optional clientId', done => {
|
||||
it('should not fail when Google signin is introduced with clientId', done => {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('../lib/Adapters/Auth/utils');
|
||||
|
||||
reconfigureServer({
|
||||
auth: { google: {} },
|
||||
auth: { google: { clientId: 'secret' } },
|
||||
})
|
||||
.then(() => {
|
||||
const fakeClaim = {
|
||||
@@ -673,8 +673,10 @@ describe('server', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
const user = new Parse.User();
|
||||
user
|
||||
|
||||
@@ -1172,6 +1172,224 @@ describe('read-only masterKey', () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when trying to create a hook function', async () => {
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
url: `${Parse.serverURL}/hooks/functions`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { functionName: 'readOnlyTest', url: 'https://example.com/hook' },
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw when trying to create a hook trigger', async () => {
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
url: `${Parse.serverURL}/hooks/triggers`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { className: 'MyClass', triggerName: 'beforeSave', url: 'https://example.com/hook' },
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw when trying to update a hook function', async () => {
|
||||
// First create the hook with the real master key
|
||||
await request({
|
||||
url: `${Parse.serverURL}/hooks/functions`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { functionName: 'readOnlyUpdateTest', url: 'https://example.com/hook' },
|
||||
});
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
url: `${Parse.serverURL}/hooks/functions/readOnlyUpdateTest`,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { url: 'https://example.com/hacked' },
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw when trying to delete a hook function', async () => {
|
||||
// First create the hook with the real master key
|
||||
await request({
|
||||
url: `${Parse.serverURL}/hooks/functions`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { functionName: 'readOnlyDeleteTest', url: 'https://example.com/hook' },
|
||||
});
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
url: `${Parse.serverURL}/hooks/functions/readOnlyDeleteTest`,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { __op: 'Delete' },
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw when trying to run a job with readOnlyMasterKey', async () => {
|
||||
Parse.Cloud.job('readOnlyTestJob', () => {});
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
url: `${Parse.serverURL}/jobs/readOnlyTestJob`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {},
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow reading hooks with readOnlyMasterKey', async () => {
|
||||
const res = await request({
|
||||
url: `${Parse.serverURL}/hooks/functions`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
},
|
||||
});
|
||||
expect(Array.isArray(res.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw when trying to delete a file with readOnlyMasterKey', async () => {
|
||||
// Create a file with the real master key
|
||||
const uploadRes = await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/files/readonly-delete-test.txt`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': Parse.masterKey,
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: 'file content',
|
||||
});
|
||||
const filename = uploadRes.data.name;
|
||||
expect(filename).toBeDefined();
|
||||
|
||||
// Attempt delete with readOnlyMasterKey — should be rejected
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
method: 'DELETE',
|
||||
url: `${Parse.serverURL}/files/${filename}`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
},
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
|
||||
// Verify file still exists
|
||||
const getRes = await request({ url: uploadRes.data.url });
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should throw when trying to create a file with readOnlyMasterKey', async () => {
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/files/readonly-create-test.txt`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: 'file content',
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw when trying to loginAs with readOnlyMasterKey', async () => {
|
||||
// Create a target user
|
||||
await Parse.User.signUp('readonly-loginas-test', 'password123');
|
||||
const userId = Parse.User.current().id;
|
||||
await Parse.User.logOut();
|
||||
|
||||
// Attempt loginAs with readOnlyMasterKey — should be rejected
|
||||
loggerErrorSpy.calls.reset();
|
||||
try {
|
||||
await request({
|
||||
method: 'POST',
|
||||
url: `${Parse.serverURL}/loginAs`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-Master-Key': 'read-only-test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: { userId },
|
||||
});
|
||||
fail('should have thrown');
|
||||
} catch (res) {
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('rest context', () => {
|
||||
|
||||
@@ -131,7 +131,228 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-5j86-7r7m-p8h6) Cloud function name prototype chain bypass', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
it('rejects "constructor" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/constructor',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "toString" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/toString',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "valueOf" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/valueOf',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "hasOwnProperty" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/hasOwnProperty',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('rejects "__proto__.toString" as cloud function name', async () => {
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/__proto__.toString',
|
||||
body: JSON.stringify({}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.SCRIPT_FAILED);
|
||||
expect(text.error).toContain('Invalid function');
|
||||
});
|
||||
|
||||
it('still executes a legitimately defined cloud function', async () => {
|
||||
Parse.Cloud.define('legitimateFunction', () => 'hello');
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/functions/legitimateFunction',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(response.text).result).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request denylist', () => {
|
||||
describe('(GHSA-q342-9w2p-57fp) Denylist bypass via sibling nested objects', () => {
|
||||
it('denies _bsontype:Code after a sibling nested object', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
obj: {
|
||||
metadata: {},
|
||||
_bsontype: 'Code',
|
||||
code: 'malicious',
|
||||
},
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('denies _bsontype:Code after a sibling nested array', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
obj: {
|
||||
tags: ['safe'],
|
||||
_bsontype: 'Code',
|
||||
code: 'malicious',
|
||||
},
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('denies __proto__ after a sibling nested object', async () => {
|
||||
// Cannot test via HTTP because deepcopy() strips __proto__ before the denylist
|
||||
// check runs. Test objectContainsKeyValue directly with a JSON.parse'd object
|
||||
// that preserves __proto__ as an own property.
|
||||
const Utils = require('../lib/Utils');
|
||||
const data = JSON.parse('{"profile": {"name": "alice"}, "__proto__": {"isAdmin": true}}');
|
||||
expect(Utils.objectContainsKeyValue(data, '__proto__', undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies constructor after a sibling nested object', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
obj: {
|
||||
data: {},
|
||||
constructor: { prototype: { polluted: true } },
|
||||
},
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"constructor"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('denies _bsontype:Code nested inside a second sibling object', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
const response = await request({
|
||||
headers,
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/classes/Bypass',
|
||||
body: JSON.stringify({
|
||||
field1: { safe: true },
|
||||
field2: { _bsontype: 'Code', code: 'malicious' },
|
||||
}),
|
||||
}).catch(e => e);
|
||||
expect(response.status).toBe(400);
|
||||
const text = JSON.parse(response.text);
|
||||
expect(text.code).toBe(Parse.Error.INVALID_KEY_NAME);
|
||||
expect(text.error).toBe(
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles circular references without infinite loop', () => {
|
||||
const Utils = require('../lib/Utils');
|
||||
const obj = { name: 'test', nested: { value: 1 } };
|
||||
obj.nested.self = obj;
|
||||
expect(Utils.objectContainsKeyValue(obj, 'nonexistent', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('denies _bsontype:Code in file metadata after a sibling nested object', async () => {
|
||||
const str = 'Hello World!';
|
||||
const data = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
data.push(str.charCodeAt(i));
|
||||
}
|
||||
const file = new Parse.File('hello.txt', data, 'text/plain');
|
||||
file.addMetadata('nested', { safe: true });
|
||||
file.addMetadata('_bsontype', 'Code');
|
||||
file.addMetadata('code', 'malicious');
|
||||
await expectAsync(file.save()).toBeRejectedWith(
|
||||
new Parse.Error(
|
||||
Parse.Error.INVALID_KEY_NAME,
|
||||
'Prohibited keyword in request data: {"key":"_bsontype","value":"Code"}.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('denies BSON type code data in write request by default', async () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -478,6 +699,100 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-mf3j-86qx-cq5j) ReDoS via $regex in LiveQuery subscription', () => {
|
||||
it('should prevent ReDoS via catastrophic backtracking in LiveQuery $regex', async () => {
|
||||
await reconfigureServer({
|
||||
liveQuery: {
|
||||
classNames: ['TestObject'],
|
||||
regexTimeout: 100,
|
||||
},
|
||||
startLiveQueryServer: true,
|
||||
});
|
||||
const query = new Parse.Query('TestObject');
|
||||
query.matches('field', /(a+)+b/);
|
||||
const subscription = await query.subscribe();
|
||||
const createPromise = new Promise(resolve => {
|
||||
subscription.on('create', () => resolve('should_not_match'));
|
||||
setTimeout(() => resolve('timeout'), 3000);
|
||||
});
|
||||
const obj = new Parse.Object('TestObject');
|
||||
obj.set('field', 'a'.repeat(30));
|
||||
await obj.save();
|
||||
const result = await createPromise;
|
||||
expect(result).toBe('timeout');
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Malformed $regex information disclosure', () => {
|
||||
it('should not leak database error internals for invalid regex pattern in class query', async () => {
|
||||
const logger = require('../lib/logger').default;
|
||||
const loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
|
||||
const obj = new Parse.Object('TestObject');
|
||||
await obj.save({ field: 'value' });
|
||||
|
||||
try {
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: `http://localhost:8378/1/classes/TestObject`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
qs: {
|
||||
where: JSON.stringify({ field: { $regex: '[abc' } }),
|
||||
},
|
||||
});
|
||||
fail('Request should have failed');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(e.data.error).toBe('An internal server error occurred');
|
||||
expect(typeof e.data.error).toBe('string');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errmsg');
|
||||
expect(JSON.stringify(e.data)).not.toContain('codeName');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errorResponse');
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Sanitized error:',
|
||||
jasmine.stringMatching(/[Rr]egular expression/i)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not leak database error internals for invalid regex pattern in role query', async () => {
|
||||
const logger = require('../lib/logger').default;
|
||||
const loggerErrorSpy = spyOn(logger, 'error').and.callThrough();
|
||||
const role = new Parse.Role('testrole', new Parse.ACL());
|
||||
await role.save(null, { useMasterKey: true });
|
||||
try {
|
||||
await request({
|
||||
method: 'GET',
|
||||
url: `http://localhost:8378/1/roles`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
qs: {
|
||||
where: JSON.stringify({ name: { $regex: '[abc' } }),
|
||||
},
|
||||
});
|
||||
fail('Request should have failed');
|
||||
} catch (e) {
|
||||
expect(e.data.code).toBe(Parse.Error.INTERNAL_SERVER_ERROR);
|
||||
expect(e.data.error).toBe('An internal server error occurred');
|
||||
expect(typeof e.data.error).toBe('string');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errmsg');
|
||||
expect(JSON.stringify(e.data)).not.toContain('codeName');
|
||||
expect(JSON.stringify(e.data)).not.toContain('errorResponse');
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Sanitized error:',
|
||||
jasmine.stringMatching(/[Rr]egular expression/i)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Postgres regex sanitizater', () => {
|
||||
it('sanitizes the regex correctly to prevent Injection', async () => {
|
||||
const user = new Parse.User();
|
||||
|
||||
@@ -73,11 +73,18 @@ const getAppleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
|
||||
};
|
||||
|
||||
const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => {
|
||||
if (!clientId) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Apple auth is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
|
||||
}
|
||||
|
||||
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
let jwtClaims;
|
||||
|
||||
@@ -89,7 +96,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
|
||||
|
||||
try {
|
||||
jwtClaims = jwt.verify(token, signingKey, {
|
||||
algorithms: algorithm,
|
||||
algorithms: ['RS256'],
|
||||
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
@@ -52,8 +52,6 @@
|
||||
* - `>= 6.5.6 < 7`
|
||||
* - `>= 7.0.1`
|
||||
*
|
||||
* Secure authentication is recommended to ensure proper data protection and compliance with Facebook's guidelines.
|
||||
*
|
||||
* @see {@link https://developers.facebook.com/docs/facebook-login/limited-login/ Facebook Limited Login}
|
||||
* @see {@link https://developers.facebook.com/docs/facebook-login/facebook-login-for-business/ Facebook Login for Business}
|
||||
*/
|
||||
@@ -131,12 +129,19 @@ const getFacebookKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
|
||||
return key;
|
||||
};
|
||||
|
||||
const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => {
|
||||
const verifyIdToken = async ({ token, id }, { appIds, cacheMaxEntries, cacheMaxAge }) => {
|
||||
if (!Array.isArray(appIds) || !appIds.length) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Facebook auth is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.');
|
||||
}
|
||||
|
||||
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
let jwtClaims;
|
||||
|
||||
@@ -148,9 +153,9 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
|
||||
|
||||
try {
|
||||
jwtClaims = jwt.verify(token, signingKey, {
|
||||
algorithms: algorithm,
|
||||
algorithms: ['RS256'],
|
||||
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
|
||||
audience: clientId,
|
||||
audience: appIds,
|
||||
});
|
||||
} catch (exception) {
|
||||
const message = exception.message;
|
||||
|
||||
+41
-112
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* @class GoogleAdapter
|
||||
* @param {Object} options - The adapter configuration options.
|
||||
* @param {string} options.clientId - Your Google application Client ID. Required for authentication.
|
||||
* @param {string} options.clientId - Your Google application Client ID.
|
||||
* @param {number} [options.cacheMaxEntries] - Maximum number of JWKS cache entries. Default: 5.
|
||||
* @param {number} [options.cacheMaxAge] - Maximum age of JWKS cache entries in ms. Default: 3600000 (1 hour).
|
||||
*
|
||||
* @description
|
||||
* ## Parse Server Configuration
|
||||
@@ -21,7 +23,6 @@
|
||||
* The adapter requires the following `authData` fields:
|
||||
* - **id**: The Google user ID.
|
||||
* - **id_token**: The Google ID token.
|
||||
* - **access_token**: The Google access token.
|
||||
*
|
||||
* ## Auth Payload
|
||||
* ### Example Auth Data Payload
|
||||
@@ -29,85 +30,74 @@
|
||||
* {
|
||||
* "google": {
|
||||
* "id": "1234567",
|
||||
* "id_token": "xxxxx.yyyyy.zzzzz",
|
||||
* "access_token": "abc123def456ghi789"
|
||||
* "id_token": "xxxxx.yyyyy.zzzzz"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Notes
|
||||
* - Ensure your Google Client ID is configured properly in the Parse Server configuration.
|
||||
* - The `id_token` and `access_token` are validated against Google's authentication services.
|
||||
* - The `id_token` is validated against Google's authentication services.
|
||||
*
|
||||
* @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation}
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Helper functions for accessing the google API.
|
||||
var Parse = require('parse/node').Parse;
|
||||
|
||||
const https = require('https');
|
||||
const jwksClient = require('jwks-rsa');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('./utils');
|
||||
|
||||
const TOKEN_ISSUER = 'accounts.google.com';
|
||||
const HTTPS_TOKEN_ISSUER = 'https://accounts.google.com';
|
||||
|
||||
let cache = {};
|
||||
const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
|
||||
const client = jwksClient({
|
||||
jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
|
||||
cache: true,
|
||||
cacheMaxEntries,
|
||||
cacheMaxAge,
|
||||
});
|
||||
|
||||
// Retrieve Google Signin Keys (with cache control)
|
||||
function getGoogleKeyByKeyId(keyId) {
|
||||
if (cache[keyId] && cache.expiresAt > new Date()) {
|
||||
return cache[keyId];
|
||||
let key;
|
||||
try {
|
||||
key = await authUtils.getSigningKey(client, keyId);
|
||||
} catch {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`Unable to find matching key for Key ID: ${keyId}`
|
||||
);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) {
|
||||
if (!clientId) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
'Google auth is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(`https://www.googleapis.com/oauth2/v3/certs`, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => {
|
||||
data += chunk.toString('utf8');
|
||||
});
|
||||
res.on('end', () => {
|
||||
const { keys } = JSON.parse(data);
|
||||
const pems = keys.reduce(
|
||||
(pems, { n: modulus, e: exposant, kid }) =>
|
||||
Object.assign(pems, {
|
||||
[kid]: rsaPublicKeyToPEM(modulus, exposant),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
if (res.headers['cache-control']) {
|
||||
var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/);
|
||||
|
||||
if (expire) {
|
||||
cache = Object.assign({}, pems, {
|
||||
expiresAt: new Date(new Date().getTime() + Number(expire[1]) * 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resolve(pems[keyId]);
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyIdToken({ id_token: token, id }, { clientId }) {
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
|
||||
}
|
||||
|
||||
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
let jwtClaims;
|
||||
const googleKey = await getGoogleKeyByKeyId(keyId);
|
||||
|
||||
cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS;
|
||||
cacheMaxEntries = cacheMaxEntries || 5;
|
||||
|
||||
const googleKey = await getGoogleKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge);
|
||||
const signingKey = googleKey.publicKey || googleKey.rsaPublicKey;
|
||||
|
||||
try {
|
||||
jwtClaims = jwt.verify(token, googleKey, {
|
||||
algorithms: algorithm,
|
||||
jwtClaims = jwt.verify(token, signingKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience: clientId,
|
||||
});
|
||||
} catch (exception) {
|
||||
@@ -126,13 +116,6 @@ async function verifyIdToken({ id_token: token, id }, { clientId }) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`);
|
||||
}
|
||||
|
||||
if (clientId && jwtClaims.aud !== clientId) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`id token not authorized for this clientId.`
|
||||
);
|
||||
}
|
||||
|
||||
return jwtClaims;
|
||||
}
|
||||
|
||||
@@ -150,57 +133,3 @@ module.exports = {
|
||||
validateAppId: validateAppId,
|
||||
validateAuthData: validateAuthData,
|
||||
};
|
||||
|
||||
// Helpers functions to convert the RSA certs to PEM (from jwks-rsa)
|
||||
function rsaPublicKeyToPEM(modulusB64, exponentB64) {
|
||||
const modulus = new Buffer(modulusB64, 'base64');
|
||||
const exponent = new Buffer(exponentB64, 'base64');
|
||||
const modulusHex = prepadSigned(modulus.toString('hex'));
|
||||
const exponentHex = prepadSigned(exponent.toString('hex'));
|
||||
const modlen = modulusHex.length / 2;
|
||||
const explen = exponentHex.length / 2;
|
||||
|
||||
const encodedModlen = encodeLengthHex(modlen);
|
||||
const encodedExplen = encodeLengthHex(explen);
|
||||
const encodedPubkey =
|
||||
'30' +
|
||||
encodeLengthHex(modlen + explen + encodedModlen.length / 2 + encodedExplen.length / 2 + 2) +
|
||||
'02' +
|
||||
encodedModlen +
|
||||
modulusHex +
|
||||
'02' +
|
||||
encodedExplen +
|
||||
exponentHex;
|
||||
|
||||
const der = new Buffer(encodedPubkey, 'hex').toString('base64');
|
||||
|
||||
let pem = '-----BEGIN RSA PUBLIC KEY-----\n';
|
||||
pem += `${der.match(/.{1,64}/g).join('\n')}`;
|
||||
pem += '\n-----END RSA PUBLIC KEY-----\n';
|
||||
return pem;
|
||||
}
|
||||
|
||||
function prepadSigned(hexStr) {
|
||||
const msb = hexStr[0];
|
||||
if (msb < '0' || msb > '7') {
|
||||
return `00${hexStr}`;
|
||||
}
|
||||
return hexStr;
|
||||
}
|
||||
|
||||
function toHex(number) {
|
||||
const nstr = number.toString(16);
|
||||
if (nstr.length % 2) {
|
||||
return `0${nstr}`;
|
||||
}
|
||||
return nstr;
|
||||
}
|
||||
|
||||
function encodeLengthHex(n) {
|
||||
if (n <= 127) {
|
||||
return toHex(n);
|
||||
}
|
||||
const nHex = toHex(n);
|
||||
const lengthOfLengthByte = 128 + nHex.length / 2;
|
||||
return toHex(lengthOfLengthByte) + nHex;
|
||||
}
|
||||
|
||||
@@ -96,8 +96,7 @@ class InstagramAdapter extends BaseAuthCodeAdapter {
|
||||
}
|
||||
|
||||
async getUserFromAccessToken(accessToken, authData) {
|
||||
const defaultURL = 'https://graph.instagram.com/';
|
||||
const apiURL = authData.apiURL || defaultURL;
|
||||
const apiURL = 'https://graph.instagram.com/';
|
||||
const path = `${apiURL}me?fields=id&access_token=${accessToken}`;
|
||||
|
||||
const response = await fetch(path);
|
||||
|
||||
@@ -20,6 +20,7 @@ import SchemaCache from '../Adapters/Cache/SchemaCache';
|
||||
import type { LoadSchemaOptions } from './types';
|
||||
import type { ParseServerOptions } from '../Options';
|
||||
import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter';
|
||||
import { createSanitizedError } from '../Error';
|
||||
|
||||
function addWriteACL(query, acl) {
|
||||
const newQuery = _.cloneDeep(query);
|
||||
@@ -1354,7 +1355,19 @@ class DatabaseController {
|
||||
})
|
||||
)
|
||||
.catch(error => {
|
||||
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, error);
|
||||
if (error instanceof Parse.Error) {
|
||||
throw error;
|
||||
}
|
||||
const detailedMessage =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error?.message || 'An internal server error occurred';
|
||||
throw createSanitizedError(
|
||||
Parse.Error.INTERNAL_SERVER_ERROR,
|
||||
detailedMessage,
|
||||
this.options,
|
||||
'An internal server error occurred'
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+4
-2
@@ -6,9 +6,11 @@ import defaultLogger from './logger';
|
||||
*
|
||||
* @param {number} errorCode - The Parse.Error code (e.g., Parse.Error.OPERATION_FORBIDDEN)
|
||||
* @param {string} detailedMessage - The detailed error message to log server-side
|
||||
* @param {object} config - Parse Server config with enableSanitizedErrorResponse
|
||||
* @param {string} [sanitizedMessage='Permission denied'] - The sanitized message to return to clients
|
||||
* @returns {Parse.Error} A Parse.Error with sanitized message
|
||||
*/
|
||||
function createSanitizedError(errorCode, detailedMessage, config) {
|
||||
function createSanitizedError(errorCode, detailedMessage, config, sanitizedMessage = 'Permission denied') {
|
||||
// On testing we need to add a prefix to the message to allow to find the correct call in the TestUtils.js file
|
||||
if (process.env.TESTING) {
|
||||
defaultLogger.error('Sanitized error:', detailedMessage);
|
||||
@@ -16,7 +18,7 @@ function createSanitizedError(errorCode, detailedMessage, config) {
|
||||
defaultLogger.error(detailedMessage);
|
||||
}
|
||||
|
||||
return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? 'Permission denied' : detailedMessage);
|
||||
return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? sanitizedMessage : detailedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
var equalObjects = require('./equalObjects');
|
||||
var Id = require('./Id');
|
||||
var Parse = require('parse/node');
|
||||
var vm = require('vm');
|
||||
var logger = require('../logger').default;
|
||||
|
||||
var regexTimeout = 0;
|
||||
var vmContext = vm.createContext(Object.create(null));
|
||||
var scriptCache = new Map();
|
||||
var SCRIPT_CACHE_MAX = 1000;
|
||||
|
||||
function setRegexTimeout(ms) {
|
||||
regexTimeout = ms;
|
||||
}
|
||||
|
||||
function safeRegexTest(pattern, flags, input) {
|
||||
if (!regexTimeout) {
|
||||
var re = new RegExp(pattern, flags);
|
||||
return re.test(input);
|
||||
}
|
||||
var cacheKey = flags + ':' + pattern;
|
||||
var script = scriptCache.get(cacheKey);
|
||||
if (!script) {
|
||||
if (scriptCache.size >= SCRIPT_CACHE_MAX) { scriptCache.clear(); }
|
||||
script = new vm.Script('new RegExp(pattern, flags).test(input)');
|
||||
scriptCache.set(cacheKey, script);
|
||||
}
|
||||
vmContext.pattern = pattern;
|
||||
vmContext.flags = flags;
|
||||
vmContext.input = input;
|
||||
try {
|
||||
return script.runInContext(vmContext, { timeout: regexTimeout });
|
||||
} catch (e) {
|
||||
if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
|
||||
logger.warn(`Regex timeout: pattern "${pattern}" with flags "${flags}" exceeded ${regexTimeout}ms limit`);
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Hashes are deterministic hashes for Parse Queries.
|
||||
@@ -290,9 +327,12 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '$regex':
|
||||
case '$regex': {
|
||||
if (typeof compareTo === 'object') {
|
||||
return compareTo.test(object[key]);
|
||||
if (!safeRegexTest(compareTo.source, compareTo.flags, object[key])) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// JS doesn't support perl-style escaping
|
||||
var expString = '';
|
||||
@@ -312,11 +352,11 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
escapeStart = compareTo.indexOf('\\Q', escapeEnd);
|
||||
}
|
||||
expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
|
||||
var exp = new RegExp(expString, constraints.$options || '');
|
||||
if (!exp.test(object[key])) {
|
||||
if (!safeRegexTest(expString, constraints.$options || '', object[key])) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '$nearSphere':
|
||||
if (!compareTo || !object[key]) {
|
||||
return false;
|
||||
@@ -396,6 +436,7 @@ function matchesKeyConstraints(object, key, constraints) {
|
||||
var QueryTools = {
|
||||
queryHash: queryHash,
|
||||
matchesQuery: matchesQuery,
|
||||
setRegexTimeout: setRegexTimeout,
|
||||
};
|
||||
|
||||
module.exports = QueryTools;
|
||||
|
||||
@@ -901,6 +901,13 @@ module.exports.LiveQueryOptions = {
|
||||
env: 'PARSE_SERVER_LIVEQUERY_REDIS_URL',
|
||||
help: "parse-server's LiveQuery redisURL",
|
||||
},
|
||||
regexTimeout: {
|
||||
env: 'PARSE_SERVER_LIVEQUERY_REGEX_TIMEOUT',
|
||||
help:
|
||||
'Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.',
|
||||
action: parsers.numberParser('regexTimeout'),
|
||||
default: 100,
|
||||
},
|
||||
wssAdapter: {
|
||||
env: 'PARSE_SERVER_LIVEQUERY_WSS_ADAPTER',
|
||||
help: 'Adapter module for the WebSocketServer',
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
* @property {Adapter<PubSubAdapter>} pubSubAdapter LiveQuery pubsub adapter
|
||||
* @property {Any} redisOptions parse-server's LiveQuery redisOptions
|
||||
* @property {String} redisURL parse-server's LiveQuery redisURL
|
||||
* @property {Number} regexTimeout Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.
|
||||
* @property {Adapter<WSSAdapter>} wssAdapter Adapter module for the WebSocketServer
|
||||
*/
|
||||
|
||||
|
||||
@@ -489,6 +489,9 @@ export interface LiveQueryOptions {
|
||||
redisURL: ?string;
|
||||
/* LiveQuery pubsub adapter */
|
||||
pubSubAdapter: ?Adapter<PubSubAdapter>;
|
||||
/* Sets the maximum execution time in milliseconds for regular expression pattern matching in LiveQuery. This protects against Regular Expression Denial of Service (ReDoS) attacks where a malicious regex pattern could block the event loop. A regex that exceeds the timeout will be treated as non-matching.<br><br>The protection runs each regex evaluation in an isolated VM context with a timeout. This adds approximately 50 microseconds of overhead per regex evaluation. For most applications this is negligible, but it can add up if you have a very large number of LiveQuery subscriptions that use `$regex` on the same class. For example, 10,000 concurrent regex subscriptions would add approximately 500ms of processing time per object save event on that class.<br><br>Set to `0` to disable the timeout and use native regex evaluation without protection. Defaults to `100`.
|
||||
:DEFAULT: 100 */
|
||||
regexTimeout: ?number;
|
||||
/* Adapter module for the WebSocketServer */
|
||||
wssAdapter: ?Adapter<WSSAdapter>;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ var batch = require('./batch'),
|
||||
fs = require('fs');
|
||||
|
||||
import { ParseServerOptions, LiveQueryServerOptions } from './Options';
|
||||
import { setRegexTimeout } from './LiveQuery/QueryTools';
|
||||
import defaults from './defaults';
|
||||
import * as logging from './logger';
|
||||
import Config from './Config';
|
||||
@@ -139,6 +140,7 @@ class ParseServer {
|
||||
this.config.masterKeyIpsStore = new Map();
|
||||
this.config.maintenanceKeyIpsStore = new Map();
|
||||
logging.setLogger(allControllers.loggerController);
|
||||
setRegexTimeout(options.liveQuery?.regexTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import Config from '../Config';
|
||||
import logger from '../logger';
|
||||
const triggers = require('../triggers');
|
||||
const Utils = require('../Utils');
|
||||
import { createSanitizedHttpError } from '../Error';
|
||||
|
||||
export class FilesRouter {
|
||||
expressRouter({ maxUploadSize = '20Mb' } = {}) {
|
||||
@@ -112,6 +113,12 @@ export class FilesRouter {
|
||||
}
|
||||
|
||||
async createHandler(req, res, next) {
|
||||
if (req.auth.isReadOnly) {
|
||||
const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to create a file.", req.config);
|
||||
res.status(error.status);
|
||||
res.end(`{"error":"${error.message}"}`);
|
||||
return;
|
||||
}
|
||||
const config = req.config;
|
||||
const user = req.auth.user;
|
||||
const isMaster = req.auth.isMaster;
|
||||
@@ -266,6 +273,12 @@ export class FilesRouter {
|
||||
}
|
||||
|
||||
async deleteHandler(req, res, next) {
|
||||
if (req.auth.isReadOnly) {
|
||||
const error = createSanitizedHttpError(403, "read-only masterKey isn't allowed to delete a file.", req.config);
|
||||
res.status(error.status);
|
||||
res.end(`{"error":"${error.message}"}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { filesController } = req.config;
|
||||
const { filename } = req.params;
|
||||
@@ -304,14 +317,45 @@ export class FilesRouter {
|
||||
async metadataHandler(req, res) {
|
||||
try {
|
||||
const config = Config.get(req.params.appId);
|
||||
if (!config) {
|
||||
res.status(200);
|
||||
res.json({});
|
||||
return;
|
||||
}
|
||||
const { filesController } = config;
|
||||
const { filename } = req.params;
|
||||
const data = await filesController.getMetadata(filename);
|
||||
let { filename } = req.params;
|
||||
const file = new Parse.File(filename, { base64: '' });
|
||||
const triggerResult = await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.beforeFind,
|
||||
{ file },
|
||||
config,
|
||||
req.auth
|
||||
);
|
||||
if (triggerResult?.file?._name) {
|
||||
filename = triggerResult.file._name;
|
||||
}
|
||||
const data = await filesController.getMetadata(filename).catch(() => {
|
||||
res.status(200);
|
||||
res.json({});
|
||||
});
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
await triggers.maybeRunFileTrigger(
|
||||
triggers.Types.afterFind,
|
||||
{ file },
|
||||
config,
|
||||
req.auth
|
||||
);
|
||||
res.status(200);
|
||||
res.json(data);
|
||||
} catch {
|
||||
res.status(200);
|
||||
res.json({});
|
||||
} catch (e) {
|
||||
const err = triggers.resolveError(e, {
|
||||
code: Parse.Error.SCRIPT_FAILED,
|
||||
message: 'Could not get file metadata.',
|
||||
});
|
||||
res.status(403);
|
||||
res.json({ code: err.code, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { promiseEnforceMasterKeyAccess, promiseEnsureIdempotency } from '../midd
|
||||
import { jobStatusHandler } from '../StatusHandler';
|
||||
import _ from 'lodash';
|
||||
import { logger } from '../logger';
|
||||
import { createSanitizedError } from '../Error';
|
||||
|
||||
function parseObject(obj, config) {
|
||||
if (Array.isArray(obj)) {
|
||||
@@ -58,6 +59,13 @@ export class FunctionsRouter extends PromiseRouter {
|
||||
}
|
||||
|
||||
static handleCloudJob(req) {
|
||||
if (req.auth.isReadOnly) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
"read-only masterKey isn't allowed to run a job.",
|
||||
req.config
|
||||
);
|
||||
}
|
||||
const jobName = req.params.jobName || req.body?.jobName;
|
||||
const applicationId = req.config.applicationId;
|
||||
const jobHandler = jobStatusHandler(req.config);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Parse } from 'parse/node';
|
||||
import PromiseRouter from '../PromiseRouter';
|
||||
import * as middleware from '../middlewares';
|
||||
import { createSanitizedError } from '../Error';
|
||||
|
||||
export class HooksRouter extends PromiseRouter {
|
||||
createHook(aHook, config) {
|
||||
@@ -12,6 +13,13 @@ export class HooksRouter extends PromiseRouter {
|
||||
}
|
||||
|
||||
handlePost(req) {
|
||||
if (req.auth.isReadOnly) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
"read-only masterKey isn't allowed to create a hook.",
|
||||
req.config
|
||||
);
|
||||
}
|
||||
return this.createHook(req.body || {}, req.config);
|
||||
}
|
||||
|
||||
@@ -82,6 +90,13 @@ export class HooksRouter extends PromiseRouter {
|
||||
}
|
||||
|
||||
handlePut(req) {
|
||||
if (req.auth.isReadOnly) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
"read-only masterKey isn't allowed to modify a hook.",
|
||||
req.config
|
||||
);
|
||||
}
|
||||
var body = req.body || {};
|
||||
if (body.__op == 'Delete') {
|
||||
return this.handleDelete(req);
|
||||
|
||||
@@ -500,7 +500,7 @@ export class PagesRouter extends PromiseRouter {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
|
||||
// Abort if the path is outside of the path directory scope
|
||||
if (!normalizedPath.startsWith(this.pagesPath)) {
|
||||
if (!normalizedPath.startsWith(this.pagesPath + path.sep)) {
|
||||
throw errors.fileOutsideAllowedScope;
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +341,13 @@ export class UsersRouter extends ClassesRouter {
|
||||
req.config
|
||||
);
|
||||
}
|
||||
if (req.auth.isReadOnly) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
"read-only masterKey isn't allowed to login as another user.",
|
||||
req.config
|
||||
);
|
||||
}
|
||||
|
||||
const userId = req.body?.userId || req.query.userId;
|
||||
if (!userId) {
|
||||
|
||||
@@ -105,6 +105,18 @@ class CheckGroupServerConfig extends CheckGroup {
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Check({
|
||||
title: 'LiveQuery regex timeout enabled',
|
||||
warning:
|
||||
'LiveQuery regex timeout is disabled. A malicious client can subscribe with a crafted $regex pattern that causes catastrophic backtracking, blocking the Node.js event loop and making the server unresponsive.',
|
||||
solution:
|
||||
"Change Parse Server configuration to 'liveQuery.regexTimeout: 100' to set a 100ms timeout for regex evaluation in LiveQuery.",
|
||||
check: () => {
|
||||
if (config.liveQuery?.classNames?.length > 0 && config.liveQuery?.regexTimeout === 0) {
|
||||
throw 1;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -344,16 +344,25 @@ class Utils {
|
||||
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(b).test(a)) || a === b;
|
||||
const isKeyMatch = k => isMatch(k, key);
|
||||
const isValueMatch = v => isMatch(v, value);
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (key !== undefined && value === undefined && isKeyMatch(k)) {
|
||||
return true;
|
||||
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
|
||||
return true;
|
||||
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
|
||||
return true;
|
||||
const stack = [obj];
|
||||
const seen = new WeakSet();
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
|
||||
return Utils.objectContainsKeyValue(v, key, value);
|
||||
seen.add(current);
|
||||
for (const [k, v] of Object.entries(current)) {
|
||||
if (key !== undefined && value === undefined && isKeyMatch(k)) {
|
||||
return true;
|
||||
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
|
||||
return true;
|
||||
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
|
||||
return true;
|
||||
}
|
||||
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
|
||||
stack.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
+18
-8
@@ -20,18 +20,28 @@ export const Types = {
|
||||
|
||||
const ConnectClassName = '@Connect';
|
||||
|
||||
/**
|
||||
* Creates a prototype-free object for use as a lookup store.
|
||||
* This prevents prototype chain properties (e.g. `constructor`, `toString`)
|
||||
* from being resolved as registered handlers when using bracket notation
|
||||
* for lookups. Always use this instead of `{}` for handler stores.
|
||||
*/
|
||||
function createStore() {
|
||||
return Object.create(null);
|
||||
}
|
||||
|
||||
const baseStore = function () {
|
||||
const Validators = Object.keys(Types).reduce(function (base, key) {
|
||||
base[key] = {};
|
||||
base[key] = createStore();
|
||||
return base;
|
||||
}, {});
|
||||
const Functions = {};
|
||||
const Jobs = {};
|
||||
}, createStore());
|
||||
const Functions = createStore();
|
||||
const Jobs = createStore();
|
||||
const LiveQuery = [];
|
||||
const Triggers = Object.keys(Types).reduce(function (base, key) {
|
||||
base[key] = {};
|
||||
base[key] = createStore();
|
||||
return base;
|
||||
}, {});
|
||||
}, createStore());
|
||||
|
||||
return Object.freeze({
|
||||
Functions,
|
||||
@@ -90,7 +100,7 @@ function getStore(category, name, applicationId) {
|
||||
const invalidNameRegex = /['"`]/;
|
||||
if (invalidNameRegex.test(name)) {
|
||||
// Prevent a malicious user from injecting properties into the store
|
||||
return {};
|
||||
return createStore();
|
||||
}
|
||||
|
||||
const path = name.split('.');
|
||||
@@ -101,7 +111,7 @@ function getStore(category, name, applicationId) {
|
||||
for (const component of path) {
|
||||
store = store[component];
|
||||
if (!store) {
|
||||
return {};
|
||||
return createStore();
|
||||
}
|
||||
}
|
||||
return store;
|
||||
|
||||
Reference in New Issue
Block a user