feat: Add Parse Server option enableSanitizedErrorResponse to remove detailed error messages from responses sent to clients (#9944)

This commit is contained in:
Lucas Coratger
2025-11-28 19:48:35 +01:00
committed by GitHub
parent 73e78127c2
commit 47521974ae
24 changed files with 121 additions and 49 deletions

View File

@@ -767,13 +767,11 @@ describe('Parse.File testing', () => {
describe('getting files', () => {
it('does not crash on file request with invalid app ID', async () => {
loggerErrorSpy.calls.reset();
const res1 = await request({
url: 'http://localhost:8378/1/files/invalid-id/invalid-file.txt',
}).catch(e => e);
expect(res1.status).toBe(403);
expect(res1.data).toEqual({ code: 119, error: 'Permission denied' });
expect(loggerErrorSpy).toHaveBeenCalledWith('Sanitized error:', jasmine.stringContaining('Invalid application ID.'));
expect(res1.data).toEqual({ code: 119, error: 'Invalid application ID.' });
// Ensure server did not crash
const res2 = await request({ url: 'http://localhost:8378/1/health' });
expect(res2.status).toEqual(200);

View File

@@ -1,4 +1,5 @@
const Utils = require('../src/Utils');
const Utils = require('../lib/Utils');
const { createSanitizedError, createSanitizedHttpError } = require("../lib/Error")
describe('Utils', () => {
describe('encodeForUrl', () => {
@@ -173,4 +174,42 @@ describe('Utils', () => {
expect(Utils.getNestedProperty(obj, 'database.name')).toBe('');
});
});
describe('createSanitizedError', () => {
it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => {
const config = { enableSanitizedErrorResponse: true };
const error = createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Detailed error message', config);
expect(error.message).toBe('Permission denied');
});
it('should not crash with config undefined', () => {
const error = createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Detailed error message', undefined);
expect(error.message).toBe('Permission denied');
});
it('should return the detailed message when enableSanitizedErrorResponse is false', () => {
const config = { enableSanitizedErrorResponse: false };
const error = createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Detailed error message', config);
expect(error.message).toBe('Detailed error message');
});
});
describe('createSanitizedHttpError', () => {
it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => {
const config = { enableSanitizedErrorResponse: true };
const error = createSanitizedHttpError(403, 'Detailed error message', config);
expect(error.message).toBe('Permission denied');
});
it('should not crash with config undefined', () => {
const error = createSanitizedHttpError(403, 'Detailed error message', undefined);
expect(error.message).toBe('Permission denied');
});
it('should return the detailed message when enableSanitizedErrorResponse is false', () => {
const config = { enableSanitizedErrorResponse: false };
const error = createSanitizedHttpError(403, 'Detailed error message', config);
expect(error.message).toBe('Detailed error message');
});
});
});

View File

@@ -1399,6 +1399,7 @@ export default class SchemaController {
return true;
}
const perms = classPermissions[operation];
const config = Config.get(Parse.applicationId)
// If only for authenticated users
// make sure we have an aclGroup
if (perms['requiresAuthentication']) {
@@ -1406,12 +1407,14 @@ export default class SchemaController {
if (!aclGroup || aclGroup.length == 0) {
throw createSanitizedError(
Parse.Error.OBJECT_NOT_FOUND,
'Permission denied, user needs to be authenticated.'
'Permission denied, user needs to be authenticated.',
config
);
} else if (aclGroup.indexOf('*') > -1 && aclGroup.length == 1) {
throw createSanitizedError(
Parse.Error.OBJECT_NOT_FOUND,
'Permission denied, user needs to be authenticated.'
'Permission denied, user needs to be authenticated.',
config
);
}
// requiresAuthentication passed, just move forward
@@ -1428,7 +1431,8 @@ export default class SchemaController {
if (permissionField == 'writeUserFields' && operation == 'create') {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`Permission denied for action ${operation} on class ${className}.`
`Permission denied for action ${operation} on class ${className}.`,
config
);
}
@@ -1451,7 +1455,8 @@ export default class SchemaController {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`Permission denied for action ${operation} on class ${className}.`
`Permission denied for action ${operation} on class ${className}.`,
config
);
}

View File

@@ -8,7 +8,7 @@ import defaultLogger from './logger';
* @param {string} detailedMessage - The detailed error message to log server-side
* @returns {Parse.Error} A Parse.Error with sanitized message
*/
function createSanitizedError(errorCode, detailedMessage) {
function createSanitizedError(errorCode, detailedMessage, config) {
// On testing we need to add a prefix to the message to allow to find the correct call in the TestUtils.js file
if (process.env.TESTING) {
defaultLogger.error('Sanitized error:', detailedMessage);
@@ -16,7 +16,7 @@ function createSanitizedError(errorCode, detailedMessage) {
defaultLogger.error(detailedMessage);
}
return new Parse.Error(errorCode, 'Permission denied');
return new Parse.Error(errorCode, config?.enableSanitizedErrorResponse !== false ? 'Permission denied' : detailedMessage);
}
/**
@@ -27,7 +27,7 @@ function createSanitizedError(errorCode, detailedMessage) {
* @param {string} detailedMessage - The detailed error message to log server-side
* @returns {Error} An Error with sanitized message
*/
function createSanitizedHttpError(statusCode, detailedMessage) {
function createSanitizedHttpError(statusCode, detailedMessage, config) {
// On testing we need to add a prefix to the message to allow to find the correct call in the TestUtils.js file
if (process.env.TESTING) {
defaultLogger.error('Sanitized error:', detailedMessage);
@@ -37,7 +37,7 @@ function createSanitizedHttpError(statusCode, detailedMessage) {
const error = new Error();
error.status = statusCode;
error.message = 'Permission denied';
error.message = config?.enableSanitizedErrorResponse !== false ? 'Permission denied' : detailedMessage;
return error;
}

View File

@@ -31,12 +31,13 @@ const load = parseGraphQLSchema => {
const { name, schemaFields } = deepcopy(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth);
enforceMasterKeyAccess(auth, config);
if (auth.isReadOnly) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to create a schema.",
config
);
}
@@ -80,12 +81,13 @@ const load = parseGraphQLSchema => {
const { name, schemaFields } = deepcopy(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth);
enforceMasterKeyAccess(auth, config);
if (auth.isReadOnly) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to update a schema."
"read-only masterKey isn't allowed to update a schema.",
config
);
}
@@ -131,12 +133,13 @@ const load = parseGraphQLSchema => {
const { name } = deepcopy(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth);
enforceMasterKeyAccess(auth, config);
if (auth.isReadOnly) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to delete a schema.",
config
);
}

View File

@@ -31,7 +31,7 @@ const load = parseGraphQLSchema => {
const { name } = deepcopy(args);
const { config, auth } = context;
enforceMasterKeyAccess(auth);
enforceMasterKeyAccess(auth, config);
const schema = await config.database.loadSchema({ clearCache: true });
const parseClass = await getClass(name, schema);
@@ -57,7 +57,7 @@ const load = parseGraphQLSchema => {
try {
const { config, auth } = context;
enforceMasterKeyAccess(auth);
enforceMasterKeyAccess(auth, config);
const schema = await config.database.loadSchema({ clearCache: true });
return (await schema.getAllClasses(true)).map(parseClass => ({

View File

@@ -9,7 +9,7 @@ import { createSanitizedError } from '../../Error';
const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) => {
const { info, config } = context;
if (!info || !info.sessionToken) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config);
}
const sessionToken = info.sessionToken;
const selectedFields = getFieldNames(queryInfo)
@@ -63,7 +63,7 @@ const getUserFromSessionToken = async (context, queryInfo, keysPrefix, userId) =
info.context
);
if (!response.results || response.results.length == 0) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config);
} else {
const user = response.results[0];
return {

View File

@@ -2,11 +2,12 @@ import Parse from 'parse/node';
import { GraphQLError } from 'graphql';
import { createSanitizedError } from '../Error';
export function enforceMasterKeyAccess(auth) {
export function enforceMasterKeyAccess(auth, config) {
if (!auth.isMaster) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
'unauthorized: master key is required',
config
);
}
}

View File

@@ -247,6 +247,13 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: true,
},
enableSanitizedErrorResponse: {
env: 'PARSE_SERVER_ENABLE_SANITIZED_ERROR_RESPONSE',
help:
'If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.',
action: parsers.booleanParser,
default: true,
},
encodeParseObjectInCloudFunction: {
env: 'PARSE_SERVER_ENCODE_PARSE_OBJECT_IN_CLOUD_FUNCTION',
help:

View File

@@ -45,6 +45,7 @@
* @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`.
* @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors
* @property {Boolean} enableInsecureAuthAdapters Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.
* @property {Boolean} enableSanitizedErrorResponse If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.
* @property {Boolean} encodeParseObjectInCloudFunction If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`. <br><br> The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`.
* @property {String} encryptionKey Key for encrypting your files
* @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access.

View File

@@ -347,6 +347,9 @@ export interface ParseServerOptions {
rateLimit: ?(RateLimitOptions[]);
/* Options to customize the request context using inversion of control/dependency injection.*/
requestContextMiddleware: ?(req: any, res: any, next: any) => void;
/* If set to `true`, error details are removed from error messages in responses to client requests, and instead a generic error message is sent. Default is `true`.
:DEFAULT: true */
enableSanitizedErrorResponse: ?boolean;
}
export interface RateLimitOptions {

View File

@@ -52,7 +52,7 @@ async function RestQuery({
throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad query type');
}
const isGet = method === RestQuery.Method.get;
enforceRoleSecurity(method, className, auth);
enforceRoleSecurity(method, className, auth, config);
const result = runBeforeFind
? await triggers.maybeRunQueryTrigger(
triggers.Types.beforeFind,
@@ -121,7 +121,7 @@ function _UnsafeRestQuery(
if (!this.auth.isMaster) {
if (this.className == '_Session') {
if (!this.auth.user) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config);
}
this.restWhere = {
$and: [
@@ -424,7 +424,8 @@ _UnsafeRestQuery.prototype.validateClientClassCreation = function () {
if (hasClass !== true) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
'This user is not allowed to access ' + 'non-existent class: ' + this.className
'This user is not allowed to access ' + 'non-existent class: ' + this.className,
this.config
);
}
});
@@ -803,7 +804,8 @@ _UnsafeRestQuery.prototype.denyProtectedFields = async function () {
if (this.restWhere[key]) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`This user is not allowed to query ${key} on class ${this.className}`
`This user is not allowed to query ${key} on class ${this.className}`,
this.config
);
}
}

View File

@@ -33,6 +33,7 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
'Cannot perform a write operation when using readOnlyMasterKey',
config
);
}
this.config = config;
@@ -203,6 +204,7 @@ RestWrite.prototype.validateClientClassCreation = function () {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
'This user is not allowed to access non-existent class: ' + this.className,
this.config
);
}
});
@@ -662,7 +664,8 @@ RestWrite.prototype.checkRestrictedFields = async function () {
if (!this.auth.isMaintenance && !this.auth.isMaster && 'emailVerified' in this.data) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"Clients aren't allowed to manually update email verification."
"Clients aren't allowed to manually update email verification.",
this.config
);
}
};
@@ -1454,7 +1457,8 @@ RestWrite.prototype.runDatabaseOperation = function () {
if (this.className === '_User' && this.query && this.auth.isUnauthenticated()) {
throw createSanitizedError(
Parse.Error.SESSION_MISSING,
`Cannot modify user ${this.query.objectId}.`
`Cannot modify user ${this.query.objectId}.`,
this.config
);
}

View File

@@ -112,7 +112,7 @@ export class ClassesRouter extends PromiseRouter {
typeof req.body?.objectId === 'string' &&
req.body.objectId.startsWith('role:')
) {
throw createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Invalid object ID.');
throw createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Invalid object ID.', req.config);
}
return rest.create(
req.config,

View File

@@ -5,7 +5,6 @@ import Config from '../Config';
import logger from '../logger';
const triggers = require('../triggers');
const Utils = require('../Utils');
import { createSanitizedError } from '../Error';
export class FilesRouter {
expressRouter({ maxUploadSize = '20Mb' } = {}) {
@@ -44,8 +43,7 @@ export class FilesRouter {
const config = Config.get(req.params.appId);
if (!config) {
res.status(403);
const err = createSanitizedError(Parse.Error.OPERATION_FORBIDDEN, 'Invalid application ID.');
res.json({ code: err.code, error: err.message });
res.json({ code: Parse.Error.OPERATION_FORBIDDEN, error: 'Invalid application ID.' });
return;
}

View File

@@ -45,6 +45,7 @@ export class GlobalConfigRouter extends PromiseRouter {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to update the config.",
req.config
);
}
const params = req.body.params || {};

View File

@@ -18,6 +18,7 @@ export class GraphQLRouter extends PromiseRouter {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to update the GraphQL config.",
req.config
);
}
const data = await req.config.parseGraphQLController.updateGraphQLConfig(req.body?.params || {});

View File

@@ -9,6 +9,7 @@ export class PurgeRouter extends PromiseRouter {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to purge a schema.",
req.config
);
}
return req.config.database

View File

@@ -13,6 +13,7 @@ export class PushRouter extends PromiseRouter {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to send push notifications.",
req.config
);
}
const pushController = req.config.pushController;

View File

@@ -76,6 +76,7 @@ async function createSchema(req) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to create a schema.",
req.config
);
}
if (req.params.className && req.body?.className) {
@@ -98,6 +99,7 @@ function modifySchema(req) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to update a schema.",
req.config
);
}
if (req.body?.className && req.body.className != req.params.className) {
@@ -113,6 +115,7 @@ const deleteSchema = req => {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
"read-only masterKey isn't allowed to delete a schema.",
req.config
);
}
if (!SchemaController.classNameIsValid(req.params.className)) {

View File

@@ -172,7 +172,7 @@ export class UsersRouter extends ClassesRouter {
handleMe(req) {
if (!req.info || !req.info.sessionToken) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
}
const sessionToken = req.info.sessionToken;
return rest
@@ -187,7 +187,7 @@ export class UsersRouter extends ClassesRouter {
)
.then(response => {
if (!response.results || response.results.length == 0 || !response.results[0].user) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config);
} else {
const user = response.results[0].user;
// Send token back on the login, because SDKs expect that.
@@ -338,6 +338,7 @@ export class UsersRouter extends ClassesRouter {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
'master key is required',
req.config
);
}

View File

@@ -9,12 +9,13 @@ const classesWithMasterOnlyAccess = [
const { createSanitizedError } = require('./Error');
// Disallowing access to the _Role collection except by master key
function enforceRoleSecurity(method, className, auth) {
function enforceRoleSecurity(method, className, auth, config) {
if (className === '_Installation' && !auth.isMaster && !auth.isMaintenance) {
if (method === 'delete' || method === 'find') {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`Clients aren't allowed to perform the ${method} operation on the installation collection.`
`Clients aren't allowed to perform the ${method} operation on the installation collection.`,
config
);
}
}
@@ -27,7 +28,8 @@ function enforceRoleSecurity(method, className, auth) {
) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`Clients aren't allowed to perform the ${method} operation on the ${className} collection.`
`Clients aren't allowed to perform the ${method} operation on the ${className} collection.`,
config
);
}
@@ -35,7 +37,8 @@ function enforceRoleSecurity(method, className, auth) {
if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) {
throw createSanitizedError(
Parse.Error.OPERATION_FORBIDDEN,
`read-only masterKey isn't allowed to perform the ${method} operation.`
`read-only masterKey isn't allowed to perform the ${method} operation.`,
config
);
}
}

View File

@@ -502,7 +502,7 @@ export function handleParseErrors(err, req, res, next) {
export function enforceMasterKeyAccess(req, res, next) {
if (!req.auth.isMaster) {
const error = createSanitizedHttpError(403, 'unauthorized: master key is required');
const error = createSanitizedHttpError(403, 'unauthorized: master key is required', req.config);
res.status(error.status);
res.end(`{"error":"${error.message}"}`);
return;
@@ -512,7 +512,7 @@ export function enforceMasterKeyAccess(req, res, next) {
export function promiseEnforceMasterKeyAccess(request) {
if (!request.auth.isMaster) {
throw createSanitizedHttpError(403, 'unauthorized: master key is required');
throw createSanitizedHttpError(403, 'unauthorized: master key is required', request.config);
}
return Promise.resolve();
}

View File

@@ -135,7 +135,7 @@ async function runFindTriggers(
// Returns a promise for an object with optional keys 'results' and 'count'.
const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
enforceRoleSecurity('find', className, auth);
enforceRoleSecurity('find', className, auth, config);
return runFindTriggers(
config,
auth,
@@ -150,7 +150,7 @@ const find = async (config, auth, className, restWhere, restOptions, clientSDK,
// get is just like find but only queries an objectId.
const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => {
enforceRoleSecurity('get', className, auth);
enforceRoleSecurity('get', className, auth, config);
return runFindTriggers(
config,
auth,
@@ -173,7 +173,7 @@ function del(config, auth, className, objectId, context) {
throw new Parse.Error(Parse.Error.SESSION_MISSING, 'Insufficient auth to delete user');
}
enforceRoleSecurity('delete', className, auth);
enforceRoleSecurity('delete', className, auth, config);
let inflatedObject;
let schemaController;
@@ -196,7 +196,7 @@ function del(config, auth, className, objectId, context) {
firstResult.className = className;
if (className === '_Session' && !auth.isMaster && !auth.isMaintenance) {
if (!auth.user || firstResult.user.objectId !== auth.user.id) {
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token');
throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config);
}
}
var cacheAdapter = config.cacheController;
@@ -258,13 +258,13 @@ function del(config, auth, className, objectId, context) {
);
})
.catch(error => {
handleSessionMissingError(error, className, auth);
handleSessionMissingError(error, className, auth, config);
});
}
// Returns a promise for a {response, status, location} object.
function create(config, auth, className, restObject, clientSDK, context) {
enforceRoleSecurity('create', className, auth);
enforceRoleSecurity('create', className, auth, config);
var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK, context);
return write.execute();
}
@@ -273,7 +273,7 @@ function create(config, auth, className, restObject, clientSDK, context) {
// REST API is supposed to return.
// Usually, this is just updatedAt.
function update(config, auth, className, restWhere, restObject, clientSDK, context) {
enforceRoleSecurity('update', className, auth);
enforceRoleSecurity('update', className, auth, config);
return Promise.resolve()
.then(async () => {
@@ -315,11 +315,11 @@ function update(config, auth, className, restWhere, restObject, clientSDK, conte
).execute();
})
.catch(error => {
handleSessionMissingError(error, className, auth);
handleSessionMissingError(error, className, auth, config);
});
}
function handleSessionMissingError(error, className, auth) {
function handleSessionMissingError(error, className, auth, config) {
// If we're trying to update a user without / with bad session token
if (
className === '_User' &&
@@ -327,7 +327,7 @@ function handleSessionMissingError(error, className, auth) {
!auth.isMaster &&
!auth.isMaintenance
) {
throw createSanitizedError(Parse.Error.SESSION_MISSING, 'Insufficient auth.');
throw createSanitizedError(Parse.Error.SESSION_MISSING, 'Insufficient auth.', config);
}
throw error;
}