parse-community / parse-community/parse-server
Saving a _Role clears the entire cache, issuing FLUSHDB on the Redis cache adapter
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 21.4k
- Forks
- 4.8k
- Avg merge
- 7h 45m
- Merged PRs (30d)
- 11
Description
New Issue Checklist
- Searched existing issues, including closed ones, for
FLUSHDB,SubCacheand cache clearing. The only related item is #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:
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, 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 theCacheAdaptercontract. Adapters that do not implement it keep today's unscoped behavior, so external adapters continue to work unchanged. - Have
SubCache#clear()passjoinKeys(appId, this.prefix)andCacheController#clear()passappId. - Implement it in
RedisCacheAdapterwithSCANusingMATCH <prefix>:*andUNLINK, glob-escaping the appId and routing through the existingKeyPromiseQueue. Unscopedclear()keepsFLUSHDB, soTestUtilsteardown is unaffected. - Implement it in
InMemoryCacheAdapterby 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 onalphaat315e157 - Operating system:
macOS 26.5.2, Node.js24.12.0 - Local or remote host: local
Database
- System (MongoDB or Postgres):
MongoDB - Database version:
8.2.9(Dockermongo: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(Dockerredis:7) redisnpm 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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read src/Controllers/CacheController.js and src/Adapters/Cache/RedisCacheAdapter.js, then trace RestWrite.js and PurgeRouter.js; start by running the supplied Redis reproduction. Done means a _Role write clears only its role-prefixed keys while unrelated Parse and Redis keys remain, with the relevant adapter behavior covered.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nodejs, redis
- Domain
- backend, databases, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100