Parse Dashboard has incomplete authentication on AI Agent endpoint
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 AI Agent API endpoint (POST /apps/:appId/agent) has multiple security vulnerabilities that, when chained, allow unauthenticated remote attackers to perform arbitrary read and write operations against any connected Parse Server database using the master key. The agent feature is opt-in; dashboards without an agent config are not affected. The fix in version 9.0.0-alpha.8 adds authentication, CSRF validation, and per-app authorization middleware to the agent endpoint. Read-only users are restricted to the readOnlyMasterKey with write permissions stripped server-side. A cache key collision between master key and read-only master key was also corrected. As a workaround, remove or comment out the agent configuration block from your Parse Dashboard configuration.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Unauthenticated attackers can chain multiple vulnerabilities in Parse Dashboard's AI Agent endpoint to achieve arbitrary read/write on any connected Parse Server database using the master key.
Vulnerability
Overview
Parse Dashboard versions 7.3.0-alpha.42 through 9.0.0-alpha.7 contain multiple security flaws in the AI Agent API endpoint (POST /apps/:appId/agent). When chained, these vulnerabilities allow an unauthenticated remote attacker to perform arbitrary read and write operations against any connected Parse Server database using the master key [1][2]. The agent feature is opt-in; dashboards without an agent configuration are not affected [3].
Exploitation
Details
The AI Agent endpoint lacks authentication, CSRF validation, and per-app authorization middleware [2]. An attacker can send arbitrary requests to the Parse Server with the master key, bypassing all access controls. Additionally, a cache key collision between the master key and the read-only master key was present, which could further confuse authorization boundaries [3]. No prior authentication or special privileges are required to exploit this endpoint [2].
Impact
Successful exploitation grants the attacker full control over the connected Parse Server database, including the ability to read, modify, or delete any data. Since the master key is used, all security restrictions are bypassed, leading to a complete compromise of confidentiality, integrity, and availability of the database [3].
Mitigation
The vulnerability is fixed in version 9.0.0-alpha.8, which adds authentication, CSRF validation, and per-app authorization middleware to the agent endpoint [4]. Read-only users are now restricted to the readOnlyMasterKey with write permissions stripped server-side, and the cache key collision has been corrected [3]. As a workaround, administrators can remove or comment out the agent configuration block from their Parse Dashboard configuration [3].
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-qwc3-h9mg-4582ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27595ghsaADVISORY
- github.com/parse-community/parse-dashboard/commit/f92a9ef5246d57e51696bd881a15f3b133b2bb50ghsaWEB
- github.com/parse-community/parse-dashboard/releases/tag/9.0.0-alpha.8ghsax_refsource_MISCWEB
- github.com/parse-community/parse-dashboard/security/advisories/GHSA-qwc3-h9mg-4582ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.