Compare commits

..
16 Commits
Author SHA1 Message Date
semantic-release-bot 38360c53ba chore(release): 8.6.29 [skip ci]
## [8.6.29](https://github.com/parse-community/parse-server/compare/8.6.28...8.6.29) (2026-03-09)

### Bug Fixes

* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10163](https://github.com/parse-community/parse-server/issues/10163)) ([c92022f](https://github.com/parse-community/parse-server/commit/c92022f1ff12b119d7a6a807426925ce7d52e5ab))
2026-03-09 21:31:36 +00:00
Manuel c92022f1ff fix: SQL injection via Increment operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) (#10163) 2026-03-09 21:30:40 +00:00
semantic-release-bot 0564c8438a chore(release): 8.6.28 [skip ci]
## [8.6.28](https://github.com/parse-community/parse-server/compare/8.6.27...8.6.28) (2026-03-09)

### Bug Fixes

* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10160](https://github.com/parse-community/parse-server/issues/10160)) ([83f38fa](https://github.com/parse-community/parse-server/commit/83f38faab25d89e7bbe7c5c2087c5ee616479975))
2026-03-09 20:29:40 +00:00
Manuel 83f38faab2 fix: SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) (#10160) 2026-03-09 20:28:21 +00:00
semantic-release-bot 7edb8fcb67 chore(release): 8.6.27 [skip ci]
## [8.6.27](https://github.com/parse-community/parse-server/compare/8.6.26...8.6.27) (2026-03-09)

### Bug Fixes

*  LiveQuery `regexTimeout` default value not applied ([#10157](https://github.com/parse-community/parse-server/issues/10157)) ([94c4f52](https://github.com/parse-community/parse-server/commit/94c4f523e02be6d82f006cbacb18bec5a1de7f2e))
2026-03-09 18:09:36 +00:00
Manuel 94c4f523e0 fix: LiveQuery regexTimeout default value not applied (#10157) 2026-03-09 18:07:12 +00:00
semantic-release-bot 01fad124d4 chore(release): 8.6.26 [skip ci]
## [8.6.26](https://github.com/parse-community/parse-server/compare/8.6.25...8.6.26) (2026-03-09)

### Bug Fixes

* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10153](https://github.com/parse-community/parse-server/issues/10153)) ([2370611](https://github.com/parse-community/parse-server/commit/23706117220a7489558683f72e8fdc0983cf8dfc))
2026-03-09 14:05:09 +00:00
Manuel 2370611722 fix: LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) (#10153) 2026-03-09 14:04:08 +00:00
semantic-release-bot 9e7a19f7ad chore(release): 8.6.25 [skip ci]
## [8.6.25](https://github.com/parse-community/parse-server/compare/8.6.24...8.6.25) (2026-03-09)

### Bug Fixes

* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10152](https://github.com/parse-community/parse-server/issues/10152)) ([94aa653](https://github.com/parse-community/parse-server/commit/94aa65318c6a4b8d7b8f0b98c96d52e36d33dc9a))
2026-03-09 03:42:25 +00:00
Manuel 94aa65318c fix: Classes _GraphQLConfig and _Audience master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) (#10152) 2026-03-09 03:41:25 +00:00
semantic-release-bot efd0ac639b chore(release): 8.6.24 [skip ci]
## [8.6.24](https://github.com/parse-community/parse-server/compare/8.6.23...8.6.24) (2026-03-09)

### Bug Fixes

* Concurrent signup with same authentication creates duplicate users ([#10150](https://github.com/parse-community/parse-server/issues/10150)) ([fac8f33](https://github.com/parse-community/parse-server/commit/fac8f338ecdfeffcaed21d7e0729e2cf1ea9947d))
2026-03-09 02:06:54 +00:00
Manuel fac8f338ec fix: Concurrent signup with same authentication creates duplicate users (#10150) 2026-03-09 02:05:49 +00:00
semantic-release-bot f2058fb5e3 chore(release): 8.6.23 [skip ci]
## [8.6.23](https://github.com/parse-community/parse-server/compare/8.6.22...8.6.23) (2026-03-08)

### Bug Fixes

* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10148](https://github.com/parse-community/parse-server/issues/10148)) ([48b94ae](https://github.com/parse-community/parse-server/commit/48b94aed12006b5f1e501c0de8284a9541e60b1b))
2026-03-08 20:31:41 +00:00
Manuel 48b94aed12 fix: Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) (#10148) 2026-03-08 20:30:42 +00:00
semantic-release-bot fa24c2c5f8 chore(release): 8.6.22 [skip ci]
## [8.6.22](https://github.com/parse-community/parse-server/compare/8.6.21...8.6.22) (2026-03-08)

### Bug Fixes

* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10146](https://github.com/parse-community/parse-server/issues/10146)) ([238110b](https://github.com/parse-community/parse-server/commit/238110b5f63f47d9ef128bbd5d37795a85f31891))
2026-03-08 18:29:09 +00:00
Manuel 238110b5f6 fix: Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) (#10146) 2026-03-08 18:27:49 +00:00
28 changed files with 1553 additions and 50 deletions
+56
View File
@@ -1,3 +1,59 @@
## [8.6.29](https://github.com/parse-community/parse-server/compare/8.6.28...8.6.29) (2026-03-09)
### Bug Fixes
* SQL injection via `Increment` operation on nested object field in PostgreSQL ([GHSA-q3vj-96h2-gwvg](https://github.com/parse-community/parse-server/security/advisories/GHSA-q3vj-96h2-gwvg)) ([#10163](https://github.com/parse-community/parse-server/issues/10163)) ([c92022f](https://github.com/parse-community/parse-server/commit/c92022f1ff12b119d7a6a807426925ce7d52e5ab))
## [8.6.28](https://github.com/parse-community/parse-server/compare/8.6.27...8.6.28) (2026-03-09)
### Bug Fixes
* SQL injection via dot-notation field name in PostgreSQL ([GHSA-qpr4-jrj4-6f27](https://github.com/parse-community/parse-server/security/advisories/GHSA-qpr4-jrj4-6f27)) ([#10160](https://github.com/parse-community/parse-server/issues/10160)) ([83f38fa](https://github.com/parse-community/parse-server/commit/83f38faab25d89e7bbe7c5c2087c5ee616479975))
## [8.6.27](https://github.com/parse-community/parse-server/compare/8.6.26...8.6.27) (2026-03-09)
### Bug Fixes
* LiveQuery `regexTimeout` default value not applied ([#10157](https://github.com/parse-community/parse-server/issues/10157)) ([94c4f52](https://github.com/parse-community/parse-server/commit/94c4f523e02be6d82f006cbacb18bec5a1de7f2e))
## [8.6.26](https://github.com/parse-community/parse-server/compare/8.6.25...8.6.26) (2026-03-09)
### Bug Fixes
* LDAP injection via unsanitized user input in DN and group filter construction ([GHSA-7m6r-fhh7-r47c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7m6r-fhh7-r47c)) ([#10153](https://github.com/parse-community/parse-server/issues/10153)) ([2370611](https://github.com/parse-community/parse-server/commit/23706117220a7489558683f72e8fdc0983cf8dfc))
## [8.6.25](https://github.com/parse-community/parse-server/compare/8.6.24...8.6.25) (2026-03-09)
### Bug Fixes
* Classes `_GraphQLConfig` and `_Audience` master key bypass via generic class routes ([GHSA-7xg7-rqf6-pw6c](https://github.com/parse-community/parse-server/security/advisories/GHSA-7xg7-rqf6-pw6c)) ([#10152](https://github.com/parse-community/parse-server/issues/10152)) ([94aa653](https://github.com/parse-community/parse-server/commit/94aa65318c6a4b8d7b8f0b98c96d52e36d33dc9a))
## [8.6.24](https://github.com/parse-community/parse-server/compare/8.6.23...8.6.24) (2026-03-09)
### Bug Fixes
* Concurrent signup with same authentication creates duplicate users ([#10150](https://github.com/parse-community/parse-server/issues/10150)) ([fac8f33](https://github.com/parse-community/parse-server/commit/fac8f338ecdfeffcaed21d7e0729e2cf1ea9947d))
## [8.6.23](https://github.com/parse-community/parse-server/compare/8.6.22...8.6.23) (2026-03-08)
### Bug Fixes
* Rate limit bypass via batch request endpoint ([GHSA-775h-3xrc-c228](https://github.com/parse-community/parse-server/security/advisories/GHSA-775h-3xrc-c228)) ([#10148](https://github.com/parse-community/parse-server/issues/10148)) ([48b94ae](https://github.com/parse-community/parse-server/commit/48b94aed12006b5f1e501c0de8284a9541e60b1b))
## [8.6.22](https://github.com/parse-community/parse-server/compare/8.6.21...8.6.22) (2026-03-08)
### Bug Fixes
* Parse Server OAuth2 authentication adapter account takeover via identity spoofing ([GHSA-fr88-w35c-r596](https://github.com/parse-community/parse-server/security/advisories/GHSA-fr88-w35c-r596)) ([#10146](https://github.com/parse-community/parse-server/issues/10146)) ([238110b](https://github.com/parse-community/parse-server/commit/238110b5f63f47d9ef128bbd5d37795a85f31891))
## [8.6.21](https://github.com/parse-community/parse-server/compare/8.6.20...8.6.21) (2026-03-08)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "parse-server",
"version": "8.6.21",
"version": "8.6.29",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
"version": "8.6.21",
"version": "8.6.29",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "8.6.21",
"version": "8.6.29",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
+107
View File
@@ -128,6 +128,62 @@ describe('OAuth2Adapter', () => {
adapter.validateAuthData(authData, null, validOptions)
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
});
it('should default useridField to sub and reject mismatched user ID', async () => {
const adapterNoUseridField = new OAuth2Adapter.constructor();
adapterNoUseridField.validateOptions({
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
});
const authData = { id: 'victim-user-id', access_token: 'attackerToken' };
const mockResponse = {
active: true,
sub: 'attacker-user-id',
};
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapterNoUseridField.validateAuthData(authData, null, {})
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
});
it('should default useridField to sub and accept matching user ID', async () => {
const adapterNoUseridField = new OAuth2Adapter.constructor();
adapterNoUseridField.validateOptions({
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
});
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = {
active: true,
sub: 'user-id',
};
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapterNoUseridField.validateAuthData(authData, null, {})
).toBeResolvedTo({});
});
});
describe('requestTokenInfo', () => {
@@ -281,6 +337,57 @@ describe('OAuth2Adapter', () => {
);
});
it('should reject account takeover when useridField is omitted and attacker uses their own token with victim ID', async () => {
await reconfigureServer({
auth: {
mockOauth: {
tokenIntrospectionEndpointUrl: 'https://provider.example.com/introspect',
authorizationHeader: 'Bearer validAuthToken',
oauth2: true,
},
},
});
// Victim signs up with their own valid token
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'victim-sub-id',
}),
},
},
]);
const victimAuthData = { access_token: 'victimToken', id: 'victim-sub-id' };
const victim = await Parse.User.logInWith('mockOauth', { authData: victimAuthData });
expect(victim.id).toBeDefined();
// Attacker tries to log in with their own valid token but claims victim's ID
mockFetch([
{
url: 'https://provider.example.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'attacker-sub-id',
}),
},
},
]);
const attackerAuthData = { access_token: 'attackerToken', id: 'victim-sub-id' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData: attackerAuthData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
);
});
it('should handle error when token introspection endpoint is missing', async () => {
await reconfigureServer({
auth: {
+12 -11
View File
@@ -36,9 +36,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -78,9 +78,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -119,9 +119,9 @@ describe('AudiencesRouter', () => {
Config.get('test');
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -159,8 +159,8 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.then(() => rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest))
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => rest.create(config, auth.master(config), '_Audience', iosAudienceRequest))
.then(() => router.handleFind(request))
.then(res => {
const response = res.response;
@@ -197,9 +197,9 @@ describe('AudiencesRouter', () => {
const router = new AudiencesRouter();
rest
.create(config, auth.nobody(config), '_Audience', androidAudienceRequest)
.create(config, auth.master(config), '_Audience', androidAudienceRequest)
.then(() => {
return rest.create(config, auth.nobody(config), '_Audience', iosAudienceRequest);
return rest.create(config, auth.master(config), '_Audience', iosAudienceRequest);
})
.then(() => {
return router.handleFind(request);
@@ -421,6 +421,7 @@ describe('AudiencesRouter', () => {
await reconfigureServer({
appId: 'test',
restAPIKey: 'test',
masterKey: 'test',
publicServerURL: 'http://localhost:8378/1',
});
try {
@@ -430,7 +431,7 @@ describe('AudiencesRouter', () => {
body: { lorem: 'ipsum', _method: 'POST' },
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'test',
'X-Parse-Master-Key': 'test',
'Content-Type': 'application/json',
},
});
+210
View File
@@ -0,0 +1,210 @@
'use strict';
const request = require('../lib/request');
const Config = require('../lib/Config');
describe('AuthData Unique Index', () => {
const fakeAuthProvider = {
validateAppId: () => Promise.resolve(),
validateAuthData: () => Promise.resolve(),
};
beforeEach(async () => {
await reconfigureServer({ auth: { fakeAuthProvider } });
});
it('should prevent concurrent signups with the same authData from creating duplicate users', async () => {
const authData = { fakeAuthProvider: { id: 'duplicate-test-id', token: 'token1' } };
// Fire multiple concurrent signup requests with the same authData
const concurrentRequests = Array.from({ length: 5 }, () =>
request({
method: 'POST',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
url: 'http://localhost:8378/1/users',
body: { authData },
}).then(
response => ({ success: true, data: response.data }),
error => ({ success: false, error: error.data || error.message })
)
);
const results = await Promise.all(concurrentRequests);
const successes = results.filter(r => r.success);
const failures = results.filter(r => !r.success);
// All should either succeed (returning the same user) or fail with "this auth is already used"
// The key invariant: only ONE unique objectId should exist
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
expect(uniqueObjectIds.size).toBe(1);
// Failures should be "this auth is already used" errors
for (const failure of failures) {
expect(failure.error.code).toBe(208);
expect(failure.error.error).toBe('this auth is already used');
}
// Verify only one user exists in the database with this authData
const query = new Parse.Query('_User');
query.equalTo('authData.fakeAuthProvider.id', 'duplicate-test-id');
const users = await query.find({ useMasterKey: true });
expect(users.length).toBe(1);
});
it('should prevent concurrent signups via batch endpoint with same authData', async () => {
const authData = { fakeAuthProvider: { id: 'batch-race-test-id', token: 'token1' } };
const response = await request({
method: 'POST',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
url: 'http://localhost:8378/1/batch',
body: {
requests: Array.from({ length: 3 }, () => ({
method: 'POST',
path: '/1/users',
body: { authData },
})),
},
});
const results = response.data;
const successes = results.filter(r => r.success);
const failures = results.filter(r => r.error);
// All successes should reference the same user
const uniqueObjectIds = new Set(successes.map(r => r.success.objectId));
expect(uniqueObjectIds.size).toBe(1);
// Failures should be "this auth is already used" errors
for (const failure of failures) {
expect(failure.error.code).toBe(208);
expect(failure.error.error).toBe('this auth is already used');
}
// Verify only one user exists in the database with this authData
const query = new Parse.Query('_User');
query.equalTo('authData.fakeAuthProvider.id', 'batch-race-test-id');
const users = await query.find({ useMasterKey: true });
expect(users.length).toBe(1);
});
it('should allow sequential signups with different authData IDs', async () => {
const user1 = await Parse.User.logInWith('fakeAuthProvider', {
authData: { id: 'user-id-1', token: 'token1' },
});
const user2 = await Parse.User.logInWith('fakeAuthProvider', {
authData: { id: 'user-id-2', token: 'token2' },
});
expect(user1.id).toBeDefined();
expect(user2.id).toBeDefined();
expect(user1.id).not.toBe(user2.id);
});
it('should still allow login with authData after successful signup', async () => {
const authPayload = { authData: { id: 'login-test-id', token: 'token1' } };
// Signup
const user1 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
expect(user1.id).toBeDefined();
// Login again with same authData — should return same user
const user2 = await Parse.User.logInWith('fakeAuthProvider', authPayload);
expect(user2.id).toBe(user1.id);
});
it('should skip startup index creation when createIndexAuthDataUniqueness is false', async () => {
const config = Config.get('test');
const adapter = config.database.adapter;
const spy = spyOn(adapter, 'ensureAuthDataUniqueness').and.callThrough();
// Temporarily set the option to false
const originalOptions = config.database.options.databaseOptions;
config.database.options.databaseOptions = { createIndexAuthDataUniqueness: false };
await config.database.performInitialization();
expect(spy).not.toHaveBeenCalled();
// Restore original options
config.database.options.databaseOptions = originalOptions;
});
it('should handle calling ensureAuthDataUniqueness multiple times (idempotent)', async () => {
const config = Config.get('test');
const adapter = config.database.adapter;
// Both calls should succeed (index creation is idempotent)
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
await adapter.ensureAuthDataUniqueness('fakeAuthProvider');
});
it('should log warning when index creation fails due to existing duplicates', async () => {
const config = Config.get('test');
const adapter = config.database.adapter;
// Spy on the adapter to simulate a duplicate value error
spyOn(adapter, 'ensureAuthDataUniqueness').and.callFake(() => {
return Promise.reject(
new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'duplicates exist')
);
});
const logSpy = spyOn(require('../lib/logger').logger, 'warn');
// Re-run performInitialization — should warn but not throw
await config.database.performInitialization();
expect(logSpy).toHaveBeenCalledWith(
jasmine.stringContaining('Unable to ensure uniqueness for auth data provider'),
jasmine.anything()
);
});
it('should prevent concurrent signups with same anonymous authData', async () => {
const anonymousId = 'anon-race-test-id';
const authData = { anonymous: { id: anonymousId } };
const concurrentRequests = Array.from({ length: 5 }, () =>
request({
method: 'POST',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
url: 'http://localhost:8378/1/users',
body: { authData },
}).then(
response => ({ success: true, data: response.data }),
error => ({ success: false, error: error.data || error.message })
)
);
const results = await Promise.all(concurrentRequests);
const successes = results.filter(r => r.success);
const failures = results.filter(r => !r.success);
// All successes should reference the same user
const uniqueObjectIds = new Set(successes.map(r => r.data.objectId));
expect(uniqueObjectIds.size).toBe(1);
// Failures should be "this auth is already used" errors
for (const failure of failures) {
expect(failure.error.code).toBe(208);
expect(failure.error.error).toBe('this auth is already used');
}
// Verify only one user exists in the database with this authData
const query = new Parse.Query('_User');
query.equalTo('authData.anonymous.id', anonymousId);
const users = await query.find({ useMasterKey: true });
expect(users.length).toBe(1);
});
});
+10
View File
@@ -415,6 +415,11 @@ describe('DatabaseController', function () {
email_1: { email: 1 },
_email_verify_token: { _email_verify_token: 1 },
_perishable_token: { _perishable_token: 1 },
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
});
}
);
@@ -441,6 +446,11 @@ describe('DatabaseController', function () {
email_1: { email: 1 },
_email_verify_token: { _email_verify_token: 1 },
_perishable_token: { _perishable_token: 1 },
_auth_data_custom_id: { '_auth_data_custom.id': 1 },
_auth_data_facebook_id: { '_auth_data_facebook.id': 1 },
_auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 },
_auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 },
_auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 },
});
}
);
+172
View File
@@ -4,6 +4,178 @@ const fs = require('fs');
const port = 12345;
const sslport = 12346;
describe('LDAP Injection Prevention', () => {
describe('escapeDN', () => {
it('should escape comma', () => {
expect(ldap.escapeDN('admin,ou=evil')).toBe('admin\\,ou\\=evil');
});
it('should escape equals sign', () => {
expect(ldap.escapeDN('admin=evil')).toBe('admin\\=evil');
});
it('should escape plus sign', () => {
expect(ldap.escapeDN('admin+evil')).toBe('admin\\+evil');
});
it('should escape less-than and greater-than signs', () => {
expect(ldap.escapeDN('admin<evil>')).toBe('admin\\<evil\\>');
});
it('should escape hash at start', () => {
expect(ldap.escapeDN('#admin')).toBe('\\#admin');
});
it('should escape semicolon', () => {
expect(ldap.escapeDN('admin;evil')).toBe('admin\\;evil');
});
it('should escape double quote', () => {
expect(ldap.escapeDN('admin"evil')).toBe('admin\\"evil');
});
it('should escape backslash', () => {
expect(ldap.escapeDN('admin\\evil')).toBe('admin\\\\evil');
});
it('should escape leading space', () => {
expect(ldap.escapeDN(' admin')).toBe('\\ admin');
});
it('should escape trailing space', () => {
expect(ldap.escapeDN('admin ')).toBe('admin\\ ');
});
it('should escape multiple special characters', () => {
expect(ldap.escapeDN('admin,ou=evil+cn=x')).toBe('admin\\,ou\\=evil\\+cn\\=x');
});
it('should not modify safe values', () => {
expect(ldap.escapeDN('testuser')).toBe('testuser');
expect(ldap.escapeDN('john.doe')).toBe('john.doe');
expect(ldap.escapeDN('user123')).toBe('user123');
});
});
describe('escapeFilter', () => {
it('should escape asterisk', () => {
expect(ldap.escapeFilter('*')).toBe('\\2a');
});
it('should escape open parenthesis', () => {
expect(ldap.escapeFilter('test(')).toBe('test\\28');
});
it('should escape close parenthesis', () => {
expect(ldap.escapeFilter('test)')).toBe('test\\29');
});
it('should escape backslash', () => {
expect(ldap.escapeFilter('test\\')).toBe('test\\5c');
});
it('should escape null byte', () => {
expect(ldap.escapeFilter('test\x00')).toBe('test\\00');
});
it('should escape multiple special characters', () => {
expect(ldap.escapeFilter('*()\\')).toBe('\\2a\\28\\29\\5c');
});
it('should not modify safe values', () => {
expect(ldap.escapeFilter('testuser')).toBe('testuser');
expect(ldap.escapeFilter('john.doe')).toBe('john.doe');
expect(ldap.escapeFilter('user123')).toBe('user123');
});
it('should escape filter injection attempt with wildcard', () => {
expect(ldap.escapeFilter('x)(|(objectClass=*)')).toBe('x\\29\\28|\\28objectClass=\\2a\\29');
});
});
describe('authData validation', () => {
it('should reject missing authData.id', async done => {
const server = await mockLdapServer(port, 'uid=testuser, o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
};
try {
await ldap.validateAuthData({ password: 'secret' }, options);
fail('Should have rejected missing id');
} catch (err) {
expect(err.message).toBe('LDAP: Wrong username or password');
}
server.close(done);
});
it('should reject non-string authData.id', async done => {
const server = await mockLdapServer(port, 'uid=testuser, o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
};
try {
await ldap.validateAuthData({ id: 123, password: 'secret' }, options);
fail('Should have rejected non-string id');
} catch (err) {
expect(err.message).toBe('LDAP: Wrong username or password');
}
server.close(done);
});
});
describe('DN injection prevention', () => {
it('should prevent DN injection via comma in authData.id', async done => {
// Mock server accepts the DN that would result from an unescaped injection
const server = await mockLdapServer(port, 'uid=admin,ou=admins,o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
};
// Attacker tries to inject additional DN components via comma
// Without escaping: DN = uid=admin,ou=admins, o=example (3 RDNs) → matches mock
// With escaping: DN = uid=admin\,ou=admins, o=example (2 RDNs) → doesn't match
try {
await ldap.validateAuthData({ id: 'admin,ou=admins', password: 'secret' }, options);
fail('Should have rejected DN injection attempt');
} catch (err) {
expect(err.message).toBe('LDAP: Wrong username or password');
}
server.close(done);
});
});
describe('Filter injection prevention', () => {
it('should prevent LDAP filter injection via wildcard in authData.id', async done => {
// Mock server accepts uid=*, o=example (the attacker's bind DN)
// The * is not special in DNs so it binds fine regardless of escaping
const server = await mockLdapServer(port, 'uid=*, o=example');
const options = {
suffix: 'o=example',
url: `ldap://localhost:${port}`,
dn: 'uid={{id}}, o=example',
groupCn: 'powerusers',
groupFilter: '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))',
};
// Attacker uses * as ID to match any group member via wildcard
// Group has member uid=testuser, not uid=*
// Without escaping: filter uses SubstringFilter, matches testuser → passes
// With escaping: filter uses EqualityFilter with literal \2a, no match → fails
try {
await ldap.validateAuthData({ id: '*', password: 'secret' }, options);
fail('Should have rejected filter injection attempt');
} catch (err) {
expect(err.message).toBe('LDAP: User not in group');
}
server.close(done);
});
});
});
describe('Ldap Auth', () => {
it('Should fail with missing options', done => {
ldap
+57 -6
View File
@@ -337,7 +337,7 @@ describe('Parse.User testing', () => {
expect(newUser).not.toBeUndefined();
});
it('should be let masterKey lock user out with authData', async () => {
it_only_db('mongo')('should reject duplicate authData when masterKey locks user out (mongo)', async () => {
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/_User',
@@ -353,15 +353,13 @@ describe('Parse.User testing', () => {
});
const body = response.data;
const objectId = body.objectId;
const sessionToken = body.sessionToken;
expect(sessionToken).toBeDefined();
expect(body.sessionToken).toBeDefined();
expect(objectId).toBeDefined();
const user = new Parse.User();
user.id = objectId;
const ACL = new Parse.ACL();
user.setACL(ACL);
await user.save(null, { useMasterKey: true });
// update the user
const options = {
method: 'POST',
url: `http://localhost:8378/1/classes/_User/`,
@@ -377,8 +375,61 @@ describe('Parse.User testing', () => {
},
},
};
const res = await request(options);
expect(res.data.objectId).not.toEqual(objectId);
try {
await request(options);
fail('should have thrown');
} catch (err) {
expect(err.data.code).toBe(208);
expect(err.data.error).toBe('this auth is already used');
}
});
it_only_db('postgres')('should reject duplicate authData when masterKey locks user out (postgres)', async () => {
await reconfigureServer();
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/_User',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
key: 'value',
authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } },
},
});
const body = response.data;
const objectId = body.objectId;
expect(body.sessionToken).toBeDefined();
expect(objectId).toBeDefined();
const user = new Parse.User();
user.id = objectId;
const ACL = new Parse.ACL();
user.setACL(ACL);
await user.save(null, { useMasterKey: true });
const options = {
method: 'POST',
url: `http://localhost:8378/1/classes/_User/`,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: {
key: 'otherValue',
authData: {
anonymous: { id: '00000000-0000-0000-0000-000000000001' },
},
},
};
try {
await request(options);
fail('should have thrown');
} catch (err) {
expect(err.data.code).toBe(208);
expect(err.data.error).toBe('this auth is already used');
}
});
it('user login with files', done => {
+1 -1
View File
@@ -1074,7 +1074,7 @@ describe('PushController', () => {
const audience = new Parse.Object('_Audience');
audience.set('name', 'testAudience');
audience.set('query', JSON.stringify(where));
await Parse.Object.saveAll(audience);
await audience.save(null, { useMasterKey: true });
await query.find({ useMasterKey: true }).then(parseResults);
const body = {
+10
View File
@@ -543,6 +543,16 @@ describe('matchesQuery', function () {
expect(matchesQuery(player, q)).toBe(true);
});
it('applies default regexTimeout when liveQuery is configured without explicit regexTimeout', async () => {
await reconfigureServer({
liveQuery: { classNames: ['Player'] },
});
// Verify the default value is applied by checking the config
const Config = require('../lib/Config');
const config = Config.get('test');
expect(config.liveQuery.regexTimeout).toBe(100);
});
it('matches $nearSphere queries', function () {
let q = new Parse.Query('Checkin');
q.near('location', new Parse.GeoPoint(20, 20));
+220
View File
@@ -1,4 +1,12 @@
const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default;
const request = require('../lib/request');
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
describe('rate limit', () => {
it('can limit cloud functions', async () => {
Parse.Cloud.define('test', () => 'Abc');
@@ -487,6 +495,218 @@ describe('rate limit', () => {
})
).toBeRejectedWith(`Invalid rate limit option "path"`);
});
describe('batch', () => {
it('should reject batch request when sub-requests exceed rate limit for a path', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/classes/*',
requestTimeWindow: 10000,
requestCount: 2,
errorResponseMessage: 'Too many requests',
includeInternalRequests: true,
},
],
});
const response = await request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
],
}),
}).catch(e => e);
expect(response.data).toEqual({
code: Parse.Error.CONNECTION_FAILED,
error: 'Too many requests',
});
});
it('should allow batch request when sub-requests are within rate limit', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/classes/*',
requestTimeWindow: 10000,
requestCount: 5,
errorResponseMessage: 'Too many requests',
includeInternalRequests: true,
},
],
});
const response = await request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
],
}),
});
expect(response.data.length).toBe(3);
expect(response.data[0].success).toBeDefined();
});
it('should reject batch when sub-requests for one rate-limited path exceed limit among mixed paths', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/login',
requestTimeWindow: 10000,
requestCount: 1,
errorResponseMessage: 'Too many login requests',
includeInternalRequests: true,
},
],
});
await Parse.User.signUp('testuser', 'password');
const response = await request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
{ method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'password' } },
{ method: 'POST', path: '/1/login', body: { username: 'testuser', password: 'wrong' } },
],
}),
}).catch(e => e);
expect(response.data).toEqual({
code: Parse.Error.CONNECTION_FAILED,
error: 'Too many login requests',
});
});
it('should not reject batch when sub-requests target non-rate-limited paths', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/login',
requestTimeWindow: 10000,
requestCount: 1,
errorResponseMessage: 'Too many login requests',
includeInternalRequests: true,
},
],
});
const response = await request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
],
}),
});
expect(response.data.length).toBe(3);
expect(response.data[0].success).toBeDefined();
});
it('should not count sub-requests whose method does not match requestMethods', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/classes/*',
requestTimeWindow: 10000,
requestCount: 1,
requestMethods: 'GET',
errorResponseMessage: 'Too many requests',
includeInternalRequests: true,
},
],
});
// 3 POST sub-requests should NOT be counted against a GET-only rate limit
const response = await request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
],
}),
});
expect(response.data.length).toBe(3);
expect(response.data[0].success).toBeDefined();
});
it('should skip batch rate limit check for master key requests when includeMasterKey is false', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/classes/*',
requestTimeWindow: 10000,
requestCount: 1,
errorResponseMessage: 'Too many requests',
includeInternalRequests: true,
},
],
});
// Master key requests should bypass rate limit (includeMasterKey defaults to false)
const masterHeaders = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
};
const response = await request({
method: 'POST',
headers: masterHeaders,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
],
}),
});
expect(response.data.length).toBe(3);
expect(response.data[0].success).toBeDefined();
});
it('should use configured errorResponseMessage when rejecting batch', async () => {
await reconfigureServer({
rateLimit: [
{
requestPath: '/classes/*',
requestTimeWindow: 10000,
requestCount: 1,
errorResponseMessage: 'Custom rate limit message',
includeInternalRequests: true,
},
],
});
const response = await request({
method: 'POST',
headers: headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value1' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value2' } },
{ method: 'POST', path: '/1/classes/MyObject', body: { key: 'value3' } },
],
}),
}).catch(e => e);
expect(response.data).toEqual({
code: Parse.Error.CONNECTION_FAILED,
error: 'Custom rate limit message',
});
});
});
describe_only(() => {
return process.env.PARSE_SERVER_TEST_CACHE === 'redis';
})('with RedisCache', function () {
+147
View File
@@ -812,6 +812,153 @@ describe('rest create', () => {
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining("Clients aren't allowed to perform the get operation on the _GlobalConfig collection."));
});
it('should require master key for all volatile classes', () => {
// This test guards against drift between volatileClasses (SchemaController.js)
// and classesWithMasterOnlyAccess (SharedRest.js). If a new volatile class is
// added, it must also be added to classesWithMasterOnlyAccess and this test.
const volatileClasses = [
'_JobStatus',
'_PushStatus',
'_Hooks',
'_GlobalConfig',
'_GraphQLConfig',
'_JobSchedule',
'_Audience',
'_Idempotency',
];
for (const className of volatileClasses) {
expect(() =>
rest.create(config, auth.nobody(config), className, {})
).toThrowMatching(
e => e.code === Parse.Error.OPERATION_FORBIDDEN,
`Expected ${className} to require master key`
);
}
});
it('cannot find objects in _GraphQLConfig without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
await expectAsync(
rest.find(config, auth.nobody(config), '_GraphQLConfig', {})
).toBeRejectedWith(
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
);
});
it('cannot update object in _GraphQLConfig without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
expect(() =>
rest.update(config, auth.nobody(config), '_GraphQLConfig', '1', {
config: { enabledForClasses: [] },
})
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('cannot delete object in _GraphQLConfig without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
expect(() =>
rest.del(config, auth.nobody(config), '_GraphQLConfig', '1')
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('can perform operations on _GraphQLConfig with masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
const found = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
expect(found.results.length).toBeGreaterThan(0);
await rest.del(config, auth.master(config), '_GraphQLConfig', '1');
const afterDelete = await rest.find(config, auth.master(config), '_GraphQLConfig', {});
expect(afterDelete.results.length).toBe(0);
});
it('cannot create object in _Audience without masterKey', () => {
expect(() =>
rest.create(config, auth.nobody(config), '_Audience', {
name: 'test',
query: '{}',
})
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('cannot find objects in _Audience without masterKey', async () => {
await expectAsync(
rest.find(config, auth.nobody(config), '_Audience', {})
).toBeRejectedWith(
jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN })
);
});
it('cannot update object in _Audience without masterKey', async () => {
const obj = await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
expect(() =>
rest.update(config, auth.nobody(config), '_Audience', obj.response.objectId, {
name: 'updated',
})
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('cannot delete object in _Audience without masterKey', async () => {
const obj = await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
expect(() =>
rest.del(config, auth.nobody(config), '_Audience', obj.response.objectId)
).toThrowMatching(e => e.code === Parse.Error.OPERATION_FORBIDDEN);
});
it('can perform CRUD on _Audience with masterKey', async () => {
const obj = await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
expect(obj.response.objectId).toBeDefined();
const found = await rest.find(config, auth.master(config), '_Audience', {});
expect(found.results.length).toBeGreaterThan(0);
await rest.del(config, auth.master(config), '_Audience', obj.response.objectId);
const afterDelete = await rest.find(config, auth.master(config), '_Audience', {});
expect(afterDelete.results.length).toBe(0);
});
it('cannot access _GraphQLConfig via class route without masterKey', async () => {
await config.parseGraphQLController.updateGraphQLConfig({ enabledForClasses: ['_User'] });
try {
await request({
url: 'http://localhost:8378/1/classes/_GraphQLConfig',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});
it('cannot access _Audience via class route without masterKey', async () => {
await rest.create(config, auth.master(config), '_Audience', {
name: 'test',
query: '{}',
});
try {
await request({
url: 'http://localhost:8378/1/classes/_Audience',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
});
fail('should have thrown');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});
it('locks down session', done => {
let currentUser;
Parse.User.signUp('foo', 'bar')
+249
View File
@@ -817,3 +817,252 @@ describe('Postgres regex sanitizater', () => {
expect(response.data.results.length).toBe(0);
});
});
describe('(GHSA-qpr4-jrj4-6f27) SQL Injection via sort dot-notation field name', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
it_only_db('postgres')('does not execute injected SQL via sort order dot-notation', async () => {
const obj = new Parse.Object('InjectionTest');
obj.set('data', { key: 'value' });
obj.set('name', 'original');
await obj.save();
// This payload would execute a stacked query if single quotes are not escaped
await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--",
},
}).catch(() => {});
// Verify the data was not modified by injected SQL
const verify = await new Parse.Query('InjectionTest').get(obj.id);
expect(verify.get('name')).toBe('original');
});
it_only_db('postgres')('does not execute injected SQL via sort order with pg_sleep', async () => {
const obj = new Parse.Object('InjectionTest');
obj.set('data', { key: 'value' });
await obj.save();
const start = Date.now();
await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: "data.x' ASC; SELECT pg_sleep(3)--",
},
}).catch(() => {});
const elapsed = Date.now() - start;
// If injection succeeded, query would take >= 3 seconds
expect(elapsed).toBeLessThan(3000);
});
it_only_db('postgres')('does not execute injection via dollar-sign quoting bypass', async () => {
// PostgreSQL supports $$string$$ as alternative to 'string'
const obj = new Parse.Object('InjectionTest');
obj.set('data', { key: 'value' });
obj.set('name', 'original');
await obj.save();
await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $$hacked$$ WHERE true--",
},
}).catch(() => {});
const verify = await new Parse.Query('InjectionTest').get(obj.id);
expect(verify.get('name')).toBe('original');
});
it_only_db('postgres')('does not execute injection via tagged dollar quoting bypass', async () => {
// PostgreSQL supports $tag$string$tag$ as alternative to 'string'
const obj = new Parse.Object('InjectionTest');
obj.set('data', { key: 'value' });
obj.set('name', 'original');
await obj.save();
await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = $t$hacked$t$ WHERE true--",
},
}).catch(() => {});
const verify = await new Parse.Query('InjectionTest').get(obj.id);
expect(verify.get('name')).toBe('original');
});
it_only_db('postgres')('does not execute injection via CHR() concatenation bypass', async () => {
// CHR(104)||CHR(97)||... builds 'hacked' without quotes
const obj = new Parse.Object('InjectionTest');
obj.set('data', { key: 'value' });
obj.set('name', 'original');
await obj.save();
await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: "data.x' ASC; UPDATE \"InjectionTest\" SET name = CHR(104)||CHR(97)||CHR(99)||CHR(107) WHERE true--",
},
}).catch(() => {});
const verify = await new Parse.Query('InjectionTest').get(obj.id);
expect(verify.get('name')).toBe('original');
});
it_only_db('postgres')('does not execute injection via backslash escape bypass', async () => {
// Backslash before quote could interact with '' escaping in some configurations
const obj = new Parse.Object('InjectionTest');
obj.set('data', { key: 'value' });
obj.set('name', 'original');
await obj.save();
await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: "data.x\\' ASC; UPDATE \"InjectionTest\" SET name = 'hacked' WHERE true--",
},
}).catch(() => {});
const verify = await new Parse.Query('InjectionTest').get(obj.id);
expect(verify.get('name')).toBe('original');
});
it('allows valid dot-notation sort on object field', async () => {
const obj = new Parse.Object('InjectionTest');
obj.set('data', { key: 'value' });
await obj.save();
const response = await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: 'data.key',
},
});
expect(response.status).toBe(200);
});
it('allows valid dot-notation with special characters in sub-field', async () => {
const obj = new Parse.Object('InjectionTest');
obj.set('data', { 'my-field': 'value' });
await obj.save();
const response = await request({
method: 'GET',
url: 'http://localhost:8378/1/classes/InjectionTest',
headers,
qs: {
order: 'data.my-field',
},
});
expect(response.status).toBe(200);
});
});
describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
it('rejects non-number Increment amount on nested object field', async () => {
const obj = new Parse.Object('IncrTest');
obj.set('stats', { counter: 0 });
await obj.save();
const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
headers,
body: JSON.stringify({
'stats.counter': { __op: 'Increment', amount: '1' },
}),
}).catch(e => e);
expect(response.status).toBe(400);
const text = JSON.parse(response.text);
expect(text.code).toBe(Parse.Error.INVALID_JSON);
});
it_only_db('postgres')('does not execute injected SQL via Increment amount with pg_sleep', async () => {
const obj = new Parse.Object('IncrTest');
obj.set('stats', { counter: 0 });
await obj.save();
const start = Date.now();
await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
headers,
body: JSON.stringify({
'stats.counter': { __op: 'Increment', amount: '0+(SELECT 1 FROM pg_sleep(3))' },
}),
}).catch(() => {});
const elapsed = Date.now() - start;
// If injection succeeded, query would take >= 3 seconds
expect(elapsed).toBeLessThan(3000);
});
it_only_db('postgres')('does not execute injected SQL via Increment amount for data exfiltration', async () => {
const obj = new Parse.Object('IncrTest');
obj.set('stats', { counter: 0 });
await obj.save();
await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
headers,
body: JSON.stringify({
'stats.counter': {
__op: 'Increment',
amount: '0+(SELECT ascii(substr(current_database(),1,1)))',
},
}),
}).catch(() => {});
// Verify counter was not modified by injected SQL
const verify = await new Parse.Query('IncrTest').get(obj.id);
expect(verify.get('stats').counter).toBe(0);
});
it('allows valid numeric Increment on nested object field', async () => {
const obj = new Parse.Object('IncrTest');
obj.set('stats', { counter: 5 });
await obj.save();
const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/IncrTest/${obj.id}`,
headers,
body: JSON.stringify({
'stats.counter': { __op: 'Increment', amount: 3 },
}),
});
expect(response.status).toBe(200);
const verify = await new Parse.Query('IncrTest').get(obj.id);
expect(verify.get('stats').counter).toBe(8);
});
});
+42 -3
View File
@@ -76,6 +76,37 @@
const ldapjs = require('ldapjs');
const Parse = require('parse/node').Parse;
// Escape LDAP DN special characters per RFC 4514
// https://datatracker.ietf.org/doc/html/rfc4514#section-2.4
function escapeDN(value) {
let escaped = value
.replace(/\\/g, '\\\\')
.replace(/,/g, '\\,')
.replace(/=/g, '\\=')
.replace(/\+/g, '\\+')
.replace(/</g, '\\<')
.replace(/>/g, '\\>')
.replace(/#/g, '\\#')
.replace(/;/g, '\\;')
.replace(/"/g, '\\"');
if (escaped.startsWith(' ')) {
escaped = '\\ ' + escaped.slice(1);
}
if (escaped.endsWith(' ')) {
escaped = escaped.slice(0, -1) + '\\ ';
}
return escaped;
}
// Escape LDAP filter special characters per RFC 4515
// https://datatracker.ietf.org/doc/html/rfc4515#section-3
function escapeFilter(value) {
// eslint-disable-next-line no-control-regex
return value.replace(/[\\*()\x00]/g, ch =>
'\\' + ch.charCodeAt(0).toString(16).padStart(2, '0')
);
}
function validateAuthData(authData, options) {
if (!optionsAreValid(options)) {
return new Promise((_, reject) => {
@@ -86,11 +117,17 @@ function validateAuthData(authData, options) {
? { url: options.url, tlsOptions: options.tlsOptions }
: { url: options.url };
if (typeof authData.id !== 'string') {
return Promise.reject(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LDAP: Wrong username or password')
);
}
const client = ldapjs.createClient(clientOptions);
const escapedId = escapeDN(authData.id);
const userCn =
typeof options.dn === 'string'
? options.dn.replace('{{id}}', authData.id)
: `uid=${authData.id},${options.suffix}`;
? options.dn.replace('{{id}}', escapedId)
: `uid=${escapedId},${options.suffix}`;
return new Promise((resolve, reject) => {
client.bind(userCn, authData.password, ldapError => {
@@ -140,7 +177,7 @@ function optionsAreValid(options) {
}
function searchForGroup(client, options, id, resolve, reject) {
const filter = options.groupFilter.replace(/{{id}}/gi, id);
const filter = options.groupFilter.replace(/{{id}}/gi, escapeFilter(id));
const opts = {
scope: 'sub',
filter: filter,
@@ -184,4 +221,6 @@ function validateAppId() {
module.exports = {
validateAppId,
validateAuthData,
escapeDN,
escapeFilter,
};
+2 -2
View File
@@ -5,7 +5,7 @@
* @param {Object} options - The adapter configuration options.
* @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required.
* @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required.
* @param {string} [options.useridField] - The field in the introspection response that contains the user ID. Optional.
* @param {string} [options.useridField='sub'] - The field in the introspection response that contains the user ID. Defaults to `sub` per RFC 7662.
* @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional.
* @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined.
* @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional.
@@ -66,7 +66,7 @@ class OAuth2Adapter extends AuthAdapter {
}
this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl;
this.useridField = options.useridField;
this.useridField = options.useridField || 'sub';
this.appidField = options.appidField;
this.appIds = options.appIds;
this.authorizationHeader = options.authorizationHeader;
@@ -530,6 +530,13 @@ export class MongoStorageAdapter implements StorageAdapter {
if (matches && Array.isArray(matches)) {
err.userInfo = { duplicated_field: matches[1] };
}
// Check for authData unique index violations
if (!err.userInfo) {
const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/);
if (authDataMatch) {
err.userInfo = { duplicated_field: authDataMatch[1] };
}
}
}
throw err;
}
@@ -605,10 +612,27 @@ export class MongoStorageAdapter implements StorageAdapter {
.then(result => mongoObjectToParseObject(className, result, schema))
.catch(error => {
if (error.code === 11000) {
throw new Parse.Error(
logger.error('Duplicate key error:', error.message);
const err = new Parse.Error(
Parse.Error.DUPLICATE_VALUE,
'A duplicate value for a field with unique values was provided'
);
err.underlyingError = error;
if (error.message) {
const matches = error.message.match(
/index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/
);
if (matches && Array.isArray(matches)) {
err.userInfo = { duplicated_field: matches[1] };
}
if (!err.userInfo) {
const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/);
if (authDataMatch) {
err.userInfo = { duplicated_field: authDataMatch[1] };
}
}
}
throw err;
}
throw error;
})
@@ -764,6 +788,32 @@ export class MongoStorageAdapter implements StorageAdapter {
.catch(err => this.handleError(err));
}
// Creates a unique sparse index on _auth_data_<provider>.id to prevent
// race conditions during concurrent signups with the same authData.
ensureAuthDataUniqueness(provider: string) {
return this._adaptiveCollection('_User')
.then(collection =>
collection._mongoCollection.createIndex(
{ [`_auth_data_${provider}.id`]: 1 },
{ unique: true, sparse: true, background: true, name: `_auth_data_${provider}_id` }
)
)
.catch(error => {
if (error.code === 11000) {
throw new Parse.Error(
Parse.Error.DUPLICATE_VALUE,
'Tried to ensure field uniqueness for a class that already has duplicates.'
);
}
// Ignore "index already exists with same name" or "index already exists with different options"
if (error.code === 85 || error.code === 86) {
return;
}
throw error;
})
.catch(err => this.handleError(err));
}
// Used in tests
_rawFind(className: string, query: QueryType) {
return this._adaptiveCollection(className)
@@ -211,12 +211,12 @@ const handleDotFields = object => {
const transformDotFieldToComponents = fieldName => {
return fieldName.split('.').map((cmpt, index) => {
if (index === 0) {
return `"${cmpt}"`;
return `"${cmpt.replace(/"/g, '""')}"`;
}
if (isArrayIndex(cmpt)) {
return Number(cmpt);
} else {
return `'${cmpt}'`;
return `'${cmpt.replace(/'/g, "''")}'`;
}
});
};
@@ -1479,9 +1479,15 @@ export class PostgresStorageAdapter implements StorageAdapter {
);
err.underlyingError = error;
if (error.constraint) {
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
if (matches && Array.isArray(matches)) {
err.userInfo = { duplicated_field: matches[1] };
// Check for authData unique index violations first
const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/);
if (authDataMatch) {
err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` };
} else {
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
if (matches && Array.isArray(matches)) {
err.userInfo = { duplicated_field: matches[1] };
}
}
}
error = err;
@@ -1721,13 +1727,19 @@ export class PostgresStorageAdapter implements StorageAdapter {
.map(k => k.split('.')[1]);
let incrementPatterns = '';
const incrementValues = [];
if (keysToIncrement.length > 0) {
incrementPatterns =
' || ' +
keysToIncrement
.map(c => {
const amount = fieldValue[c].amount;
return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + ${amount}, '}')::jsonb`;
if (typeof amount !== 'number') {
throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number');
}
incrementValues.push(amount);
const amountIndex = index + incrementValues.length;
return `CONCAT('{"${c}":', COALESCE($${index}:name->>'${c}','0')::int + $${amountIndex}, '}')::jsonb`;
})
.join(' || ');
// Strip the keys
@@ -1750,7 +1762,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
.map(k => k.split('.')[1]);
const deletePatterns = keysToDelete.reduce((p: string, c: string, i: number) => {
return p + ` - '$${index + 1 + i}:value'`;
return p + ` - '$${index + 1 + incrementValues.length + i}:value'`;
}, '');
// Override Object
let updateObject = "'{}'::jsonb";
@@ -1760,11 +1772,11 @@ export class PostgresStorageAdapter implements StorageAdapter {
updateObject = `COALESCE($${index}:name, '{}'::jsonb)`;
}
updatePatterns.push(
`$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + keysToDelete.length
`$${index}:name = (${updateObject} ${deletePatterns} ${incrementPatterns} || $${index + 1 + incrementValues.length + keysToDelete.length
}::jsonb )`
);
values.push(fieldName, ...keysToDelete, JSON.stringify(fieldValue));
index += 2 + keysToDelete.length;
values.push(fieldName, ...incrementValues, ...keysToDelete, JSON.stringify(fieldValue));
index += 2 + incrementValues.length + keysToDelete.length;
} else if (
Array.isArray(fieldValue) &&
schema.fields[fieldName] &&
@@ -1801,7 +1813,30 @@ export class PostgresStorageAdapter implements StorageAdapter {
const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : '';
const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`;
const promise = (transactionalSession ? transactionalSession.t : this._client).any(qs, values);
const promise = (transactionalSession ? transactionalSession.t : this._client)
.any(qs, values)
.catch(error => {
if (error.code === PostgresUniqueIndexViolationError) {
const err = new Parse.Error(
Parse.Error.DUPLICATE_VALUE,
'A duplicate value for a field with unique values was provided'
);
err.underlyingError = error;
if (error.constraint) {
const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/);
if (authDataMatch) {
err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` };
} else {
const matches = error.constraint.match(/unique_([a-zA-Z]+)/);
if (matches && Array.isArray(matches)) {
err.userInfo = { duplicated_field: matches[1] };
}
}
}
throw err;
}
throw error;
});
if (transactionalSession) {
transactionalSession.batch.push(promise);
}
@@ -2044,6 +2079,31 @@ export class PostgresStorageAdapter implements StorageAdapter {
});
}
// Creates a unique index on authData-><provider>->>'id' to prevent
// race conditions during concurrent signups with the same authData.
async ensureAuthDataUniqueness(provider: string) {
const indexName = `_User_unique_authData_${provider}_id`;
const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $1:name ON "_User" (("authData"->$2::text->>'id')) WHERE "authData"->$2::text->>'id' IS NOT NULL`;
await this._client.none(qs, [indexName, provider]).catch(error => {
if (
error.code === PostgresDuplicateRelationError &&
error.message.includes(indexName)
) {
// Index already exists. Ignore error.
} else if (
error.code === PostgresUniqueIndexViolationError &&
error.message.includes(indexName)
) {
throw new Parse.Error(
Parse.Error.DUPLICATE_VALUE,
'Tried to ensure field uniqueness for a class that already has duplicates.'
);
} else {
throw error;
}
});
}
// Executes a count.
async count(
className: string,
+14
View File
@@ -13,6 +13,7 @@ import {
DatabaseOptions,
FileUploadOptions,
IdempotencyOptions,
LiveQueryOptions,
LogLevels,
PagesOptions,
ParseServerOptions,
@@ -134,6 +135,7 @@ export class Config {
databaseOptions,
extendSessionOnUse,
allowClientClassCreation,
liveQuery,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -176,6 +178,7 @@ export class Config {
this.validateDatabaseOptions(databaseOptions);
this.validateCustomPages(customPages);
this.validateAllowClientClassCreation(allowClientClassCreation);
this.validateLiveQueryOptions(liveQuery);
}
static validateCustomPages(customPages) {
@@ -669,6 +672,17 @@ export class Config {
}
}
static validateLiveQueryOptions(liveQuery) {
if (liveQuery == undefined) {
return;
}
if (liveQuery.regexTimeout === undefined) {
liveQuery.regexTimeout = LiveQueryOptions.regexTimeout.default;
} else if (typeof liveQuery.regexTimeout !== 'number') {
throw `liveQuery.regexTimeout must be a number`;
}
}
static validateRateLimit(rateLimit) {
if (!rateLimit) {
return;
+24
View File
@@ -1838,6 +1838,30 @@ class DatabaseController {
throw error;
});
}
// Create unique indexes for authData providers to prevent race conditions
// during concurrent signups with the same authData
if (
databaseOptions.createIndexAuthDataUniqueness !== false &&
typeof this.adapter.ensureAuthDataUniqueness === 'function'
) {
const authProviders = Object.keys(this.options.auth || {});
if (this.options.enableAnonymousUsers !== false) {
if (!authProviders.includes('anonymous')) {
authProviders.push('anonymous');
}
}
await Promise.all(
authProviders.map(provider =>
this.adapter.ensureAuthDataUniqueness(provider).catch(error => {
logger.warn(
`Unable to ensure uniqueness for auth data provider "${provider}": `,
error
);
})
)
);
}
await this.adapter.updateSchemaWithIndexes();
}
+9 -2
View File
@@ -110,7 +110,7 @@ module.exports.ParseServerOptions = {
auth: {
env: 'PARSE_SERVER_AUTH_PROVIDERS',
help:
'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication',
"Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication<br><br>Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_<provider>`), which must comply with Parse Server's field naming rules.",
action: parsers.objectParser,
},
cacheAdapter: {
@@ -682,7 +682,7 @@ module.exports.RateLimitOptions = {
requestCount: {
env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_COUNT',
help:
'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied.',
'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.',
action: parsers.numberParser('requestCount'),
},
requestMethods: {
@@ -1248,6 +1248,13 @@ module.exports.DatabaseOptions = {
'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.',
action: parsers.numberParser('connectTimeoutMS'),
},
createIndexAuthDataUniqueness: {
env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_AUTH_DATA_UNIQUENESS',
help:
'Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.<br><br>\u26A0\uFE0F When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.',
action: parsers.booleanParser,
default: true,
},
createIndexRoleName: {
env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_ROLE_NAME',
help:
+3 -2
View File
@@ -21,7 +21,7 @@
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
* @property {String} appId Your Parse Application ID
* @property {String} appName Sets the app name
* @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication
* @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication<br><br>Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_<provider>`), which must comply with Parse Server's field naming rules.
* @property {Adapter<CacheAdapter>} cacheAdapter Adapter module for the cache
* @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000
* @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds)
@@ -120,7 +120,7 @@
* @property {Boolean} includeInternalRequests Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks.
* @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.
* @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests.
* @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied.
* @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting.
* @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.
* @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html
* @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.
@@ -278,6 +278,7 @@
* @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead.
* @property {Union} compressors The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance.
* @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.
* @property {Boolean} createIndexAuthDataUniqueness Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.
* @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
* @property {Boolean} createIndexUserEmail Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
* @property {Boolean} createIndexUserEmailCaseInsensitive Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
+5 -2
View File
@@ -164,7 +164,7 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID
:DEFAULT: false */
allowCustomObjectId: ?boolean;
/* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication
/* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication<br><br>Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_<provider>`), which must comply with Parse Server's field naming rules.
:ENV: PARSE_SERVER_AUTH_PROVIDERS */
auth: ?{ [string]: AuthAdapter };
/* Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.
@@ -361,7 +361,7 @@ export interface RateLimitOptions {
requestPath: string;
/* The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. */
requestTimeWindow: ?number;
/* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. */
/* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. For batch requests, this also limits the number of sub-requests in a single batch that target this path; however, requests already consumed in the current time window are not counted against the batch, so the effective limit may be higher when combining individual and batch requests. Note that this is a basic server-level rate limit; for comprehensive protection, use a reverse proxy or WAF for rate limiting. */
requestCount: ?number;
/* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`.
:DEFAULT: Too many requests. */
@@ -775,6 +775,9 @@ export interface DatabaseOptions {
/* Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
:DEFAULT: true */
createIndexUserUsernameCaseInsensitive: ?boolean;
/* Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.
:DEFAULT: true */
createIndexAuthDataUniqueness: ?boolean;
/* Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.<br><br>⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server.
:DEFAULT: true */
createIndexRoleName: ?boolean;
+27 -6
View File
@@ -514,6 +514,16 @@ RestWrite.prototype.getUserId = function () {
}
};
RestWrite.prototype._throwIfAuthDataDuplicate = function (error) {
if (
this.className === '_User' &&
error?.code === Parse.Error.DUPLICATE_VALUE &&
error.userInfo?.duplicated_field?.startsWith('_auth_data_')
) {
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used');
}
};
// Developers are allowed to change authData via before save trigger
// we need after before save to ensure that the developer
// is not currently duplicating auth data ID
@@ -644,12 +654,17 @@ RestWrite.prototype.handleAuthData = async function (authData) {
// uses the `doNotSave` option. Just update the authData part
// Then we're good for the user, early exit of sorts
if (Object.keys(this.data.authData).length) {
await this.config.database.update(
this.className,
{ objectId: this.data.objectId },
{ authData: this.data.authData },
{}
);
try {
await this.config.database.update(
this.className,
{ objectId: this.data.objectId },
{ authData: this.data.authData },
{}
);
} catch (error) {
this._throwIfAuthDataDuplicate(error);
throw error;
}
}
}
}
@@ -1545,6 +1560,10 @@ RestWrite.prototype.runDatabaseOperation = function () {
false,
this.validSchemaController
)
.catch(error => {
this._throwIfAuthDataDuplicate(error);
throw error;
})
.then(response => {
response.updatedAt = this.updatedAt;
this._updateResponseWithData(response, this.data);
@@ -1579,6 +1598,8 @@ RestWrite.prototype.runDatabaseOperation = function () {
throw error;
}
this._throwIfAuthDataDuplicate(error);
// Quick check, if we were able to infer the duplicated field name
if (error && error.userInfo && error.userInfo.duplicated_field === 'username') {
throw new Parse.Error(
+2
View File
@@ -3,7 +3,9 @@ const classesWithMasterOnlyAccess = [
'_PushStatus',
'_Hooks',
'_GlobalConfig',
'_GraphQLConfig',
'_JobSchedule',
'_Audience',
'_Idempotency',
];
const { createSanitizedError } = require('./Error');
+43
View File
@@ -83,6 +83,49 @@ function handleBatch(router, req) {
req.config.publicServerURL
);
// Check if batch sub-requests would exceed any configured rate limits.
// Count how many sub-requests target each rate-limited path and reject
// the entire batch if any path's count exceeds its requestCount.
const rateLimits = req.config.rateLimits || [];
for (const limit of rateLimits) {
// Skip rate limit if master key is used and includeMasterKey is not set
if (req.auth?.isMaster && !limit.includeMasterKey) {
continue;
}
// Skip rate limit for internal requests if includeInternalRequests is not set
if (req.config.ip === '127.0.0.1' && !limit.includeInternalRequests) {
continue;
}
const pathExp = limit.path;
let matchCount = 0;
for (const restRequest of req.body.requests) {
// Check if sub-request method matches the rate limit's requestMethods filter
if (limit.requestMethods) {
const method = restRequest.method?.toUpperCase();
if (Array.isArray(limit.requestMethods)) {
if (!limit.requestMethods.includes(method)) {
continue;
}
} else {
const regExp = new RegExp(limit.requestMethods);
if (!regExp.test(method)) {
continue;
}
}
}
const routablePath = makeRoutablePath(restRequest.path);
if (pathExp.test(routablePath)) {
matchCount++;
}
}
if (matchCount > limit.requestCount) {
throw new Parse.Error(
Parse.Error.CONNECTION_FAILED,
limit.errorResponseMessage || 'Batch request exceeds rate limit for endpoint'
);
}
}
const batch = transactionRetries => {
let initialPromise = Promise.resolve();
if (req.body?.transaction === true) {
+1
View File
@@ -38,6 +38,7 @@ export const DefaultMongoURI = DefinitionDefaults.databaseURI;
// before passing to MongoDB client
export const ParseServerDatabaseOptions = [
'allowPublicExplain',
'createIndexAuthDataUniqueness',
'createIndexRoleName',
'createIndexUserEmail',
'createIndexUserEmailCaseInsensitive',
+5
View File
@@ -566,6 +566,11 @@ export const addRateLimit = (route, config, cloud) => {
}
config.rateLimits.push({
path: pathToRegexp(transformPath),
requestCount: route.requestCount,
requestMethods: route.requestMethods,
includeMasterKey: route.includeMasterKey,
includeInternalRequests: route.includeInternalRequests,
errorResponseMessage: route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default,
handler: rateLimit({
windowMs: route.requestTimeWindow,
max: route.requestCount,