Skip to content

Saving a _Role clears the entire cache, issuing FLUSHDB on the Redis cache adapter #10617

Description

@AdrianCurtin

New Issue Checklist

  • Searched existing issues, including closed ones, for FLUSHDB, SubCache and cache clearing. The only related item is Use flushdb instead of flushall in RedisCacheAdapter #3523.
  • Filing publicly rather than confidentially: the primary impact is a cache availability problem, the secondary impact requires an operator to share the Redis database, and the code path is plainly visible in the source. Happy to move this to a private report if maintainers prefer.

Issue Description

Saving any _Role object clears the entire cache, not just the role subcache. With RedisCacheAdapter this is a raw FLUSHDB, which deletes every key in the configured Redis database.

The cause is that SubCache#clear() drops the key prefix that every other method on the class applies:

  1. RestWrite.js#L1566 calls this.config.cacheController.role.clear() on every _Role create or update.
  2. CacheController.js#L37-L39: SubCache#clear() ignores this.prefix and delegates straight to the parent controller.
  3. CacheController.js#L66-L68: CacheController#clear() likewise ignores this.appId and delegates to the adapter.
  4. RedisCacheAdapter.js#L83-L87: clear() issues FLUSHDB.

The asymmetry is what makes this easy to miss: get, put and del on SubCache all correctly build joinKeys(this.prefix, key), and CacheController correctly prefixes appId. Only clear discards both. Reading the class, cacheController.role.clear() looks like it clears cached roles.

PurgeRouter.js reaches the same code path through cacheAdapter.user.clear() and cacheAdapter.role.clear().

Impact 1: every _Role write invalidates the whole cache, including all sessions

This needs no misconfiguration and no attacker. Many apps write _Role as part of ordinary operation, for example creating a role per organization, workspace, team or project at signup. Every one of those writes evicts all cached entries for every app served by that Parse Server, including the <appId>:user:<sessionToken> entries that back session authentication. Each flush is therefore followed by a burst of uncached session lookups against the database.

At any meaningful rate of role creation, the cache is being destroyed continuously and never gets to serve anything, which defeats the purpose of configuring a shared cache adapter at all. Clearing the role subcache should not invalidate user sessions.

Impact 2: destruction of unrelated data sharing the Redis database

Deployments frequently have one Redis instance and one URL available, for example a single REDIS_URL or REDISCLOUD_URL add-on, so the same database ends up holding the Parse Server cache alongside a job queue (Sidekiq, BullMQ, Celery), rate limiter state, or application data. A single _Role write silently destroys all of it: queued and scheduled jobs, retry and dead sets, worker registrations, and distributed locks.

Nothing in the Parse Server documentation states that the cache adapter requires an exclusively owned Redis database. Redis pub/sub channels are not affected by FLUSHDB, so a co-located LiveQuery pub/sub setup keeps working and the problem is easy to miss during evaluation.

Impact 3: unprivileged trigger where _Role writes are permitted

If an app's CLP allows non-master-key writes to _Role, any such client can trigger an unbounded number of FLUSHDB commands, which is a cheap way to hold the cache permanently empty.

Separately, role.clear() at RestWrite.js#L1566 is not awaited, so the FLUSHDB can land after subsequent writes to the cache.

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 = 1339;
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/psrepro',
    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']);
  await redis.sendCommand(['CONFIG', 'RESETSTAT']);

  // A logged-in user, so Parse Server populates its own session cache.
  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());
  await fetch(`http://localhost:${PORT}/parse/users/me`, {
    headers: { ...HEADERS, 'X-Parse-Session-Token': signup.sessionToken },
  });
  await sleep(300);

  // A key owned by something else sharing this Redis database.
  await redis.set('queue:default', 'a-queued-job');
  console.log('BEFORE:', (await redis.keys('*')).sort());

  // Any _Role write.
  const acl = new Parse.ACL();
  acl.setPublicReadAccess(true);
  await new Parse.Role(`Admin${Date.now()}`, acl).save(null, { useMasterKey: true });
  await sleep(1000);

  console.log('AFTER: ', (await redis.keys('*')).sort());
  console.log('queue:default =', await redis.get('queue:default'));
  console.log(
    String(await redis.sendCommand(['INFO', 'commandstats']))
      .split('\n')
      .filter(l => /flushdb/i.test(l))
      .join('\n')
  );
  process.exit(0);
})();

Actual Outcome

BEFORE: [
  'myAppId:role:jLz8Tmbb22',
  'myAppId:user:r:60b85fb0183f0b8058c3a0aae8f4f999',
  'queue:default'
]
AFTER:  []
queue:default = null
cmdstat_flushdb:calls=1,usec=213,usec_per_call=213.00,rejected_calls=0,failed_calls=0

A single _Role save issued one FLUSHDB. It removed the role cache entry, the cached session for an unrelated logged-in user, and a key that Parse Server never owned.

Expected Outcome

AFTER:  [
  'myAppId:user:r:60b85fb0183f0b8058c3a0aae8f4f999',
  'queue:default'
]
queue:default = a-queued-job

Clearing the role subcache should delete only the keys it owns, that is <appId>:role:*. Entries belonging to other subcaches, other Parse apps, and other applications sharing the database should be untouched.

Suggested fix

This can be fixed without breaking third-party cache adapters, by making the scoped clear optional:

  • Add an optional clear(prefix) to the CacheAdapter contract. Adapters that do not implement it keep today's unscoped behavior, so external adapters continue to work unchanged.
  • Have SubCache#clear() pass joinKeys(appId, this.prefix) and CacheController#clear() pass appId.
  • Implement it in RedisCacheAdapter with SCAN using MATCH <prefix>:* and UNLINK, glob-escaping the appId and routing through the existing KeyPromiseQueue. Unscoped clear() keeps FLUSHDB, so TestUtils teardown is unaffected.
  • Implement it in InMemoryCacheAdapter by filtering its own keys.

This mirrors the reasoning in #3523, which narrowed the adapter from FLUSHALL to FLUSHDB for the same class of problem. Narrowing further, to the key prefix, removes the remaining assumption that Parse Server exclusively owns the database.

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

Logs

redis-cli INFO commandstats after a single _Role save, with CONFIG RESETSTAT immediately before:

cmdstat_flushdb:calls=1,usec=213,usec_per_call=213.00,rejected_calls=0,failed_calls=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