Parse Dashboard Has a Cache Key Collision that Leaks Master Key to Read-Only Sessions
Description
Parse Dashboard is a standalone dashboard for managing Parse Server apps. In versions 7.3.0-alpha.42 through 9.0.0-alpha.7, the ConfigKeyCache uses the same cache key for both master key and read-only master key when resolving function-typed keys. Under specific timing conditions, a read-only user can receive the cached full master key, or a regular user can receive the cached read-only master key. The fix in version 9.0.0-alpha.8 uses distinct cache keys for master key and read-only master key. As a workaround, avoid using function-typed master keys, or remove the agent configuration block from your dashboard configuration.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Parse Dashboard versions 7.3.0-alpha.42 through 9.0.0-alpha.7 use the same cache key collision-prone cache keys for master key and read-only master key, potentially leaking elevated privileges to lower-privileged users.
Vulnerability
Overview
Parse Dashboard, a standalone management interface for Parse Server apps, contains a cache key collision vulnerability in its ConfigKeyCache component. In affected versions (7.3.0-alpha.42 through 9.0.0-alpha.7), the cache uses the same key for both the full master key and the read-only master key when resolving function-typed keys [2]. This design flaw means that under specific timing conditions, a read-only user may receive the cached full master key, or a regular user may receive the cached read-only master key [2].
Exploitation
Context
The vulnerability is exploitable only when function-typed master keys are used in the dashboard configuration [2]. An attacker must have network access to the dashboard and be able to trigger a cache race condition. The attack requires no special privileges beyond a valid read-only or regular user session, but the timing window is narrow [3]. The advisory notes that the attack complexity is high, as the attacker must win a race condition to retrieve the wrongfully retrieve the cached key [3].
Impact
If successfully exploited, a read-only user could gain full master key access, granting them full administrative control over the Parse Server, including the ability to read, modify, or delete all data and execute privileged operations [2]. Conversely, a regular user could receive the read-only master key, potentially losing write capabilities. The most severe outcome is privilege escalation from read-only to full master key access.
Mitigation
The issue is fixed in Parse Dashboard version 9.0.0-alpha.8, which uses distinct cache keys for the master key and read-only master key [2][4]. As a workaround, administrators can avoid using function-typed master keys or remove the agent configuration block from their dashboard configuration [2]. Users are strongly advised to upgrade to the patched version or apply the workaround to prevent potential privilege escalation.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
parse-dashboardnpm | >= 7.3.0-alpha.42, < 9.0.0-alpha.8 | 9.0.0-alpha.8 |
Affected products
2- Range: >=7.3.0-alpha.42, <=9.0.0-alpha.7
- parse-community/parse-dashboardv5Range: >= 7.3.0-alpha.42, < 9.0.0-alpha.8
Patches
1f92a9ef5246dfix: Incomplete authentication on AI Agent endpoint ([GHSA-qwc3-h9mg-4582](https://github.com/parse-community/parse-dashboard/security/advisories/GHSA-qwc3-h9mg-4582)) (#3224)
2 files changed · +433 −9
Parse-Dashboard/app.js+58 −9 modified@@ -158,7 +158,8 @@ module.exports = function(config, options) { } if (typeof app.masterKey === 'function') { - app.masterKey = await ConfigKeyCache.get(app.appId, 'masterKey', app.masterKeyTtl, app.masterKey); + const cacheKey = matchingAccess.readOnly ? 'readOnlyMasterKey' : 'masterKey'; + app.masterKey = await ConfigKeyCache.get(app.appId, cacheKey, app.masterKeyTtl, app.masterKey); } return app; @@ -197,9 +198,11 @@ module.exports = function(config, options) { // In-memory conversation storage (consider using Redis in future) const conversations = new Map(); - // Agent API endpoint for handling AI requests - scoped to specific app - app.post('/apps/:appId/agent', async (req, res) => { + // Agent API endpoint handler + async function agentHandler(req, res) { try { + const authentication = req.user; + const { message, modelName, conversationId, permissions } = req.body || {}; const { appId } = req.params; @@ -221,11 +224,40 @@ module.exports = function(config, options) { } // Find the app in the configuration - const app = config.apps.find(app => (app.appNameForURL || app.appName) === appId); - if (!app) { + const appConfig = config.apps.find(a => (a.appNameForURL || a.appName) === appId); + if (!appConfig) { return res.status(404).json({ error: `App "${appId}" not found` }); } + // Cross-app access control — restrict to apps the authenticated user has access to + const appsUserHasAccess = authentication && authentication.appsUserHasAccessTo; + let isPerAppReadOnly = false; + if (appsUserHasAccess) { + const matchingAccess = appsUserHasAccess.find(access => access.appId === appConfig.appId); + if (!matchingAccess) { + return res.status(403).json({ error: 'Forbidden: you do not have access to this app' }); + } + isPerAppReadOnly = !!matchingAccess.readOnly; + } + + // Determine if the user is read-only (globally or per-app) + const isReadOnly = (authentication && authentication.isReadOnly) || isPerAppReadOnly; + + // Build the app context — always shallow copy to avoid mutating the shared config + const appContext = { ...appConfig }; + if (isReadOnly) { + if (!appConfig.readOnlyMasterKey) { + return res.status(400).json({ error: 'You need to provide a readOnlyMasterKey to use read-only features.' }); + } + appContext.masterKey = appConfig.readOnlyMasterKey; + } + + // Resolve function-typed masterKey (supports dynamic key rotation via ConfigKeyCache) + if (typeof appContext.masterKey === 'function') { + const cacheKey = isReadOnly ? 'readOnlyMasterKey' : 'masterKey'; + appContext.masterKey = await ConfigKeyCache.get(appContext.appId, cacheKey, appContext.masterKeyTtl, appContext.masterKey); + } + // Find the requested model const modelConfig = config.agent.models.find(model => model.name === modelName); if (!modelConfig) { @@ -258,8 +290,12 @@ module.exports = function(config, options) { // Array to track database operations for this request const operationLog = []; + // Read-only users: override client permissions to deny all write operations, + // preventing privilege escalation via self-authorized permissions in the request body + const effectivePermissions = isReadOnly ? {} : (permissions || {}); + // Make request to OpenAI API with app context and conversation history - const response = await makeOpenAIRequest(message, model, apiKey, app, conversationHistory, operationLog, permissions); + const response = await makeOpenAIRequest(message, model, apiKey, appContext, conversationHistory, operationLog, effectivePermissions); // Update conversation history with user message and AI response conversationHistory.push( @@ -280,7 +316,7 @@ module.exports = function(config, options) { conversationId: finalConversationId, debug: { timestamp: new Date().toISOString(), - appId: app.appId, + appId: appContext.appId, modelUsed: model, operations: operationLog } @@ -291,7 +327,19 @@ module.exports = function(config, options) { const errorMessage = error.message || 'Provider error'; res.status(500).json({ error: `Error: ${errorMessage}` }); } - }); + } + + // Agent API endpoint — middleware chain: auth check (401) → CSRF validation (403) → handler + app.post('/apps/:appId/agent', + (req, res, next) => { + if (users && (!req.user || !req.user.isAuthenticated)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + next(); + }, + Authentication.csrfProtection, + agentHandler + ); /** * Database function tools for the AI agent @@ -1115,7 +1163,7 @@ You have direct access to the Parse database through function calls, so you can }); // For every other request, go to index.html. Let client-side handle the rest. - app.get('{*splat}', function(req, res) { + app.get('{*splat}', Authentication.csrfProtection, function(req, res) { if (users && (!req.user || !req.user.isAuthenticated)) { const redirect = req.url.replace('/login', ''); if (redirect.length > 1) { @@ -1139,6 +1187,7 @@ You have direct access to the Parse database through function calls, so you can </head> <body> <div id="browser_mount"></div> + <script id="csrf" type="application/json">"${req.csrfToken()}"</script> <script src="${mountPath}bundles/dashboard.bundle.js"></script> </body> </html>
src/lib/tests/AgentAuth.test.js+375 −0 added@@ -0,0 +1,375 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +jest.dontMock('../../../Parse-Dashboard/Authentication.js'); +jest.dontMock('../../../Parse-Dashboard/app.js'); + +const express = require('express'); +const http = require('http'); +const session = require('express-session'); +const cookieSignature = require('express-session/node_modules/cookie-signature'); + +/** + * Helper to make HTTP requests to the test server. + */ +function makeRequest(port, { method = 'GET', path = '/', body = null, cookie = null, headers = {} }) { + return new Promise((resolve, reject) => { + const options = { + hostname: '127.0.0.1', + port, + path, + method, + headers: { ...headers }, + }; + + if (body) { + const bodyStr = JSON.stringify(body); + options.headers['Content-Type'] = 'application/json'; + options.headers['Content-Length'] = Buffer.byteLength(bodyStr); + } + + if (cookie) { + options.headers['Cookie'] = cookie; + } + + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + let json = null; + try { + json = JSON.parse(data); + } catch { + // not JSON + } + resolve({ status: res.statusCode, body: json, raw: data, headers: res.headers }); + }); + }); + + req.on('error', reject); + + if (body) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +/** + * In-memory session store for testing authenticated flows. + * Pre-populated sessions allow simulating different dashboard user roles + * without going through the full login flow. + */ +class MockSessionStore extends session.Store { + constructor(sessions = {}) { + super(); + this.sessions = sessions; + } + get(sid, callback) { + const sess = this.sessions[sid]; + callback(null, sess ? JSON.parse(sess) : null); + } + set(sid, sess, callback) { + this.sessions[sid] = JSON.stringify(sess); + callback(null); + } + destroy(sid, callback) { + delete this.sessions[sid]; + callback(null); + } +} + +/** + * Build a signed session cookie for express-session. + */ +function buildSessionCookie(sessionId, secret) { + const signed = 's:' + cookieSignature.sign(sessionId, secret); + return `parse_dash=${encodeURIComponent(signed)}`; +} + +/** + * Build a pre-populated session object for the mock store. + * The passport.user field is the serialized username (as stored by passport.serializeUser). + */ +const CSRF_TOKEN = 'test-csrf-token'; + +function buildSessionData(username) { + return JSON.stringify({ + cookie: { + originalMaxAge: null, + expires: null, + httpOnly: true, + sameSite: 'lax', + path: '/', + }, + passport: { user: username }, + csrfToken: CSRF_TOKEN, + }); +} + +const SESSION_SECRET = 'test-secret'; + +/** + * Helper to build an agent request body. + */ +function agentBody(overrides = {}) { + return { + message: 'List all classes', + modelName: 'test-model', + conversationId: 'test', + permissions: {}, + ...overrides, + }; +} + +// Single server with all user types to avoid passport singleton issues. +// Passport is a singleton — creating multiple servers in one process +// causes the later server's deserializeUser to overwrite the earlier one's. +describe('Agent endpoint security', () => { + let server; + let port; + + // Dashboard config with multiple user types: + // - admin: full access to all apps + // - readonly: global read-only user + // - appreadonly: user with per-app read-only on TestApp + // - appadmin: full access scoped to TestApp only + const dashboardConfig = { + apps: [ + { + serverURL: 'http://localhost:1337/parse', + appId: 'testAppId', + masterKey: 'testMasterKey', + readOnlyMasterKey: 'testReadOnlyMasterKey', + appName: 'TestApp', + }, + { + serverURL: 'http://localhost:1337/parse', + appId: 'secretAppId', + masterKey: 'secretMasterKey', + readOnlyMasterKey: 'secretReadOnlyMasterKey', + appName: 'SecretApp', + }, + ], + users: [ + { + user: 'admin', + pass: 'password123', + }, + { + user: 'readonly', + pass: 'password123', + readOnly: true, + }, + { + user: 'appreadonly', + pass: 'password123', + apps: [{ appId: 'testAppId', readOnly: true }], + }, + { + user: 'appadmin', + pass: 'password123', + apps: [{ appId: 'testAppId' }], + }, + ], + agent: { + models: [ + { + name: 'test-model', + provider: 'openai', + model: 'gpt-4', + apiKey: 'fake-api-key-for-testing', + }, + ], + }, + }; + + // Pre-populate sessions for each dashboard user type + const mockStore = new MockSessionStore({ + 'admin-session': buildSessionData('admin'), + 'readonly-session': buildSessionData('readonly'), + 'appreadonly-session': buildSessionData('appreadonly'), + 'appadmin-session': buildSessionData('appadmin'), + }); + + const adminCookie = buildSessionCookie('admin-session', SESSION_SECRET); + const readonlyCookie = buildSessionCookie('readonly-session', SESSION_SECRET); + const appreadonlyCookie = buildSessionCookie('appreadonly-session', SESSION_SECRET); + const appadminCookie = buildSessionCookie('appadmin-session', SESSION_SECRET); + + beforeAll((done) => { + const parseDashboard = require('../../../Parse-Dashboard/app.js'); + const dashboardApp = parseDashboard(dashboardConfig, { + cookieSessionSecret: SESSION_SECRET, + cookieSessionStore: mockStore, + dev: true, + }); + + const parentApp = express(); + parentApp.use('/', dashboardApp); + + server = parentApp.listen(0, () => { + port = server.address().port; + done(); + }); + }); + + afterAll((done) => { + if (server) { + server.close(done); + } else { + done(); + } + }); + + // --------------------------------------------------------------- + // Unauthenticated access (no session cookie) + // --------------------------------------------------------------- + + it('returns 401 for unauthenticated requests to the agent endpoint', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody(), + }); + expect(res.status).toBe(401); + }); + + it('returns 401 when unauthenticated attacker sends self-authorized permissions', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody({ + permissions: { + deleteClass: true, + deleteObject: true, + createObject: true, + updateObject: true, + }, + }), + }); + expect(res.status).toBe(401); + }); + + it('returns 401 for unauthenticated requests to config endpoint', async () => { + const res = await makeRequest(port, { + method: 'GET', + path: '/parse-dashboard-config.json', + }); + expect(res.status).toBe(401); + }); + + // --------------------------------------------------------------- + // Authenticated access — full admin (no app restrictions) + // --------------------------------------------------------------- + + it('allows authenticated full admin to reach the agent handler', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody(), + cookie: adminCookie, + headers: { 'X-CSRF-Token': CSRF_TOKEN }, + }); + // 500 expected: auth passes, reaches OpenAI call which fails with fake API key + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it('returns 403 when authenticated admin sends request without CSRF token', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody(), + cookie: adminCookie, + // No X-CSRF-Token header + }); + expect(res.status).toBe(403); + }); + + // --------------------------------------------------------------- + // Authenticated access — app-scoped admin + // --------------------------------------------------------------- + + it('allows app-scoped admin to access their assigned app', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody(), + cookie: appadminCookie, + headers: { 'X-CSRF-Token': CSRF_TOKEN }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it('returns 403 when app-scoped admin tries to access an unassigned app', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/SecretApp/agent', + body: agentBody(), + cookie: appadminCookie, + headers: { 'X-CSRF-Token': CSRF_TOKEN }, + }); + expect(res.status).toBe(403); + }); + + // --------------------------------------------------------------- + // Read-only privilege escalation — global read-only user + // --------------------------------------------------------------- + + it('allows global read-only user to reach the agent handler (for reads)', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody(), + cookie: readonlyCookie, + headers: { 'X-CSRF-Token': CSRF_TOKEN }, + }); + // Read-only users can still use the agent for read operations + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it('uses readOnlyMasterKey for global read-only user even when write permissions are sent', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody({ + permissions: { + deleteClass: true, + deleteObject: true, + createObject: true, + updateObject: true, + createClass: true, + }, + }), + cookie: readonlyCookie, + headers: { 'X-CSRF-Token': CSRF_TOKEN }, + }); + // Passes auth, request is processed (500 from fake API key, not 401/403). + // Server-side: masterKey swapped to readOnlyMasterKey + permissions overridden to {}. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + // --------------------------------------------------------------- + // Read-only privilege escalation — per-app read-only user + // --------------------------------------------------------------- + + it('allows per-app read-only user to reach the agent handler for their app', async () => { + const res = await makeRequest(port, { + method: 'POST', + path: '/apps/TestApp/agent', + body: agentBody(), + cookie: appreadonlyCookie, + headers: { 'X-CSRF-Token': CSRF_TOKEN }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); +});
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-jhp4-jvq3-w5xrghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27610ghsaADVISORY
- github.com/parse-community/parse-dashboard/commit/f92a9ef5246d57e51696bd881a15f3b133b2bb50ghsax_refsource_MISCWEB
- github.com/parse-community/parse-dashboard/releases/tag/9.0.0-alpha.8ghsax_refsource_MISCWEB
- github.com/parse-community/parse-dashboard/security/advisories/GHSA-jhp4-jvq3-w5xrghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.