fix: Server option routeAllowList is bypassable through batch sub-requests ([GHSA-p84r-h6rx-f2xr](https://github.com/parse-community/parse-server/security/advisories/GHSA-p84r-h6rx-f2xr)) (#10482)

This commit is contained in:
Manuel
2026-05-27 22:41:48 +02:00
committed by GitHub
parent 0ae0ed382b
commit 552c6dd754
3 changed files with 154 additions and 22 deletions
+107
View File
@@ -375,6 +375,113 @@ describe('routeAllowList', () => {
});
});
describe('batch sub-requests', () => {
// routeAllowList must be enforced per batch sub-request. The outer
// enforceRouteAllowList middleware runs only on the outer /batch URL,
// so without per-sub-request enforcement an operator who allowlists
// `batch` would accidentally expose every REST route reachable through
// batch sub-request dispatch.
const restRequest = require('../lib/request');
const headers = {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
it('blocks a batch GET sub-request whose path is not allowlisted', async () => {
await reconfigureServer({ routeAllowList: ['batch'] });
await new Parse.Object('Blocked').save({ secret: 'x' }, { useMasterKey: true });
try {
await restRequest({
method: 'POST',
headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [{ method: 'GET', path: '/1/classes/Blocked' }],
}),
});
fail('batch sub-request to a blocked route should have been rejected');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
});
it('blocks a batch POST sub-request whose path is not allowlisted', async () => {
await reconfigureServer({ routeAllowList: ['batch'] });
try {
await restRequest({
method: 'POST',
headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [{ method: 'POST', path: '/1/classes/Blocked', body: { x: 1 } }],
}),
});
fail('batch sub-request POST to a blocked route should have been rejected');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
const query = new Parse.Query('Blocked');
const results = await query.find({ useMasterKey: true });
expect(results.length).toBe(0);
});
it('allows a batch sub-request whose path matches the allow list', async () => {
await reconfigureServer({ routeAllowList: ['batch', 'classes/Allowed'] });
const response = await restRequest({
method: 'POST',
headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [{ method: 'POST', path: '/1/classes/Allowed', body: { x: 1 } }],
}),
});
expect(response.data.length).toBe(1);
expect(response.data[0].success.objectId).toBeDefined();
});
it('rejects the entire batch if any sub-request is not allowlisted', async () => {
await reconfigureServer({ routeAllowList: ['batch', 'classes/Allowed'] });
try {
await restRequest({
method: 'POST',
headers,
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [
{ method: 'POST', path: '/1/classes/Allowed', body: { x: 1 } },
{ method: 'POST', path: '/1/classes/Blocked', body: { y: 2 } },
],
}),
});
fail('batch with any disallowed sub-request should have been rejected');
} catch (e) {
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
}
const allowedQuery = new Parse.Query('Allowed');
const allowedResults = await allowedQuery.find({ useMasterKey: true });
expect(allowedResults.length).toBe(0);
});
it('allows master key to bypass sub-request allow-list check', async () => {
await reconfigureServer({ routeAllowList: ['batch'] });
const response = await restRequest({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
url: 'http://localhost:8378/1/batch',
body: JSON.stringify({
requests: [{ method: 'POST', path: '/1/classes/Blocked', body: { x: 1 } }],
}),
});
expect(response.data.length).toBe(1);
expect(response.data[0].success.objectId).toBeDefined();
});
});
it_id('229cab22-dad3-4d08-8de5-64d813658596')(it)('should block all route groups when not in allow list', async () => {
await reconfigureServer({
routeAllowList: ['classes/GameScore'],
+13
View File
@@ -1,5 +1,7 @@
const Parse = require('parse/node').Parse;
const path = require('path');
const { isRouteAllowed } = require('./middlewares');
const { createSanitizedError } = require('./Error');
// These methods handle batch requests.
const batchPath = '/batch';
@@ -104,6 +106,17 @@ async function handleBatch(router, req) {
if ((restRequest.method || 'GET').toUpperCase() === 'POST' && routablePath === batchPath) {
throw new Parse.Error(Parse.Error.INVALID_JSON, 'nested batch requests are not allowed');
}
// Re-enforce routeAllowList on each sub-request. The enforceRouteAllowList
// middleware runs once on the outer /batch URL, so without this check an
// operator who allowlists `batch` would expose every route reachable via
// sub-request dispatch.
if (!isRouteAllowed(routablePath, req.config, req.auth)) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`Route not allowed by routeAllowList: ${(restRequest.method || 'GET').toUpperCase()} ${routablePath}`,
req.config
);
}
for (const limit of rateLimits) {
const pathExp = limit.path.regexp || limit.path;
if (!pathExp.test(routablePath)) {
+34 -22
View File
@@ -519,41 +519,53 @@ export function handleParseHealth(options) {
};
}
export function enforceRouteAllowList(req, res, next) {
const config = req.config;
if (!config || config.routeAllowList === undefined || config.routeAllowList === null) {
return next();
}
if (req.auth && (req.auth.isMaster || req.auth.isMaintenance)) {
return next();
}
let path = req.originalUrl;
if (config.mount) {
const mountPath = new URL(config.mount).pathname;
if (path.startsWith(mountPath)) {
path = path.substring(mountPath.length);
function normalizeRouteAllowListPath(path, mount) {
let normalized = path;
if (mount) {
const mountPath = new URL(mount).pathname;
if (normalized.startsWith(mountPath)) {
normalized = normalized.substring(mountPath.length);
}
}
if (path.startsWith('/')) {
path = path.substring(1);
if (normalized.startsWith('/')) {
normalized = normalized.substring(1);
}
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
if (normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
const queryIndex = path.indexOf('?');
const queryIndex = normalized.indexOf('?');
if (queryIndex !== -1) {
path = path.substring(0, queryIndex);
normalized = normalized.substring(0, queryIndex);
}
return normalized;
}
export function isRouteAllowed(path, config, auth) {
if (!config || config.routeAllowList === undefined || config.routeAllowList === null) {
return true;
}
if (auth && (auth.isMaster || auth.isMaintenance)) {
return true;
}
const normalized = normalizeRouteAllowListPath(path, config.mount);
const regexes = config._routeAllowListRegex || [];
for (const regex of regexes) {
if (regex.test(path)) {
return next();
if (regex.test(normalized)) {
return true;
}
}
return false;
}
export function enforceRouteAllowList(req, res, next) {
if (isRouteAllowed(req.originalUrl, req.config, req.auth)) {
return next();
}
const path = normalizeRouteAllowListPath(req.originalUrl, req.config?.mount);
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`Route not allowed by routeAllowList: ${req.method} ${path}`,
config
req.config
);
}