You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Filing publicly rather than confidentially: the exposure window is bounded by the cache TTL and requires an operator to have deleted a role, so it is a correctness bug with a security consequence rather than a directly exploitable vulnerability. Happy to move it to a private report if maintainers prefer.
Issue Description
Deleting a _Role does not invalidate the role cache. Every user who held that role keeps it in their cached role closure until the entry expires, so ACLs granting access to role:<name> continue to be honored for a role that no longer exists.
cacheController.role.clear() is called from exactly two places:
RestWrite.js#L1566, in runDatabaseOperation, guarded by className === '_Role'. This covers create and update only.
Deletes do not go through RestWrite. They go through rest.jsdel(), which contains no role cache handling at all. Its only cache interaction is cacheAdapter.user.del(firstResult.sessionToken) at rest.js:199, and even that sits inside if (hasTriggers || hasLiveQuery || className == '_Session') (rest.js:181), so for a _Role with no triggers registered the branch is never entered.
The asymmetry is what makes this clearly unintended rather than a deliberate tradeoff. Adding or removing a user from a role is a _Role update, and it clears the cache correctly. Deleting the entire role has the same security-relevant effect, revoking a capability from every member at once, and clears nothing.
RestWrite.js:1564-1568 also calls liveQueryController.clearCachedRoles(this.auth.user) alongside the cache clear. The delete path skips that too.
Steps to reproduce
constexpress=require('express');const{ ParseServer, RedisCacheAdapter }=require('parse-server');const{ createClient }=require('redis');constParse=require('parse/node');constREDIS_URL='redis://localhost:6399';constPORT=1341;constHEADERS={'X-Parse-Application-Id': 'myAppId','X-Parse-Javascript-Key': 'myJsKey'};constsleep=ms=>newPromise(r=>setTimeout(r,ms));(async()=>{constserver=newParseServer({databaseURI: 'mongodb://localhost:27399/roledelete',appId: 'myAppId',masterKey: 'myMasterKey',javascriptKey: 'myJsKey',serverURL: `http://localhost:${PORT}/parse`,cacheAdapter: newRedisCacheAdapter({url: REDIS_URL}),});awaitserver.start();constapp=express();app.use('/parse',server.app);app.listen(PORT);Parse.initialize('myAppId','myJsKey','myMasterKey');Parse.serverURL=`http://localhost:${PORT}/parse`;constredis=createClient({url: REDIS_URL});awaitredis.connect();awaitredis.sendCommand(['FLUSHDB']);// A user in a role.constsignup=awaitfetch(`http://localhost:${PORT}/parse/users`,{method: 'POST',headers: { ...HEADERS,'Content-Type': 'application/json'},body: JSON.stringify({username: `u${Date.now()}`,password: 'pw'}),}).then(r=>r.json());const{sessionToken: token,objectId: userId}=signup;constacl=newParse.ACL();acl.setPublicReadAccess(true);acl.setPublicWriteAccess(true);constrole=newParse.Role(`Doomed${Date.now()}`,acl);role.getUsers().add(Parse.User.createWithoutData(userId));awaitrole.save(null,{useMasterKey: true});constroleKey=`myAppId:role:${userId}`;// Any authenticated non-master request populates the role cache.constwarm=async()=>{awaitfetch(`http://localhost:${PORT}/parse/classes/_User?limit=0`,{headers: { ...HEADERS,'X-Parse-Session-Token': token},});awaitsleep(400);};awaitwarm();console.log('1. after auth, cached closure :',awaitredis.get(roleKey));// CONTROL: an UPDATE to the role clears the cache.role.set('updatedMarker',Date.now());awaitrole.save(null,{useMasterKey: true});awaitsleep(600);console.log('2. after role UPDATE :',awaitredis.get(roleKey));// Re-warm, then DELETE the role.awaitwarm();console.log('3. re-warmed closure :',awaitredis.get(roleKey));awaitrole.destroy({useMasterKey: true});awaitsleep(600);console.log('4. after role DELETE :',awaitredis.get(roleKey));constq=newParse.Query(Parse.Role).equalTo('name',role.get('name'));console.log('5. role row still in database :',(awaitq.first({useMasterKey: true})) ? 'YES' : 'NO (deleted)');console.log('6. PTTL on the stale entry :',awaitredis.sendCommand(['PTTL',roleKey]),'ms');process.exit(0);})();
Actual Outcome
1. after auth, cached closure : ["role:Doomed1785563560629"]
2. after role UPDATE : null
3. re-warmed closure : ["role:Doomed1785563560629"]
4. after role DELETE : ["role:Doomed1785563560629"]
5. role row still in database : NO (deleted)
6. PTTL on the stale entry : 28983 ms
Step 2 shows the update path clearing correctly. Step 4 shows the delete path leaving the closure intact, and step 5 confirms the role itself is gone. For the remaining 29 seconds, auth.getUserRoles() returns ["role:Doomed..."] for that user, and any object whose ACL grants role:Doomed... is readable and writable by them.
Expected Outcome
4. after role DELETE : null
Deleting a _Role should invalidate the role cache exactly as creating or updating one does.
Behavior Notes
Adapter independent. The clear is simply never called, so it affects the in-memory adapter too. Only the exposure window differs: cacheTTL (default 5000ms) for InMemoryCacheAdapter, DEFAULT_REDIS_TTL (30 seconds) for RedisCacheAdapter, and unbounded if an operator constructs the Redis adapter with Infinity.
Deleting a role that is a parent in a role hierarchy has the same effect on every child role's members, since the cached value is the flattened transitive closure produced by _getAllRolesNamesForRoleIds.
Role deletion is the one role mutation an operator is most likely to treat as an immediate revocation, which makes the silent delay the surprising part.
Suggested fix
Mirror the RestWrite behavior in the delete path. In rest.jsdel(), outside the hasTriggers || hasLiveQuery || className == '_Session' branch so it runs unconditionally:
New Issue Checklist
FLUSHDBon_Rolewrites; this is the opposite problem on the delete path.Issue Description
Deleting a
_Roledoes not invalidate the role cache. Every user who held that role keeps it in their cached role closure until the entry expires, so ACLs granting access torole:<name>continue to be honored for a role that no longer exists.cacheController.role.clear()is called from exactly two places:RestWrite.js#L1566, inrunDatabaseOperation, guarded byclassName === '_Role'. This covers create and update only.PurgeRouter.js#L22.Deletes do not go through
RestWrite. They go throughrest.jsdel(), which contains no role cache handling at all. Its only cache interaction iscacheAdapter.user.del(firstResult.sessionToken)atrest.js:199, and even that sits insideif (hasTriggers || hasLiveQuery || className == '_Session')(rest.js:181), so for a_Rolewith no triggers registered the branch is never entered.The asymmetry is what makes this clearly unintended rather than a deliberate tradeoff. Adding or removing a user from a role is a
_Roleupdate, and it clears the cache correctly. Deleting the entire role has the same security-relevant effect, revoking a capability from every member at once, and clears nothing.RestWrite.js:1564-1568also callsliveQueryController.clearCachedRoles(this.auth.user)alongside the cache clear. The delete path skips that too.Steps to reproduce
Actual Outcome
Step 2 shows the update path clearing correctly. Step 4 shows the delete path leaving the closure intact, and step 5 confirms the role itself is gone. For the remaining 29 seconds,
auth.getUserRoles()returns["role:Doomed..."]for that user, and any object whose ACL grantsrole:Doomed...is readable and writable by them.Expected Outcome
Deleting a
_Roleshould invalidate the role cache exactly as creating or updating one does.Behavior Notes
cacheTTL(default 5000ms) forInMemoryCacheAdapter,DEFAULT_REDIS_TTL(30 seconds) forRedisCacheAdapter, and unbounded if an operator constructs the Redis adapter withInfinity._getAllRolesNamesForRoleIds.Suggested fix
Mirror the
RestWritebehavior in the delete path. Inrest.jsdel(), outside thehasTriggers || hasLiveQuery || className == '_Session'branch so it runs unconditionally:With #10618 merged,
role.clear()is a scopedSCANover<appId>:role:*rather than aFLUSHDB, so adding this call on the delete path is inexpensive.I am happy to open a PR for this.
Environment
Server
9.10.0, also present onalphaat315e157macOS 26.5.2, Node.js24.12.0Database
MongoDB8.2.9(Dockermongo:8)Client
parse8.6.0(the version bundled with Parse Server 9.10.0)Cache
RedisCacheAdapter7.4.10(Dockerredis:7)redisnpm package version:5.11.0