Skip to content

Commit 4d48847

Browse files
authored
fix: Protected fields bypass via LiveQuery subscription WHERE clause ([GHSA-j7mm-f4rv-6q6q](GHSA-j7mm-f4rv-6q6q)) (#10175)
1 parent 0744225 commit 4d48847

File tree

2 files changed

+242
-0
lines changed

2 files changed

+242
-0
lines changed

spec/vulnerabilities.spec.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,6 +1706,213 @@ describe('(GHSA-r2m8-pxm9-9c4g) Protected fields WHERE clause bypass via dot-not
17061706
});
17071707
});
17081708

1709+
describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => {
1710+
let obj;
1711+
1712+
beforeEach(async () => {
1713+
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
1714+
await reconfigureServer({
1715+
liveQuery: { classNames: ['SecretClass'] },
1716+
startLiveQueryServer: true,
1717+
verbose: false,
1718+
silent: true,
1719+
});
1720+
const config = Config.get(Parse.applicationId);
1721+
const schemaController = await config.database.loadSchema();
1722+
await schemaController.addClassIfNotExists(
1723+
'SecretClass',
1724+
{ secretObj: { type: 'Object' }, publicField: { type: 'String' } },
1725+
);
1726+
await schemaController.updateClass(
1727+
'SecretClass',
1728+
{},
1729+
{
1730+
find: { '*': true },
1731+
get: { '*': true },
1732+
create: { '*': true },
1733+
update: { '*': true },
1734+
delete: { '*': true },
1735+
addField: {},
1736+
protectedFields: { '*': ['secretObj'] },
1737+
}
1738+
);
1739+
1740+
obj = new Parse.Object('SecretClass');
1741+
obj.set('secretObj', { apiKey: 'SENSITIVE_KEY_123', score: 42 });
1742+
obj.set('publicField', 'visible');
1743+
await obj.save(null, { useMasterKey: true });
1744+
});
1745+
1746+
afterEach(async () => {
1747+
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
1748+
if (client) {
1749+
await client.close();
1750+
}
1751+
});
1752+
1753+
it('should reject LiveQuery subscription with dot-notation on protected field in where clause', async () => {
1754+
const query = new Parse.Query('SecretClass');
1755+
query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
1756+
await expectAsync(query.subscribe()).toBeRejectedWith(
1757+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1758+
);
1759+
});
1760+
1761+
it('should reject LiveQuery subscription with protected field directly in where clause', async () => {
1762+
const query = new Parse.Query('SecretClass');
1763+
query.exists('secretObj');
1764+
await expectAsync(query.subscribe()).toBeRejectedWith(
1765+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1766+
);
1767+
});
1768+
1769+
it('should reject LiveQuery subscription with protected field in $or', async () => {
1770+
const q1 = new Parse.Query('SecretClass');
1771+
q1._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
1772+
const q2 = new Parse.Query('SecretClass');
1773+
q2._addCondition('secretObj.apiKey', '$eq', 'other');
1774+
const query = Parse.Query.or(q1, q2);
1775+
await expectAsync(query.subscribe()).toBeRejectedWith(
1776+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1777+
);
1778+
});
1779+
1780+
it('should reject LiveQuery subscription with protected field in $and', async () => {
1781+
// Build $and manually since Parse SDK doesn't expose it directly
1782+
const query = new Parse.Query('SecretClass');
1783+
query._where = { $and: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }, { publicField: 'visible' }] };
1784+
await expectAsync(query.subscribe()).toBeRejectedWith(
1785+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1786+
);
1787+
});
1788+
1789+
it('should reject LiveQuery subscription with protected field in $nor', async () => {
1790+
// Build $nor manually since Parse SDK doesn't expose it directly
1791+
const query = new Parse.Query('SecretClass');
1792+
query._where = { $nor: [{ 'secretObj.apiKey': 'SENSITIVE_KEY_123' }] };
1793+
await expectAsync(query.subscribe()).toBeRejectedWith(
1794+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1795+
);
1796+
});
1797+
1798+
it('should reject LiveQuery subscription with $regex on protected field (boolean oracle)', async () => {
1799+
const query = new Parse.Query('SecretClass');
1800+
query._addCondition('secretObj.apiKey', '$regex', '^S');
1801+
await expectAsync(query.subscribe()).toBeRejectedWith(
1802+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1803+
);
1804+
});
1805+
1806+
it('should reject LiveQuery subscription with deeply nested dot-notation on protected field', async () => {
1807+
const query = new Parse.Query('SecretClass');
1808+
query._addCondition('secretObj.nested.deep.key', '$eq', 'value');
1809+
await expectAsync(query.subscribe()).toBeRejectedWith(
1810+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1811+
);
1812+
});
1813+
1814+
it('should allow LiveQuery subscription on non-protected fields and strip protected fields from response', async () => {
1815+
const query = new Parse.Query('SecretClass');
1816+
query.exists('publicField');
1817+
const subscription = await query.subscribe();
1818+
await Promise.all([
1819+
new Promise(resolve => {
1820+
subscription.on('update', object => {
1821+
expect(object.get('secretObj')).toBeUndefined();
1822+
expect(object.get('publicField')).toBe('updated');
1823+
resolve();
1824+
});
1825+
}),
1826+
obj.save({ publicField: 'updated' }, { useMasterKey: true }),
1827+
]);
1828+
});
1829+
1830+
it('should reject admin user querying protected field when both * and role protect it', async () => {
1831+
// Common case: protectedFields has both '*' and 'role:admin' entries.
1832+
// Even without resolving user roles, the '*' protection applies and blocks the query.
1833+
// This validates that role-based exemptions are irrelevant when '*' covers the field.
1834+
const config = Config.get(Parse.applicationId);
1835+
const schemaController = await config.database.loadSchema();
1836+
await schemaController.updateClass(
1837+
'SecretClass',
1838+
{},
1839+
{
1840+
find: { '*': true },
1841+
get: { '*': true },
1842+
create: { '*': true },
1843+
update: { '*': true },
1844+
delete: { '*': true },
1845+
addField: {},
1846+
protectedFields: { '*': ['secretObj'], 'role:admin': ['secretObj'] },
1847+
}
1848+
);
1849+
1850+
const user = new Parse.User();
1851+
user.setUsername('adminuser');
1852+
user.setPassword('password');
1853+
await user.signUp();
1854+
1855+
const roleACL = new Parse.ACL();
1856+
roleACL.setPublicReadAccess(true);
1857+
const role = new Parse.Role('admin', roleACL);
1858+
role.getUsers().add(user);
1859+
await role.save(null, { useMasterKey: true });
1860+
1861+
const query = new Parse.Query('SecretClass');
1862+
query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
1863+
await expectAsync(query.subscribe(user.getSessionToken())).toBeRejectedWith(
1864+
new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Permission denied')
1865+
);
1866+
});
1867+
1868+
it('should not reject when role-only protection exists without * entry', async () => {
1869+
// Edge case: protectedFields only has a role entry, no '*'.
1870+
// Without resolving roles, the protection set is empty, so the subscription is allowed.
1871+
// This is a correctness gap, not a security issue: the role entry means "protect this
1872+
// field FROM role members" (i.e. admins should not see it). Not resolving roles means
1873+
// the admin loses their own restriction — they see data meant to be hidden from them.
1874+
// This does not allow unprivileged users to access protected data.
1875+
const config = Config.get(Parse.applicationId);
1876+
const schemaController = await config.database.loadSchema();
1877+
await schemaController.updateClass(
1878+
'SecretClass',
1879+
{},
1880+
{
1881+
find: { '*': true },
1882+
get: { '*': true },
1883+
create: { '*': true },
1884+
update: { '*': true },
1885+
delete: { '*': true },
1886+
addField: {},
1887+
protectedFields: { 'role:admin': ['secretObj'] },
1888+
}
1889+
);
1890+
1891+
const user = new Parse.User();
1892+
user.setUsername('adminuser2');
1893+
user.setPassword('password');
1894+
await user.signUp();
1895+
1896+
const roleACL = new Parse.ACL();
1897+
roleACL.setPublicReadAccess(true);
1898+
const role = new Parse.Role('admin', roleACL);
1899+
role.getUsers().add(user);
1900+
await role.save(null, { useMasterKey: true });
1901+
1902+
// This subscribes successfully because without '*' entry, no fields are protected
1903+
// for purposes of WHERE clause validation. The role-only config means "hide secretObj
1904+
// from admins" — a restriction ON the privileged user, not a security boundary.
1905+
const query = new Parse.Query('SecretClass');
1906+
query._addCondition('secretObj.apiKey', '$eq', 'SENSITIVE_KEY_123');
1907+
const subscription = await query.subscribe(user.getSessionToken());
1908+
expect(subscription).toBeDefined();
1909+
});
1910+
1911+
// Note: master key bypass is inherently tested by the `!client.hasMasterKey` guard
1912+
// in the implementation. Testing master key LiveQuery requires configuring keyPairs
1913+
// in the LiveQuery server config, which is not part of the default test setup.
1914+
});
1915+
17091916
describe('(GHSA-w54v-hf9p-8856) User enumeration via email verification endpoint', () => {
17101917
let sendVerificationEmail;
17111918

src/LiveQuery/ParseLiveQueryServer.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -916,6 +916,41 @@ class ParseLiveQueryServer {
916916
op
917917
);
918918

919+
// Check protected fields in WHERE clause
920+
if (!client.hasMasterKey) {
921+
const auth = request.user ? { user: request.user, userRoles: [] } : {};
922+
const protectedFields =
923+
appConfig.database.addProtectedFields(
924+
classLevelPermissions,
925+
className,
926+
request.query.where,
927+
aclGroup,
928+
auth
929+
) || [];
930+
if (protectedFields.length > 0 && request.query.where) {
931+
const checkWhere = (where: any) => {
932+
if (typeof where !== 'object' || where === null) {
933+
return;
934+
}
935+
for (const whereKey of Object.keys(where)) {
936+
const rootField = whereKey.split('.')[0];
937+
if (protectedFields.includes(whereKey) || protectedFields.includes(rootField)) {
938+
throw new Parse.Error(
939+
Parse.Error.OPERATION_FORBIDDEN,
940+
'Permission denied'
941+
);
942+
}
943+
}
944+
for (const op of ['$or', '$and', '$nor']) {
945+
if (Array.isArray(where[op])) {
946+
where[op].forEach((subQuery: any) => checkWhere(subQuery));
947+
}
948+
}
949+
};
950+
checkWhere(request.query.where);
951+
}
952+
}
953+
919954
// Get subscription from subscriptions, create one if necessary
920955
const subscriptionHash = queryHash(request.query);
921956
// Add className to subscriptions if necessary

0 commit comments

Comments
 (0)