Compare commits

..
Author SHA1 Message Date
GitHub Actions c692c3e6e6 empty commit to trigger CI 2026-06-01 00:46:13 +00:00
11 changed files with 30 additions and 800 deletions
-35
View File
@@ -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 },
-21
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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": {
-21
View File
@@ -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);
});
}
});
});
-507
View File
@@ -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');
});
});
});
+13 -154
View File
@@ -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;
+4 -5
View File
@@ -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;
}
+6 -12
View File
@@ -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(
+4 -25
View File
@@ -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 };
-17
View File
@@ -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;