Skip to content

Commit 38adef7

Browse files
authored
build: Release (#10090)
2 parents 62e9132 + 746f641 commit 38adef7

17 files changed

Lines changed: 309 additions & 21 deletions

changelogs/CHANGELOG_alpha.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,24 @@
1+
## [9.4.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.4.1-alpha.2...9.4.1-alpha.3) (2026-03-04)
2+
3+
4+
### Bug Fixes
5+
6+
* Cloud Hooks and Cloud Jobs bypass `readOnlyMasterKey` write restriction ([GHSA-vc89-5g3r-cmhh](https://github.com/parse-community/parse-server/security/advisories/GHSA-vc89-5g3r-cmhh)) ([#10088](https://github.com/parse-community/parse-server/issues/10088)) ([9a3dd4d](https://github.com/parse-community/parse-server/commit/9a3dd4d2d55ad506348062b43a7fe42e22a57fe9))
7+
8+
## [9.4.1-alpha.2](https://github.com/parse-community/parse-server/compare/9.4.1-alpha.1...9.4.1-alpha.2) (2026-03-03)
9+
10+
11+
### Performance Improvements
12+
13+
* Upgrade to mongodb 7.1.0 ([#10087](https://github.com/parse-community/parse-server/issues/10087)) ([bebf2fd](https://github.com/parse-community/parse-server/commit/bebf2fd62b51cfc35c271ad4c76b8f552f886ce8))
14+
15+
## [9.4.1-alpha.1](https://github.com/parse-community/parse-server/compare/9.4.0...9.4.1-alpha.1) (2026-03-03)
16+
17+
18+
### Bug Fixes
19+
20+
* MongoDB default batch size changed from 1000 to 100 without announcement ([#10085](https://github.com/parse-community/parse-server/issues/10085)) ([8f17397](https://github.com/parse-community/parse-server/commit/8f1739788d434c91109f049a438c32bdd4fc26a5))
21+
122
# [9.4.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.4.0-alpha.1...9.4.0-alpha.2) (2026-02-27)
223

324

package-lock.json

Lines changed: 12 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parse-server",
3-
"version": "9.4.0",
3+
"version": "9.4.1-alpha.3",
44
"description": "An express module providing a Parse-compatible API server",
55
"main": "lib/index.js",
66
"repository": {
@@ -45,7 +45,7 @@
4545
"lodash": "4.17.23",
4646
"lru-cache": "10.4.0",
4747
"mime": "4.0.7",
48-
"mongodb": "7.0.0",
48+
"mongodb": "7.1.0",
4949
"mustache": "4.2.0",
5050
"otpauth": "9.4.0",
5151
"parse": "8.3.0",

spec/GridFSBucketStorageAdapter.spec.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe_only_db('mongo')('GridFSBucket', () => {
2929
enableSchemaHooks: true,
3030
schemaCacheTtl: 5000,
3131
maxTimeMS: 30000,
32+
batchSize: 500,
3233
disableIndexFieldValidation: true,
3334
logClientEvents: [{ name: 'commandStarted' }],
3435
createIndexUserUsername: true,
@@ -46,6 +47,13 @@ describe_only_db('mongo')('GridFSBucket', () => {
4647
expect(db.options?.retryWrites).toEqual(true);
4748
});
4849

50+
it('should store batchSize and filter it from MongoClient options', async () => {
51+
const gfsAdapter = new GridFSBucketAdapter(databaseURI, { batchSize: 500 });
52+
expect(gfsAdapter._batchSize).toEqual(500);
53+
// Verify batchSize is filtered from MongoClient options
54+
expect(gfsAdapter._mongoOptions.batchSize).toBeUndefined();
55+
});
56+
4957
it('should save an encrypted file that can only be decrypted by a GridFS adapter with the encryptionKey', async () => {
5058
const unencryptedAdapter = new GridFSBucketAdapter(databaseURI);
5159
const encryptedAdapter = new GridFSBucketAdapter(

spec/MongoStorageAdapter.spec.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,58 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
108108
);
109109
});
110110

111+
it('passes batchSize to the MongoDB driver find() call', async () => {
112+
const batchSize = 50;
113+
const adapter = new MongoStorageAdapter({
114+
uri: databaseURI,
115+
mongoOptions: { batchSize },
116+
});
117+
await adapter.createObject('BatchTest', { fields: {} }, { objectId: 'obj1' });
118+
119+
// Spy on the MongoDB driver's Collection.prototype.find to verify batchSize is forwarded
120+
const originalFind = Collection.prototype.find;
121+
let capturedOptions;
122+
spyOn(Collection.prototype, 'find').and.callFake(function (query, options) {
123+
capturedOptions = options;
124+
return originalFind.call(this, query, options);
125+
});
126+
127+
await adapter.find('BatchTest', { fields: {} }, {}, {});
128+
expect(capturedOptions).toBeDefined();
129+
expect(capturedOptions.batchSize).toEqual(50);
130+
});
131+
132+
it('passes batchSize to the MongoDB driver aggregate() call', async () => {
133+
const batchSize = 50;
134+
const adapter = new MongoStorageAdapter({
135+
uri: databaseURI,
136+
mongoOptions: { batchSize },
137+
});
138+
await adapter.createObject('AggBatchTest', { fields: { count: { type: 'Number' } } }, { objectId: 'obj1', count: 1 });
139+
140+
// Spy on the MongoDB driver's Collection.prototype.aggregate to verify batchSize is forwarded
141+
const originalAggregate = Collection.prototype.aggregate;
142+
let capturedOptions;
143+
spyOn(Collection.prototype, 'aggregate').and.callFake(function (pipeline, options) {
144+
capturedOptions = options;
145+
return originalAggregate.call(this, pipeline, options);
146+
});
147+
148+
await adapter.aggregate('AggBatchTest', { fields: { count: { type: 'Number' } } }, [{ $match: {} }]);
149+
expect(capturedOptions).toBeDefined();
150+
expect(capturedOptions.batchSize).toEqual(50);
151+
});
152+
153+
it('defaults batchSize to 1000', async () => {
154+
await reconfigureServer({
155+
databaseURI: databaseURI,
156+
collectionPrefix: 'test_',
157+
databaseAdapter: undefined,
158+
});
159+
const adapter = Config.get(Parse.applicationId).database.adapter;
160+
expect(adapter._batchSize).toEqual(1000);
161+
});
162+
111163
it('stores pointers with a _p_ prefix', done => {
112164
const obj = {
113165
objectId: 'bar',

spec/rest.spec.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,141 @@ describe('read-only masterKey', () => {
11721172
done();
11731173
});
11741174
});
1175+
1176+
it('should throw when trying to create a hook function', async () => {
1177+
loggerErrorSpy.calls.reset();
1178+
try {
1179+
await request({
1180+
url: `${Parse.serverURL}/hooks/functions`,
1181+
method: 'POST',
1182+
headers: {
1183+
'X-Parse-Application-Id': Parse.applicationId,
1184+
'X-Parse-Master-Key': 'read-only-test',
1185+
'Content-Type': 'application/json',
1186+
},
1187+
body: { functionName: 'readOnlyTest', url: 'https://example.com/hook' },
1188+
});
1189+
fail('should have thrown');
1190+
} catch (res) {
1191+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
1192+
expect(res.data.error).toBe('Permission denied');
1193+
}
1194+
});
1195+
1196+
it('should throw when trying to create a hook trigger', async () => {
1197+
loggerErrorSpy.calls.reset();
1198+
try {
1199+
await request({
1200+
url: `${Parse.serverURL}/hooks/triggers`,
1201+
method: 'POST',
1202+
headers: {
1203+
'X-Parse-Application-Id': Parse.applicationId,
1204+
'X-Parse-Master-Key': 'read-only-test',
1205+
'Content-Type': 'application/json',
1206+
},
1207+
body: { className: 'MyClass', triggerName: 'beforeSave', url: 'https://example.com/hook' },
1208+
});
1209+
fail('should have thrown');
1210+
} catch (res) {
1211+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
1212+
expect(res.data.error).toBe('Permission denied');
1213+
}
1214+
});
1215+
1216+
it('should throw when trying to update a hook function', async () => {
1217+
// First create the hook with the real master key
1218+
await request({
1219+
url: `${Parse.serverURL}/hooks/functions`,
1220+
method: 'POST',
1221+
headers: {
1222+
'X-Parse-Application-Id': Parse.applicationId,
1223+
'X-Parse-Master-Key': Parse.masterKey,
1224+
'Content-Type': 'application/json',
1225+
},
1226+
body: { functionName: 'readOnlyUpdateTest', url: 'https://example.com/hook' },
1227+
});
1228+
loggerErrorSpy.calls.reset();
1229+
try {
1230+
await request({
1231+
url: `${Parse.serverURL}/hooks/functions/readOnlyUpdateTest`,
1232+
method: 'PUT',
1233+
headers: {
1234+
'X-Parse-Application-Id': Parse.applicationId,
1235+
'X-Parse-Master-Key': 'read-only-test',
1236+
'Content-Type': 'application/json',
1237+
},
1238+
body: { url: 'https://example.com/hacked' },
1239+
});
1240+
fail('should have thrown');
1241+
} catch (res) {
1242+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
1243+
expect(res.data.error).toBe('Permission denied');
1244+
}
1245+
});
1246+
1247+
it('should throw when trying to delete a hook function', async () => {
1248+
// First create the hook with the real master key
1249+
await request({
1250+
url: `${Parse.serverURL}/hooks/functions`,
1251+
method: 'POST',
1252+
headers: {
1253+
'X-Parse-Application-Id': Parse.applicationId,
1254+
'X-Parse-Master-Key': Parse.masterKey,
1255+
'Content-Type': 'application/json',
1256+
},
1257+
body: { functionName: 'readOnlyDeleteTest', url: 'https://example.com/hook' },
1258+
});
1259+
loggerErrorSpy.calls.reset();
1260+
try {
1261+
await request({
1262+
url: `${Parse.serverURL}/hooks/functions/readOnlyDeleteTest`,
1263+
method: 'PUT',
1264+
headers: {
1265+
'X-Parse-Application-Id': Parse.applicationId,
1266+
'X-Parse-Master-Key': 'read-only-test',
1267+
'Content-Type': 'application/json',
1268+
},
1269+
body: { __op: 'Delete' },
1270+
});
1271+
fail('should have thrown');
1272+
} catch (res) {
1273+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
1274+
expect(res.data.error).toBe('Permission denied');
1275+
}
1276+
});
1277+
1278+
it('should throw when trying to run a job with readOnlyMasterKey', async () => {
1279+
Parse.Cloud.job('readOnlyTestJob', () => {});
1280+
loggerErrorSpy.calls.reset();
1281+
try {
1282+
await request({
1283+
url: `${Parse.serverURL}/jobs/readOnlyTestJob`,
1284+
method: 'POST',
1285+
headers: {
1286+
'X-Parse-Application-Id': Parse.applicationId,
1287+
'X-Parse-Master-Key': 'read-only-test',
1288+
'Content-Type': 'application/json',
1289+
},
1290+
body: {},
1291+
});
1292+
fail('should have thrown');
1293+
} catch (res) {
1294+
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
1295+
expect(res.data.error).toBe('Permission denied');
1296+
}
1297+
});
1298+
1299+
it('should allow reading hooks with readOnlyMasterKey', async () => {
1300+
const res = await request({
1301+
url: `${Parse.serverURL}/hooks/functions`,
1302+
method: 'GET',
1303+
headers: {
1304+
'X-Parse-Application-Id': Parse.applicationId,
1305+
'X-Parse-Master-Key': 'read-only-test',
1306+
},
1307+
});
1308+
expect(Array.isArray(res.data)).toBe(true);
1309+
});
11751310
});
11761311

11771312
describe('rest context', () => {

src/Adapters/Files/GridFSBucketAdapter.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
3838
const defaultMongoOptions = {};
3939
const _mongoOptions = Object.assign(defaultMongoOptions, mongoOptions);
4040
this._clientMetadata = mongoOptions.clientMetadata;
41+
this._batchSize = mongoOptions.batchSize;
4142
// Remove Parse Server-specific options that should not be passed to MongoDB client
4243
for (const key of ParseServerDatabaseOptions) {
4344
delete _mongoOptions[key];
@@ -135,7 +136,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
135136

136137
async deleteFile(filename: string) {
137138
const bucket = await this._getBucket();
138-
const documents = await bucket.find({ filename }).toArray();
139+
const documents = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
139140
if (documents.length === 0) {
140141
throw new Error('FileNotFound');
141142
}
@@ -196,7 +197,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
196197
if (options.fileNames !== undefined) {
197198
fileNames = options.fileNames;
198199
} else {
199-
const fileNamesIterator = await bucket.find().toArray();
200+
const fileNamesIterator = await bucket.find({}, { batchSize: this._batchSize }).toArray();
200201
fileNamesIterator.forEach(file => {
201202
fileNames.push(file.filename);
202203
});
@@ -226,7 +227,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
226227

227228
async getMetadata(filename) {
228229
const bucket = await this._getBucket();
229-
const files = await bucket.find({ filename }).toArray();
230+
const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
230231
if (files.length === 0) {
231232
return {};
232233
}
@@ -236,7 +237,7 @@ export class GridFSBucketAdapter extends FilesAdapter {
236237

237238
async handleFileStream(filename: string, req, res, contentType) {
238239
const bucket = await this._getBucket();
239-
const files = await bucket.find({ filename }).toArray();
240+
const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray();
240241
if (files.length === 0) {
241242
throw new Error('FileNotFound');
242243
}

0 commit comments

Comments
 (0)