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 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:
RestWrite.js#L1566 calls this.config.cacheController.role.clear() on every _Role create or update.
CacheController.js#L37-L39: SubCache#clear() ignores this.prefix and delegates straight to the parent controller.
CacheController.js#L66-L68: CacheController#clear() likewise ignores this.appId and delegates to the adapter.
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
constexpress=require('express');const{ ParseServer, RedisCacheAdapter }=require('parse-server');const{ createClient }=require('redis');constParse=require('parse/node');constREDIS_URL='redis://localhost:6399';constPORT=1339;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/psrepro',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']);awaitredis.sendCommand(['CONFIG','RESETSTAT']);// A logged-in user, so Parse Server populates its own session cache.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());awaitfetch(`http://localhost:${PORT}/parse/users/me`,{headers: { ...HEADERS,'X-Parse-Session-Token': signup.sessionToken},});awaitsleep(300);// A key owned by something else sharing this Redis database.awaitredis.set('queue:default','a-queued-job');console.log('BEFORE:',(awaitredis.keys('*')).sort());// Any _Role write.constacl=newParse.ACL();acl.setPublicReadAccess(true);awaitnewParse.Role(`Admin${Date.now()}`,acl).save(null,{useMasterKey: true});awaitsleep(1000);console.log('AFTER: ',(awaitredis.keys('*')).sort());console.log('queue:default =',awaitredis.get('queue:default'));console.log(String(awaitredis.sendCommand(['INFO','commandstats'])).split('\n').filter(l=>/flushdb/i.test(l)).join('\n'));process.exit(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.
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: parse8.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:
New Issue Checklist
FLUSHDB,SubCacheand cache clearing. The only related item is Use flushdb instead of flushall in RedisCacheAdapter #3523.Issue Description
Saving any
_Roleobject clears the entire cache, not just the role subcache. WithRedisCacheAdapterthis is a rawFLUSHDB, 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:RestWrite.js#L1566callsthis.config.cacheController.role.clear()on every_Rolecreate or update.CacheController.js#L37-L39:SubCache#clear()ignoresthis.prefixand delegates straight to the parent controller.CacheController.js#L66-L68:CacheController#clear()likewise ignoresthis.appIdand delegates to the adapter.RedisCacheAdapter.js#L83-L87:clear()issuesFLUSHDB.The asymmetry is what makes this easy to miss:
get,putanddelonSubCacheall correctly buildjoinKeys(this.prefix, key), andCacheControllercorrectly prefixesappId. Onlycleardiscards both. Reading the class,cacheController.role.clear()looks like it clears cached roles.PurgeRouter.jsreaches the same code path throughcacheAdapter.user.clear()andcacheAdapter.role.clear().Impact 1: every
_Rolewrite invalidates the whole cache, including all sessionsThis needs no misconfiguration and no attacker. Many apps write
_Roleas 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_URLorREDISCLOUD_URLadd-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_Rolewrite 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
_Rolewrites are permittedIf an app's CLP allows non-master-key writes to
_Role, any such client can trigger an unbounded number ofFLUSHDBcommands, which is a cheap way to hold the cache permanently empty.Separately,
role.clear()atRestWrite.js#L1566is not awaited, so theFLUSHDBcan land after subsequent writes to the cache.Steps to reproduce
Actual Outcome
A single
_Rolesave issued oneFLUSHDB. 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
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:
clear(prefix)to theCacheAdaptercontract. Adapters that do not implement it keep today's unscoped behavior, so external adapters continue to work unchanged.SubCache#clear()passjoinKeys(appId, this.prefix)andCacheController#clear()passappId.RedisCacheAdapterwithSCANusingMATCH <prefix>:*andUNLINK, glob-escaping the appId and routing through the existingKeyPromiseQueue. Unscopedclear()keepsFLUSHDB, soTestUtilsteardown is unaffected.InMemoryCacheAdapterby filtering its own keys.This mirrors the reasoning in #3523, which narrowed the adapter from
FLUSHALLtoFLUSHDBfor 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
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.0Logs
redis-cli INFO commandstatsafter a single_Rolesave, withCONFIG RESETSTATimmediately before: