mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c692c3e6e6 |
@@ -829,40 +829,6 @@ async function benchmarkObjectCreateNestedDenylist(name) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benchmark: $relatedTo relation query (public, non-master)
|
||||
*
|
||||
* Measures a public `$relatedTo` query, which now performs an owning-object
|
||||
* read-access check before reading the relation join table (GHSA-wmwx-jr2p-4j4r).
|
||||
* This captures the cost of that added authorization read on the relation path.
|
||||
*/
|
||||
async function benchmarkRelatedToQuery(name) {
|
||||
const Child = Parse.Object.extend('BenchmarkRelChild');
|
||||
const children = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
children.push(new Child({ value: i }));
|
||||
}
|
||||
await Parse.Object.saveAll(children, { useMasterKey: true });
|
||||
|
||||
// Publicly readable owning object, so the authorized relation path runs fully.
|
||||
const Parent = Parse.Object.extend('BenchmarkRelParent');
|
||||
const parent = new Parent({ name: 'benchmark-parent' });
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(true);
|
||||
parent.setACL(acl);
|
||||
parent.relation('members').add(children);
|
||||
await parent.save(null, { useMasterKey: true });
|
||||
|
||||
return measureOperation({
|
||||
name,
|
||||
iterations: 1_000,
|
||||
operation: async () => {
|
||||
// Non-master query exercises the owning-object read-access check.
|
||||
await parent.relation('members').query().find();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all benchmarks
|
||||
*/
|
||||
@@ -890,7 +856,6 @@ async function runBenchmarks() {
|
||||
{ name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave },
|
||||
{ name: 'Query.get (by objectId)', fn: benchmarkObjectRead },
|
||||
{ name: 'Query.find (simple query)', fn: benchmarkSimpleQuery },
|
||||
{ name: 'Query.find ($relatedTo relation)', fn: benchmarkRelatedToQuery },
|
||||
{ name: 'User.signUp', fn: benchmarkUserSignup },
|
||||
{ name: 'User.login', fn: benchmarkUserLogin },
|
||||
{ name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
## [9.9.1-alpha.6](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.5...9.9.1-alpha.6) (2026-06-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Relation `$relatedTo` query bypasses `protectedFields` and owning-object ACL ([GHSA-wmwx-jr2p-4j4r](https://github.com/parse-community/parse-server/security/advisories/GHSA-wmwx-jr2p-4j4r)) ([#10493](https://github.com/parse-community/parse-server/issues/10493)) ([43658f1](https://github.com/parse-community/parse-server/commit/43658f1fd83689b24a4350094f1071ac555ac9b3))
|
||||
|
||||
## [9.9.1-alpha.5](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.4...9.9.1-alpha.5) (2026-06-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Endpoints `/login` and `/verifyPassword` disclose MFA secrets and protected fields when `_User` get is denied ([GHSA-75v4-m273-5j49](https://github.com/parse-community/parse-server/security/advisories/GHSA-75v4-m273-5j49)) ([#10492](https://github.com/parse-community/parse-server/issues/10492)) ([83e90ed](https://github.com/parse-community/parse-server/commit/83e90edbe4224c81172a20e40fa986662c9394ca))
|
||||
|
||||
## [9.9.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.3...9.9.1-alpha.4) (2026-06-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Stored XSS via trailing-dot filename bypassing file upload extension blocklist ([GHSA-7wqv-xjf3-x35v](https://github.com/parse-community/parse-server/security/advisories/GHSA-7wqv-xjf3-x35v)) ([#10489](https://github.com/parse-community/parse-server/issues/10489)) ([66484ce](https://github.com/parse-community/parse-server/commit/66484ce8fdd87a5d4c23bf9e40f7ea379b4dce79))
|
||||
|
||||
## [9.9.1-alpha.3](https://github.com/parse-community/parse-server/compare/9.9.1-alpha.2...9.9.1-alpha.3) (2026-05-27)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.6",
|
||||
"version": "9.9.1-alpha.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.6",
|
||||
"version": "9.9.1-alpha.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.9.1-alpha.6",
|
||||
"version": "9.9.1-alpha.3",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
|
||||
@@ -447,25 +447,4 @@ describe('Utils', () => {
|
||||
expect(Utils.isObject(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
const cases = [
|
||||
['file.txt', 'txt'],
|
||||
['file.tar.gz', 'gz'],
|
||||
['.hidden', 'hidden'],
|
||||
['file.', ''],
|
||||
['file..', ''],
|
||||
['file', ''],
|
||||
['', ''],
|
||||
[null, ''],
|
||||
[undefined, ''],
|
||||
['poc.svg.', ''],
|
||||
['archive.tar.gz.', ''],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
it(`returns ${JSON.stringify(expected)} for ${JSON.stringify(input)}`, () => {
|
||||
expect(Utils.getFileExtension(input)).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1749,174 +1749,6 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-7wqv-xjf3-x35v) Stored XSS via trailing-dot filename bypassing file extension blocklist', () => {
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks trailing-dot SVG filename with dangerous _ContentType on JSON-body upload', async () => {
|
||||
const svgContent = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
).toString('base64');
|
||||
// No X-Parse-Application-Id header — must be in JSON body to trigger
|
||||
// _ContentType extraction via the fileViaJSON middleware path.
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'image/svg+xml',
|
||||
base64: svgContent,
|
||||
}),
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks trailing-dot SVG filename with dangerous Content-Type on binary upload', async () => {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks filename with mixed trailing dots and whitespace', async () => {
|
||||
for (const filename of ['poc.svg..', 'poc.svg. ', 'poc.svg . ']) {
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
url: `http://localhost:8378/1/files/${encodeURIComponent(filename)}`,
|
||||
body: '<svg/>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
it('still allows trailing-dot filename with allowed Content-Type', async () => {
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/notes.txt.',
|
||||
body: JSON.stringify({
|
||||
_ApplicationId: 'test',
|
||||
_JavaScriptKey: 'test',
|
||||
_ContentType: 'text/plain',
|
||||
base64: Buffer.from('hello').toString('base64'),
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FilesController treats trailing-dot filename as extensionless when appending derived extension via master key upload', async () => {
|
||||
await reconfigureServer({
|
||||
fileUpload: {
|
||||
enableForPublic: true,
|
||||
},
|
||||
preserveFileName: true,
|
||||
});
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
body: '<svg/>',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const filenameArg = spy.calls.mostRecent().args[0];
|
||||
const contentTypeArg = spy.calls.mostRecent().args[2];
|
||||
// Trailing-dot filename is treated as extensionless: derived extension appended without doubling the dot
|
||||
expect(filenameArg).toBe('poc.svg.svg');
|
||||
// Caller-supplied Content-Type is preserved on the extensionless path
|
||||
expect(contentTypeArg).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('allows trailing-dot filename when no Content-Type is supplied (no XSS path)', async () => {
|
||||
// Trailing-dot filename with no caller-supplied Content-Type: the
|
||||
// blocklist gate skips because no extension can be determined, but no
|
||||
// attacker-controlled Content-Type reaches the storage adapter — only
|
||||
// the SDK's benign default — so no stored XSS is possible.
|
||||
const adapter = Config.get('test').filesController.adapter;
|
||||
const spy = spyOn(adapter, 'createFile').and.callThrough();
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc.svg.',
|
||||
body: '<svg/>',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const contentTypeArg = spy.calls.mostRecent().args[2];
|
||||
expect(contentTypeArg).not.toMatch(/svg|html|xml|xhtml|xslt|mathml/i);
|
||||
});
|
||||
|
||||
it('falls back to raw Content-Type when Content-Type is malformed (no slash)', async () => {
|
||||
// Exercises the last-resort branch: when both the filename has no usable
|
||||
// extension AND the Content-Type lacks a "/" subtype to parse, the raw
|
||||
// Content-Type is used as the extension so a malformed header that
|
||||
// matches a blocked pattern still trips the blocklist.
|
||||
await expectAsync(
|
||||
request({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'svg',
|
||||
},
|
||||
url: 'http://localhost:8378/1/files/poc',
|
||||
body: '<svg/>',
|
||||
}).catch(e => {
|
||||
throw new Error(e.data.error);
|
||||
})
|
||||
).toBeRejectedWith(jasmine.objectContaining({
|
||||
message: jasmine.stringMatching(/File upload of extension svg is disabled/),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -2307,207 +2139,6 @@ describe('Vulnerabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-wmwx-jr2p-4j4r) $relatedTo bypasses protectedFields and parent ACL for Relation fields', () => {
|
||||
let childLinked;
|
||||
let parentProtectedKey;
|
||||
let parentPrivate;
|
||||
let parentPublic;
|
||||
|
||||
const relatedToWhere = (parentId, key, extra = {}) => ({
|
||||
$relatedTo: {
|
||||
object: { __type: 'Pointer', className: 'RelParent', objectId: parentId },
|
||||
key,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const queryChild = (where, headers = {}) =>
|
||||
request({
|
||||
method: 'GET',
|
||||
url: `${Parse.serverURL}/classes/RelChild`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
...headers,
|
||||
},
|
||||
qs: { where: JSON.stringify(where) },
|
||||
}).catch(e => e);
|
||||
|
||||
beforeEach(async () => {
|
||||
const schema = new Parse.Schema('RelParent');
|
||||
schema.addString('name');
|
||||
schema.addRelation('secretRel', 'RelChild');
|
||||
schema.addRelation('openRel', 'RelChild');
|
||||
schema.setCLP({
|
||||
find: { '*': true },
|
||||
get: { '*': true },
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: { '*': true },
|
||||
addField: {},
|
||||
// secretRel is a protected Relation field for public clients
|
||||
protectedFields: { '*': ['secretRel'] },
|
||||
});
|
||||
await schema.save();
|
||||
|
||||
childLinked = new Parse.Object('RelChild', { value: 'linked child' });
|
||||
await childLinked.save(null, { useMasterKey: true });
|
||||
|
||||
const publicAcl = new Parse.ACL();
|
||||
publicAcl.setPublicReadAccess(true);
|
||||
|
||||
const privateAcl = new Parse.ACL();
|
||||
privateAcl.setPublicReadAccess(false);
|
||||
privateAcl.setPublicWriteAccess(false);
|
||||
|
||||
// Publicly readable parent whose relation key is protected (isolates the
|
||||
// protectedFields facet).
|
||||
parentProtectedKey = new Parse.Object('RelParent', { name: 'protected-key parent' });
|
||||
parentProtectedKey.setACL(publicAcl);
|
||||
parentProtectedKey.relation('secretRel').add(childLinked);
|
||||
await parentProtectedKey.save(null, { useMasterKey: true });
|
||||
|
||||
// Parent that is not readable by the public, queried via a non-protected
|
||||
// relation key (isolates the parent-ACL facet).
|
||||
parentPrivate = new Parse.Object('RelParent', { name: 'private parent' });
|
||||
parentPrivate.setACL(privateAcl);
|
||||
parentPrivate.relation('openRel').add(childLinked);
|
||||
await parentPrivate.save(null, { useMasterKey: true });
|
||||
|
||||
// Publicly readable parent with a non-protected relation key (legitimate
|
||||
// use that must keep working).
|
||||
parentPublic = new Parse.Object('RelParent', { name: 'public parent' });
|
||||
parentPublic.setACL(publicAcl);
|
||||
parentPublic.relation('openRel').add(childLinked);
|
||||
await parentPublic.save(null, { useMasterKey: true });
|
||||
});
|
||||
|
||||
it('denies $relatedTo query that references a protected relation field', async () => {
|
||||
const res = await queryChild(relatedToWhere(parentProtectedKey.id, 'secretRel'));
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('denies $relatedTo on a protected relation field nested in $or', async () => {
|
||||
const res = await queryChild({
|
||||
$or: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
|
||||
});
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('denies $relatedTo on a protected relation field nested in $and', async () => {
|
||||
const res = await queryChild({
|
||||
$and: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
|
||||
});
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('denies $relatedTo on a protected relation field nested in $nor', async () => {
|
||||
const res = await queryChild({
|
||||
$nor: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
|
||||
});
|
||||
expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
|
||||
expect(res.data.error).toBe('Permission denied');
|
||||
});
|
||||
|
||||
it('returns no results when the owning object is not readable by the caller', async () => {
|
||||
const res = await queryChild(relatedToWhere(parentPrivate.id, 'openRel'));
|
||||
expect(res.data.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not act as a membership oracle for an unreadable owning object', async () => {
|
||||
const res = await queryChild(
|
||||
relatedToWhere(parentPrivate.id, 'openRel', { objectId: childLinked.id })
|
||||
);
|
||||
expect(res.data.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('still returns related objects for a readable parent and non-protected key', async () => {
|
||||
const res = await queryChild(relatedToWhere(parentPublic.id, 'openRel'));
|
||||
expect(res.data.results.length).toBe(1);
|
||||
expect(res.data.results[0].objectId).toBe(childLinked.id);
|
||||
});
|
||||
|
||||
it('allows master key to query a protected relation and an unreadable parent', async () => {
|
||||
const masterHeaders = { 'X-Parse-Master-Key': Parse.masterKey };
|
||||
const resProtected = await queryChild(
|
||||
relatedToWhere(parentProtectedKey.id, 'secretRel'),
|
||||
masterHeaders
|
||||
);
|
||||
expect(resProtected.data.results.length).toBe(1);
|
||||
const resPrivate = await queryChild(
|
||||
relatedToWhere(parentPrivate.id, 'openRel'),
|
||||
masterHeaders
|
||||
);
|
||||
expect(resPrivate.data.results.length).toBe(1);
|
||||
});
|
||||
|
||||
it('respects user-level read access to the owning object', async () => {
|
||||
const userA = await Parse.User.signUp('relUserA', 'pw');
|
||||
const userB = await Parse.User.signUp('relUserB', 'pw');
|
||||
|
||||
const acl = new Parse.ACL();
|
||||
acl.setReadAccess(userA, true);
|
||||
const parent = new Parse.Object('RelParent', { name: 'user-scoped parent' });
|
||||
parent.setACL(acl);
|
||||
parent.relation('openRel').add(childLinked);
|
||||
await parent.save(null, { useMasterKey: true });
|
||||
|
||||
const resA = await queryChild(relatedToWhere(parent.id, 'openRel'), {
|
||||
'X-Parse-Session-Token': userA.getSessionToken(),
|
||||
});
|
||||
expect(resA.data.results.length).toBe(1);
|
||||
|
||||
const resB = await queryChild(relatedToWhere(parent.id, 'openRel'), {
|
||||
'X-Parse-Session-Token': userB.getSessionToken(),
|
||||
});
|
||||
expect(resB.data.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no results when the owning class denies get permission (CLP)', async () => {
|
||||
// Owning class denies public `get`, so the owning-object read throws
|
||||
// OPERATION_FORBIDDEN; the relation must then return no results.
|
||||
const schema = new Parse.Schema('RelParentNoGet');
|
||||
schema.addRelation('members', 'RelChild');
|
||||
schema.setCLP({
|
||||
find: { '*': true },
|
||||
get: {},
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: { '*': true },
|
||||
addField: {},
|
||||
});
|
||||
await schema.save();
|
||||
|
||||
const acl = new Parse.ACL();
|
||||
acl.setPublicReadAccess(true);
|
||||
const parent = new Parse.Object('RelParentNoGet', { name: 'no-get parent' });
|
||||
parent.setACL(acl);
|
||||
parent.relation('members').add(childLinked);
|
||||
await parent.save(null, { useMasterKey: true });
|
||||
|
||||
const res = await request({
|
||||
method: 'GET',
|
||||
url: `${Parse.serverURL}/classes/RelChild`,
|
||||
headers: {
|
||||
'X-Parse-Application-Id': Parse.applicationId,
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
},
|
||||
qs: {
|
||||
where: JSON.stringify({
|
||||
$relatedTo: {
|
||||
object: { __type: 'Pointer', className: 'RelParentNoGet', objectId: parent.id },
|
||||
key: 'members',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}).catch(e => e);
|
||||
expect(res.data.results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => {
|
||||
let obj;
|
||||
|
||||
@@ -6352,142 +5983,4 @@ describe('Vulnerabilities', () => {
|
||||
expect(req.info.clientSDK).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('(GHSA-75v4-m273-5j49) _User CLP refetch fallback leaks raw MFA secrets and protected fields', () => {
|
||||
const headers = {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-REST-API-Key': 'rest',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const denyGetCLP = {
|
||||
get: {},
|
||||
find: {},
|
||||
create: { '*': true },
|
||||
update: { '*': true },
|
||||
delete: {},
|
||||
};
|
||||
|
||||
const updateUserCLP = classLevelPermissions =>
|
||||
request({
|
||||
method: 'PUT',
|
||||
url: Parse.serverURL + '/schemas/_User',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ classLevelPermissions }),
|
||||
});
|
||||
|
||||
async function setupMfaUser() {
|
||||
const OTPAuth = require('otpauth');
|
||||
const user = await Parse.User.signUp('victim', 'password');
|
||||
const sessionToken = user.getSessionToken();
|
||||
user.set('phone', '555-1234');
|
||||
await user.save(null, { sessionToken });
|
||||
const secret = new OTPAuth.Secret();
|
||||
const totp = new OTPAuth.TOTP({ algorithm: 'SHA1', digits: 6, period: 30, secret });
|
||||
await user.save(
|
||||
{ authData: { mfa: { secret: secret.base32, token: totp.generate() } } },
|
||||
{ sessionToken }
|
||||
);
|
||||
return { user, totp, secret };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await reconfigureServer({
|
||||
auth: {
|
||||
mfa: { enabled: true, options: ['TOTP'], algorithm: 'SHA1', digits: 6, period: 30 },
|
||||
},
|
||||
protectedFields: { _User: { '*': ['phone'] } },
|
||||
protectedFieldsOwnerExempt: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not leak raw MFA secrets or protected fields from /verifyPassword when _User get CLP denies the re-fetch', async () => {
|
||||
await setupMfaUser();
|
||||
await updateUserCLP(denyGetCLP);
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/verifyPassword',
|
||||
headers,
|
||||
body: JSON.stringify({ username: 'victim', password: 'password' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
// Access control denied the re-fetch, so no stored fields may be disclosed
|
||||
expect(response.data.authData).toBeUndefined();
|
||||
expect(response.data.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not leak raw MFA secrets or protected fields from /login when _User get CLP denies the re-fetch', async () => {
|
||||
const { totp } = await setupMfaUser();
|
||||
await updateUserCLP(denyGetCLP);
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/login',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
username: 'victim',
|
||||
password: 'password',
|
||||
authData: { mfa: { token: totp.generate() } },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Login still succeeds and issues a session for the authenticated user
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
expect(response.data.sessionToken).toBeDefined();
|
||||
// But discloses no stored fields the caller may not read
|
||||
expect(response.data.authData).toBeUndefined();
|
||||
expect(response.data.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sanitizes MFA secrets and protected fields on /verifyPassword when get CLP permits the re-fetch', async () => {
|
||||
await setupMfaUser();
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/verifyPassword',
|
||||
headers,
|
||||
body: JSON.stringify({ username: 'victim', password: 'password' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
// afterFind replaces raw MFA material with a status flag
|
||||
expect(response.data.authData.mfa.status).toBe('enabled');
|
||||
expect(response.data.authData.mfa.secret).toBeUndefined();
|
||||
expect(response.data.authData.mfa.recovery).toBeUndefined();
|
||||
// protectedFieldsOwnerExempt:false strips protected fields even for the owner
|
||||
expect(response.data.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the full user to a master-key /verifyPassword even when get CLP is denied', async () => {
|
||||
await setupMfaUser();
|
||||
await updateUserCLP(denyGetCLP);
|
||||
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: Parse.serverURL + '/verifyPassword',
|
||||
headers: {
|
||||
'X-Parse-Application-Id': 'test',
|
||||
'X-Parse-Master-Key': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username: 'victim', password: 'password' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.objectId).toBeDefined();
|
||||
// Master bypasses CLP and protectedFields by design, so it still receives
|
||||
// the full record (auth hierarchy preserved); the minimal denied-path
|
||||
// response only applies to non-master callers.
|
||||
expect(response.data.phone).toBe('555-1234');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1144,169 +1144,38 @@ class DatabaseController {
|
||||
|
||||
// Modifies query so that it no longer has $relatedTo
|
||||
// Returns a promise that resolves when query is mutated
|
||||
reduceRelationKeys(
|
||||
className: string,
|
||||
query: any,
|
||||
queryOptions: any,
|
||||
auth: any = {},
|
||||
aclGroup: any[] = [],
|
||||
isMaster: boolean = false,
|
||||
schemaController: ?SchemaController.SchemaController
|
||||
): ?Promise<void> {
|
||||
reduceRelationKeys(className: string, query: any, queryOptions: any): ?Promise<void> {
|
||||
if (query['$or']) {
|
||||
return Promise.all(
|
||||
query['$or'].map(aQuery => {
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
aQuery,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
return this.reduceRelationKeys(className, aQuery, queryOptions);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (query['$and']) {
|
||||
return Promise.all(
|
||||
query['$and'].map(aQuery => {
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
aQuery,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (Array.isArray(query['$nor'])) {
|
||||
// Guard with Array.isArray (unlike the legacy $or/$and checks above) so a
|
||||
// malformed non-array $nor still falls through to validateQuery and yields
|
||||
// the existing INVALID_QUERY error instead of throwing here.
|
||||
return Promise.all(
|
||||
query['$nor'].map(aQuery => {
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
aQuery,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
return this.reduceRelationKeys(className, aQuery, queryOptions);
|
||||
})
|
||||
);
|
||||
}
|
||||
var relatedTo = query['$relatedTo'];
|
||||
if (relatedTo) {
|
||||
return this.authorizeRelatedToQuery(relatedTo, auth, aclGroup, isMaster, schemaController)
|
||||
.then(canReadOwningObject => {
|
||||
return this.relatedIds(
|
||||
relatedTo.object.className,
|
||||
relatedTo.key,
|
||||
relatedTo.object.objectId,
|
||||
queryOptions
|
||||
)
|
||||
.then(ids => {
|
||||
delete query['$relatedTo'];
|
||||
if (!canReadOwningObject) {
|
||||
// The caller is not allowed to read the owning object, so the
|
||||
// relation must not disclose any linked objects (and must not act
|
||||
// as a membership oracle for a known related id).
|
||||
this.addInObjectIdsIds([], query);
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
query,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
}
|
||||
return this.relatedIds(
|
||||
relatedTo.object.className,
|
||||
relatedTo.key,
|
||||
relatedTo.object.objectId,
|
||||
queryOptions
|
||||
).then(ids => {
|
||||
this.addInObjectIdsIds(ids, query);
|
||||
return this.reduceRelationKeys(
|
||||
className,
|
||||
query,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
);
|
||||
});
|
||||
this.addInObjectIdsIds(ids, query);
|
||||
return this.reduceRelationKeys(className, query, queryOptions);
|
||||
})
|
||||
.then(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Authorizes a `$relatedTo` relation query against the owning object before
|
||||
// its join table is read by `relatedIds`. Without this check, `$relatedTo`
|
||||
// bypasses both `protectedFields` and the owning object's ACL/CLP, because
|
||||
// the downstream protected-field and ACL filters only apply to the queried
|
||||
// (target) class, never to the owning class referenced by `$relatedTo`.
|
||||
//
|
||||
// - Throws `OPERATION_FORBIDDEN` if the relation key is a protected field on
|
||||
// the owning class for the caller's auth context (mirrors the protected
|
||||
// WHERE-field denial in `RestQuery.denyProtectedFields`).
|
||||
// - Resolves to `true` if the caller may read the owning object (so the join
|
||||
// table read may proceed), or `false` otherwise (so the relation yields no
|
||||
// results and cannot be used as a membership oracle).
|
||||
//
|
||||
// Master and maintenance requests bypass both checks by design.
|
||||
authorizeRelatedToQuery(
|
||||
relatedTo: any,
|
||||
auth: any = {},
|
||||
aclGroup: any[] = [],
|
||||
isMaster: boolean = false,
|
||||
schemaController: ?SchemaController.SchemaController
|
||||
): Promise<boolean> {
|
||||
if (isMaster) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const owningClassName = relatedTo && relatedTo.object && relatedTo.object.className;
|
||||
const owningId = relatedTo && relatedTo.object && relatedTo.object.objectId;
|
||||
const relationKey = relatedTo && relatedTo.key;
|
||||
return this.loadSchemaIfNeeded(schemaController).then(loadedSchema => {
|
||||
// 1. The relation key must not be a protected field on the owning class.
|
||||
const protectedFields =
|
||||
this.addProtectedFields(loadedSchema, owningClassName, {}, aclGroup, auth) || [];
|
||||
const rootField = typeof relationKey === 'string' ? relationKey.split('.')[0] : relationKey;
|
||||
if (protectedFields.includes(relationKey) || protectedFields.includes(rootField)) {
|
||||
throw createSanitizedError(
|
||||
Parse.Error.OPERATION_FORBIDDEN,
|
||||
`This user is not allowed to query ${relationKey} on class ${owningClassName}`,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
// 2. The caller must be able to read the owning object itself. A read with
|
||||
// the caller's auth context applies the owning class CLP, the object
|
||||
// ACL and pointer permissions. Any "not authorized" or "not found"
|
||||
// outcome maps to "cannot read", so the relation returns no results.
|
||||
return this.find(
|
||||
owningClassName,
|
||||
{ objectId: owningId },
|
||||
{ acl: aclGroup, limit: 1, keys: ['objectId'], op: 'get' },
|
||||
auth,
|
||||
loadedSchema
|
||||
)
|
||||
.then(results => Array.isArray(results) && results.length > 0)
|
||||
.catch(error => {
|
||||
if (
|
||||
error instanceof Parse.Error &&
|
||||
(error.code === Parse.Error.OPERATION_FORBIDDEN ||
|
||||
error.code === Parse.Error.OBJECT_NOT_FOUND)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addInObjectIdsIds(ids: ?Array<string> = null, query: any) {
|
||||
const idsFromString: ?Array<string> =
|
||||
typeof query.objectId === 'string' ? [query.objectId] : null;
|
||||
@@ -1472,17 +1341,7 @@ class DatabaseController {
|
||||
? Promise.resolve()
|
||||
: schemaController.validatePermission(className, aclGroup, op)
|
||||
)
|
||||
.then(() =>
|
||||
this.reduceRelationKeys(
|
||||
className,
|
||||
query,
|
||||
queryOptions,
|
||||
auth,
|
||||
aclGroup,
|
||||
isMaster,
|
||||
schemaController
|
||||
)
|
||||
)
|
||||
.then(() => this.reduceRelationKeys(className, query, queryOptions))
|
||||
.then(() => this.reduceInRelation(className, query, schemaController))
|
||||
.then(() => {
|
||||
let protectedFields;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { randomHexString } from '../cryptoUtils';
|
||||
import AdaptableController from './AdaptableController';
|
||||
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
|
||||
import path from 'path';
|
||||
const Parse = require('parse/node').Parse;
|
||||
const Utils = require('../Utils');
|
||||
|
||||
const legacyFilesRegex = new RegExp(
|
||||
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*'
|
||||
@@ -15,13 +15,12 @@ export class FilesController extends AdaptableController {
|
||||
}
|
||||
|
||||
async createFile(config, filename, data, contentType, options) {
|
||||
const extname = Utils.getFileExtension(filename);
|
||||
const extname = path.extname(filename);
|
||||
|
||||
const hasExtension = extname.length > 0;
|
||||
const mime = (await import('mime')).default
|
||||
if (!hasExtension && contentType && mime.getExtension(contentType)) {
|
||||
// Avoid producing a doubled dot when the filename already ends in one
|
||||
const separator = filename.endsWith('.') ? '' : '.';
|
||||
filename = filename + separator + mime.getExtension(contentType);
|
||||
filename = filename + '.' + mime.getExtension(contentType);
|
||||
} else if (hasExtension) {
|
||||
contentType = mime.getType(filename) || contentType;
|
||||
}
|
||||
|
||||
@@ -423,20 +423,14 @@ export class FilesRouter {
|
||||
}
|
||||
});
|
||||
};
|
||||
let extension = Utils.getFileExtension(filename);
|
||||
let extension = contentType;
|
||||
if (filename && filename.includes('.')) {
|
||||
extension = filename.substring(filename.lastIndexOf('.') + 1);
|
||||
} else if (contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1];
|
||||
}
|
||||
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
|
||||
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
|
||||
// If the filename has no usable extension (no dot, trailing dot, or
|
||||
// whitespace-only suffix), fall back to the Content-Type subtype — same
|
||||
// as a dotless filename.
|
||||
if (!extension && contentType && contentType.includes('/')) {
|
||||
extension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
// Last resort for malformed inputs (e.g. Content-Type without a slash):
|
||||
// use the raw Content-Type so the existing rejection path still fires.
|
||||
if (!extension && contentType) {
|
||||
extension = contentType.split(';')[0]?.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
if (extension && !isValidExtension(extension)) {
|
||||
next(
|
||||
|
||||
@@ -370,21 +370,10 @@ export class UsersRouter extends ClassesRouter {
|
||||
);
|
||||
filteredUser = filteredUserResponse.results?.[0];
|
||||
} catch {
|
||||
// The re-fetch enforces `_User` `get` CLP and may be denied by access
|
||||
// control (e.g. CLP `get: {}` or an ACL that excludes the caller).
|
||||
// Handled below; never fall back to the raw row.
|
||||
// re-fetch may fail for legacy users without ACL; fall through
|
||||
}
|
||||
if (!filteredUser) {
|
||||
// Master/maintenance callers bypass CLP, protectedFields, and authData
|
||||
// afterFind, so for them an empty re-fetch is a genuine not-found edge, not
|
||||
// an access-control denial; they are entitled to the full row. For every
|
||||
// other caller, an empty/denied re-fetch means access control withheld the
|
||||
// record, so disclose only the identity — never the raw row, which would
|
||||
// leak fields hidden by `protectedFields` and raw `authData` (e.g. MFA
|
||||
// secrets and recovery codes) that the sanitizing re-fetch would remove.
|
||||
// The session token is still attached below so login succeeds.
|
||||
filteredUser =
|
||||
req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
|
||||
filteredUser = user;
|
||||
}
|
||||
UsersRouter.removeHiddenProperties(filteredUser);
|
||||
filteredUser.sessionToken = user.sessionToken;
|
||||
@@ -483,20 +472,10 @@ export class UsersRouter extends ClassesRouter {
|
||||
);
|
||||
filteredUser = filteredUserResponse.results?.[0];
|
||||
} catch {
|
||||
// The re-fetch enforces `_User` `get` CLP and may be denied by access
|
||||
// control (e.g. CLP `get: {}` or an ACL that excludes the caller).
|
||||
// Handled below; never fall back to the raw row.
|
||||
// re-fetch may fail for legacy users without ACL; fall through
|
||||
}
|
||||
if (!filteredUser) {
|
||||
// See handleLogIn: master/maintenance callers bypass CLP,
|
||||
// protectedFields, and authData afterFind, so an empty re-fetch is a
|
||||
// genuine not-found edge for them and they are entitled to the full
|
||||
// row. For all other callers, an empty/denied re-fetch means access
|
||||
// control withheld the record, so disclose only the identity rather
|
||||
// than the raw row, which would leak protectedFields and raw authData
|
||||
// (e.g. MFA secrets and recovery codes).
|
||||
filteredUser =
|
||||
req.auth.isMaster || req.auth.isMaintenance ? user : { objectId: user.objectId };
|
||||
filteredUser = user;
|
||||
}
|
||||
UsersRouter.removeHiddenProperties(filteredUser);
|
||||
return { response: filteredUser };
|
||||
|
||||
@@ -576,23 +576,6 @@ class Utils {
|
||||
return Math.floor(num);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file extension as the substring after the last dot in the
|
||||
* filename. A trailing dot or a filename without a dot yields an empty
|
||||
* string. Callers apply any further normalization (whitespace, MIME
|
||||
* parameters, etc.) for their use case — this is a pure parser, not a
|
||||
* policy.
|
||||
*
|
||||
* @param {string} filename
|
||||
* @returns {string} the extension, or `''` if none
|
||||
*/
|
||||
static getFileExtension(filename) {
|
||||
if (!filename || !filename.includes('.')) {
|
||||
return '';
|
||||
}
|
||||
return filename.substring(filename.lastIndexOf('.') + 1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Utils;
|
||||
|
||||
Reference in New Issue
Block a user