mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user