Skip to content

Deleting a _Role does not clear the role cache, so revoked roles stay active for the cache TTL #10619

Description

@AdrianCurtin

New Issue Checklist

  • Searched existing issues, including closed ones, for role cache invalidation on delete. Related but distinct from Saving a _Role clears the entire cache, issuing FLUSHDB on the Redis cache adapter #10617, which covers the over-broad FLUSHDB on _Role writes; this is the opposite problem on the delete path.
  • 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:

Deletes do not go through RestWrite. They go through rest.js del(), 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

const express = require('express');
const { ParseServer, RedisCacheAdapter } = require('parse-server');
const { createClient } = require('redis');
const Parse = require('parse/node');

const REDIS_URL = 'redis://localhost:6399';
const PORT = 1341;
const HEADERS = { 'X-Parse-Application-Id': 'myAppId', 'X-Parse-Javascript-Key': 'myJsKey' };
const sleep = ms => new Promise(r => setTimeout(r, ms));

(async () => {
  const server = new ParseServer({
    databaseURI: 'mongodb://localhost:27399/roledelete',
    appId: 'myAppId',
    masterKey: 'myMasterKey',
    javascriptKey: 'myJsKey',
    serverURL: `http://localhost:${PORT}/parse`,
    cacheAdapter: new RedisCacheAdapter({ url: REDIS_URL }),
  });
  await server.start();
  const app = express();
  app.use('/parse', server.app);
  app.listen(PORT);

  Parse.initialize('myAppId', 'myJsKey', 'myMasterKey');
  Parse.serverURL = `http://localhost:${PORT}/parse`;

  const redis = createClient({ url: REDIS_URL });
  await redis.connect();
  await redis.sendCommand(['FLUSHDB']);

  // A user in a role.
  const signup = await fetch(`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;

  const acl = new Parse.ACL();
  acl.setPublicReadAccess(true);
  acl.setPublicWriteAccess(true);
  const role = new Parse.Role(`Doomed${Date.now()}`, acl);
  role.getUsers().add(Parse.User.createWithoutData(userId));
  await role.save(null, { useMasterKey: true });

  const roleKey = `myAppId:role:${userId}`;
  // Any authenticated non-master request populates the role cache.
  const warm = async () => {
    await fetch(`http://localhost:${PORT}/parse/classes/_User?limit=0`, {
      headers: { ...HEADERS, 'X-Parse-Session-Token': token },
    });
    await sleep(400);
  };

  await warm();
  console.log('1. after auth, cached closure :', await redis.get(roleKey));

  // CONTROL: an UPDATE to the role clears the cache.
  role.set('updatedMarker', Date.now());
  await role.save(null, { useMasterKey: true });
  await sleep(600);
  console.log('2. after role UPDATE          :', await redis.get(roleKey));

  // Re-warm, then DELETE the role.
  await warm();
  console.log('3. re-warmed closure          :', await redis.get(roleKey));
  await role.destroy({ useMasterKey: true });
  await sleep(600);
  console.log('4. after role DELETE          :', await redis.get(roleKey));

  const q = new Parse.Query(Parse.Role).equalTo('name', role.get('name'));
  console.log('5. role row still in database :', (await q.first({ useMasterKey: true })) ? 'YES' : 'NO (deleted)');
  console.log('6. PTTL on the stale entry    :', await redis.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.js del(), outside the hasTriggers || hasLiveQuery || className == '_Session' branch so it runs unconditionally:

if (className === '_Role') {
  config.cacheController.role.clear();
  if (config.liveQueryController) {
    config.liveQueryController.clearCachedRoles(auth.user);
  }
}

With #10618 merged, role.clear() is a scoped SCAN over <appId>:role:* rather than a FLUSHDB, so adding this call on the delete path is inexpensive.

I am happy to open a PR for this.

Environment

Server

  • Parse Server version: 9.10.0, also present on alpha at 315e157
  • Operating system: macOS 26.5.2, Node.js 24.12.0
  • Local or remote host: local

Database

  • System (MongoDB or Postgres): MongoDB
  • Database version: 8.2.9 (Docker mongo:8)
  • Local or remote host: local

Client

  • SDK (iOS, Android, JavaScript, PHP, Unity, etc): not client specific, reproduced through the REST API and the JS SDK
  • SDK version: parse 8.6.0 (the version bundled with Parse Server 9.10.0)

Cache

  • Adapter: RedisCacheAdapter
  • Redis version: 7.4.10 (Docker redis:7)
  • redis npm package version: 5.11.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions