Skip to content

Commit f92a9ef

Browse files
authored
fix: Incomplete authentication on AI Agent endpoint ([GHSA-qwc3-h9mg-4582](GHSA-qwc3-h9mg-4582)) (#3224)
1 parent 8a34c2f commit f92a9ef

2 files changed

Lines changed: 433 additions & 9 deletions

File tree

Parse-Dashboard/app.js

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ module.exports = function(config, options) {
158158
}
159159

160160
if (typeof app.masterKey === 'function') {
161-
app.masterKey = await ConfigKeyCache.get(app.appId, 'masterKey', app.masterKeyTtl, app.masterKey);
161+
const cacheKey = matchingAccess.readOnly ? 'readOnlyMasterKey' : 'masterKey';
162+
app.masterKey = await ConfigKeyCache.get(app.appId, cacheKey, app.masterKeyTtl, app.masterKey);
162163
}
163164

164165
return app;
@@ -197,9 +198,11 @@ module.exports = function(config, options) {
197198
// In-memory conversation storage (consider using Redis in future)
198199
const conversations = new Map();
199200

200-
// Agent API endpoint for handling AI requests - scoped to specific app
201-
app.post('/apps/:appId/agent', async (req, res) => {
201+
// Agent API endpoint handler
202+
async function agentHandler(req, res) {
202203
try {
204+
const authentication = req.user;
205+
203206
const { message, modelName, conversationId, permissions } = req.body || {};
204207
const { appId } = req.params;
205208

@@ -221,11 +224,40 @@ module.exports = function(config, options) {
221224
}
222225

223226
// Find the app in the configuration
224-
const app = config.apps.find(app => (app.appNameForURL || app.appName) === appId);
225-
if (!app) {
227+
const appConfig = config.apps.find(a => (a.appNameForURL || a.appName) === appId);
228+
if (!appConfig) {
226229
return res.status(404).json({ error: `App "${appId}" not found` });
227230
}
228231

232+
// Cross-app access control — restrict to apps the authenticated user has access to
233+
const appsUserHasAccess = authentication && authentication.appsUserHasAccessTo;
234+
let isPerAppReadOnly = false;
235+
if (appsUserHasAccess) {
236+
const matchingAccess = appsUserHasAccess.find(access => access.appId === appConfig.appId);
237+
if (!matchingAccess) {
238+
return res.status(403).json({ error: 'Forbidden: you do not have access to this app' });
239+
}
240+
isPerAppReadOnly = !!matchingAccess.readOnly;
241+
}
242+
243+
// Determine if the user is read-only (globally or per-app)
244+
const isReadOnly = (authentication && authentication.isReadOnly) || isPerAppReadOnly;
245+
246+
// Build the app context — always shallow copy to avoid mutating the shared config
247+
const appContext = { ...appConfig };
248+
if (isReadOnly) {
249+
if (!appConfig.readOnlyMasterKey) {
250+
return res.status(400).json({ error: 'You need to provide a readOnlyMasterKey to use read-only features.' });
251+
}
252+
appContext.masterKey = appConfig.readOnlyMasterKey;
253+
}
254+
255+
// Resolve function-typed masterKey (supports dynamic key rotation via ConfigKeyCache)
256+
if (typeof appContext.masterKey === 'function') {
257+
const cacheKey = isReadOnly ? 'readOnlyMasterKey' : 'masterKey';
258+
appContext.masterKey = await ConfigKeyCache.get(appContext.appId, cacheKey, appContext.masterKeyTtl, appContext.masterKey);
259+
}
260+
229261
// Find the requested model
230262
const modelConfig = config.agent.models.find(model => model.name === modelName);
231263
if (!modelConfig) {
@@ -258,8 +290,12 @@ module.exports = function(config, options) {
258290
// Array to track database operations for this request
259291
const operationLog = [];
260292

293+
// Read-only users: override client permissions to deny all write operations,
294+
// preventing privilege escalation via self-authorized permissions in the request body
295+
const effectivePermissions = isReadOnly ? {} : (permissions || {});
296+
261297
// Make request to OpenAI API with app context and conversation history
262-
const response = await makeOpenAIRequest(message, model, apiKey, app, conversationHistory, operationLog, permissions);
298+
const response = await makeOpenAIRequest(message, model, apiKey, appContext, conversationHistory, operationLog, effectivePermissions);
263299

264300
// Update conversation history with user message and AI response
265301
conversationHistory.push(
@@ -280,7 +316,7 @@ module.exports = function(config, options) {
280316
conversationId: finalConversationId,
281317
debug: {
282318
timestamp: new Date().toISOString(),
283-
appId: app.appId,
319+
appId: appContext.appId,
284320
modelUsed: model,
285321
operations: operationLog
286322
}
@@ -291,7 +327,19 @@ module.exports = function(config, options) {
291327
const errorMessage = error.message || 'Provider error';
292328
res.status(500).json({ error: `Error: ${errorMessage}` });
293329
}
294-
});
330+
}
331+
332+
// Agent API endpoint — middleware chain: auth check (401) → CSRF validation (403) → handler
333+
app.post('/apps/:appId/agent',
334+
(req, res, next) => {
335+
if (users && (!req.user || !req.user.isAuthenticated)) {
336+
return res.status(401).json({ error: 'Unauthorized' });
337+
}
338+
next();
339+
},
340+
Authentication.csrfProtection,
341+
agentHandler
342+
);
295343

296344
/**
297345
* 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
11151163
});
11161164

11171165
// For every other request, go to index.html. Let client-side handle the rest.
1118-
app.get('{*splat}', function(req, res) {
1166+
app.get('{*splat}', Authentication.csrfProtection, function(req, res) {
11191167
if (users && (!req.user || !req.user.isAuthenticated)) {
11201168
const redirect = req.url.replace('/login', '');
11211169
if (redirect.length > 1) {
@@ -1139,6 +1187,7 @@ You have direct access to the Parse database through function calls, so you can
11391187
</head>
11401188
<body>
11411189
<div id="browser_mount"></div>
1190+
<script id="csrf" type="application/json">"${req.csrfToken()}"</script>
11421191
<script src="${mountPath}bundles/dashboard.bundle.js"></script>
11431192
</body>
11441193
</html>

0 commit comments

Comments
 (0)