feat: Add protectedFieldsTriggerExempt option to exempt Cloud Code triggers from protectedFields (#10288)

This commit is contained in:
Manuel
2026-03-23 02:42:56 +00:00
committed by GitHub
parent 1610c932ec
commit 1610f98316
7 changed files with 178 additions and 1 deletions
+2
View File
@@ -24,6 +24,8 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
| DEPPS18 | Config option `requestComplexity` limits enabled by default | [#10207](https://github.com/parse-community/parse-server/pull/10207) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS19 | Remove config option `enableProductPurchaseLegacyApi` | [#10228](https://github.com/parse-community/parse-server/pull/10228) | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS20 | Remove config option `allowExpiredAuthDataToken` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS21 | Config option `protectedFieldsOwnerExempt` defaults to `false` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
| DEPPS22 | Config option `protectedFieldsTriggerExempt` defaults to `true` | | 9.6.0 (2026) | 10.0.0 (2027) | deprecated | - |
[i_deprecation]: ## "The version and date of the deprecation."
[i_change]: ## "The version and date of the planned change."
+155
View File
@@ -2061,4 +2061,159 @@ describe('ProtectedFields', function () {
expect(response.data.updatedAt).toBeUndefined();
});
});
describe('protectedFieldsTriggerExempt', function () {
it('should expose protected fields in beforeSave trigger for a custom class', async function () {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
protectedFieldsTriggerExempt: true,
});
// Create object with master key so both fields are stored
const obj = new Parse.Object('MyClass');
obj.set('secretField', 'hidden-value');
obj.set('publicField', 'visible-value');
const acl = new Parse.ACL();
acl.setPublicReadAccess(true);
acl.setPublicWriteAccess(true);
obj.setACL(acl);
await obj.save(null, { useMasterKey: true });
// Set up beforeSave trigger to capture field visibility
let triggerObject;
let triggerOriginal;
Parse.Cloud.beforeSave('MyClass', request => {
triggerObject = {
hasSecret: request.object.has('secretField'),
hasPublic: request.object.has('publicField'),
secretValue: request.object.get('secretField'),
};
if (request.original) {
triggerOriginal = {
hasSecret: request.original.has('secretField'),
hasPublic: request.original.has('publicField'),
secretValue: request.original.get('secretField'),
};
}
});
// Update using a user session (not master key)
const user = await Parse.User.signUp('testuser', 'password');
obj.set('publicField', 'updated-value');
await obj.save(null, { sessionToken: user.getSessionToken() });
// request.object should have all fields (original + changes merged)
expect(triggerObject.hasPublic).toBe(true);
expect(triggerObject.hasSecret).toBe(true);
expect(triggerObject.secretValue).toBe('hidden-value');
// request.original should have all fields unfiltered
expect(triggerOriginal.hasPublic).toBe(true);
expect(triggerOriginal.hasSecret).toBe(true);
expect(triggerOriginal.secretValue).toBe('hidden-value');
});
it('should expose protected fields in beforeSave trigger for _User class with protectedFieldsOwnerExempt false', async function () {
await reconfigureServer({
protectedFields: { _User: { '*': ['email'] } },
protectedFieldsOwnerExempt: false,
protectedFieldsTriggerExempt: true,
});
// Create user
const user = new Parse.User();
user.setUsername('testuser');
user.setPassword('password');
user.setEmail('test@example.com');
user.set('publicField', 'visible-value');
await user.signUp();
// Set up beforeSave trigger to capture field visibility
let triggerObject;
let triggerOriginal;
Parse.Cloud.beforeSave(Parse.User, request => {
triggerObject = {
hasEmail: request.object.has('email'),
hasPublic: request.object.has('publicField'),
emailValue: request.object.get('email'),
};
if (request.original) {
triggerOriginal = {
hasEmail: request.original.has('email'),
hasPublic: request.original.has('publicField'),
emailValue: request.original.get('email'),
};
}
});
// Update using the user's own session
user.set('publicField', 'updated-value');
await user.save(null, { sessionToken: user.getSessionToken() });
// request.object should have all fields including email
expect(triggerObject.hasPublic).toBe(true);
expect(triggerObject.hasEmail).toBe(true);
expect(triggerObject.emailValue).toBe('test@example.com');
// request.original should have all fields including email
expect(triggerOriginal.hasPublic).toBe(true);
expect(triggerOriginal.hasEmail).toBe(true);
expect(triggerOriginal.emailValue).toBe('test@example.com');
});
it('should still hide protected fields from query results when protectedFieldsTriggerExempt is true', async function () {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
protectedFieldsTriggerExempt: true,
});
const obj = new Parse.Object('MyClass');
obj.set('secretField', 'hidden-value');
obj.set('publicField', 'visible-value');
const acl = new Parse.ACL();
acl.setPublicReadAccess(true);
obj.setACL(acl);
await obj.save(null, { useMasterKey: true });
// Query as a regular user — protectedFields should still apply to reads
const user = await Parse.User.signUp('testuser', 'password');
const fetched = await new Parse.Query('MyClass').get(obj.id, { sessionToken: user.getSessionToken() });
expect(fetched.has('publicField')).toBe(true);
expect(fetched.has('secretField')).toBe(false);
});
it('should not expose protected fields in beforeSave trigger when protectedFieldsTriggerExempt is false', async function () {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
protectedFieldsTriggerExempt: false,
});
const obj = new Parse.Object('MyClass');
obj.set('secretField', 'hidden-value');
obj.set('publicField', 'visible-value');
const acl = new Parse.ACL();
acl.setPublicReadAccess(true);
acl.setPublicWriteAccess(true);
obj.setACL(acl);
await obj.save(null, { useMasterKey: true });
let triggerOriginal;
Parse.Cloud.beforeSave('MyClass', request => {
if (request.original) {
triggerOriginal = {
hasSecret: request.original.has('secretField'),
hasPublic: request.original.has('publicField'),
};
}
});
const user = await Parse.User.signUp('testuser', 'password');
obj.set('publicField', 'updated-value');
await obj.save(null, { sessionToken: user.getSessionToken() });
// With protectedFieldsTriggerExempt: false, current behavior is preserved
expect(triggerOriginal.hasPublic).toBe(true);
expect(triggerOriginal.hasSecret).toBe(false);
});
});
});
+5
View File
@@ -91,4 +91,9 @@ module.exports = [
changeNewDefault: 'false',
solution: "Set 'protectedFieldsOwnerExempt' to 'false' to apply protectedFields consistently to the user's own _User object (same as all other classes), or to 'true' to keep the current behavior where a user can see all their own fields.",
},
{
optionKey: 'protectedFieldsTriggerExempt',
changeNewDefault: 'true',
solution: "Set 'protectedFieldsTriggerExempt' to 'true' to make Cloud Code triggers (e.g. beforeSave, afterSave) receive the full object including protected fields, or to 'false' to keep the current behavior where protected fields are stripped from trigger objects.",
},
];
+6
View File
@@ -484,6 +484,12 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: true,
},
protectedFieldsTriggerExempt: {
env: 'PARSE_SERVER_PROTECTED_FIELDS_TRIGGER_EXEMPT',
help: "Whether Cloud Code triggers (e.g. `beforeSave`, `afterSave`) are exempt from `protectedFields`. If `true`, triggers receive the full object including protected fields in `request.object` and `request.original`, regardless of the caller's auth context. If `false`, protected fields are stripped from the original object fetch used to build trigger objects. Defaults to `false`.",
action: parsers.booleanParser,
default: false,
},
publicServerURL: {
env: 'PARSE_PUBLIC_SERVER_URL',
help: 'Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.',
+1
View File
@@ -90,6 +90,7 @@
* @property {Boolean} preventSignupWithUnverifiedEmail If set to `true` it prevents a user from signing up if the email has not yet been verified and email verification is required. In that case the server responds to the sign-up with HTTP status 400 and a Parse Error 205 `EMAIL_NOT_FOUND`. If set to `false` the server responds with HTTP status 200, and client SDKs return an unauthenticated Parse User without session token. In that case subsequent requests fail until the user's email address is verified.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.
* @property {ProtectedFields} protectedFields Fields per class that are hidden from query results for specific user groups. Protected fields are stripped from the server response, but can still be used internally (e.g. in Cloud Code triggers). Configure as `{ 'ClassName': { 'UserGroup': ['field1', 'field2'] } }` where `UserGroup` is one of: `'*'` (all users), `'authenticated'` (authenticated users), `'role:RoleName'` (users with a specific role), `'userField:FieldName'` (users referenced by a pointer field), or a user `objectId` to target a specific user. When multiple groups apply, the intersection of their protected fields is used. Any field can be protected, including system fields like `createdAt` and `updatedAt`. By default, `email` is protected on the `_User` class for all users. On the `_User` class, the object owner is exempt from protected fields by default; see `protectedFieldsOwnerExempt` to change this.
* @property {Boolean} protectedFieldsOwnerExempt Whether the `_User` class is exempt from `protectedFields` when the logged-in user queries their own user object. If `true` (default), a user can see all their own fields regardless of `protectedFields` configuration; default protected fields (e.g. `email`) are merged into any custom `protectedFields` configuration. If `false`, `protectedFields` applies equally to the user's own object, consistent with all other classes; only explicitly configured protected fields apply, defaults are not merged. Defaults to `true`.
* @property {Boolean} protectedFieldsTriggerExempt Whether Cloud Code triggers (e.g. `beforeSave`, `afterSave`) are exempt from `protectedFields`. If `true`, triggers receive the full object including protected fields in `request.object` and `request.original`, regardless of the caller's auth context. If `false`, protected fields are stripped from the original object fetch used to build trigger objects. Defaults to `false`.
* @property {Union} publicServerURL Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
* @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications
* @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.<br><br>ℹ️ Mind the following limitations:<br>- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses<br>- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable<br>- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and use case.
+4
View File
@@ -175,6 +175,10 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_PROTECTED_FIELDS_OWNER_EXEMPT
:DEFAULT: true */
protectedFieldsOwnerExempt: ?boolean;
/* Whether Cloud Code triggers (e.g. `beforeSave`, `afterSave`) are exempt from `protectedFields`. If `true`, triggers receive the full object including protected fields in `request.object` and `request.original`, regardless of the caller's auth context. If `false`, protected fields are stripped from the original object fetch used to build trigger objects. Defaults to `false`.
:ENV: PARSE_SERVER_PROTECTED_FIELDS_TRIGGER_EXEMPT
:DEFAULT: false */
protectedFieldsTriggerExempt: ?boolean;
/* Enable (or disable) anonymous users, defaults to true
:ENV: PARSE_SERVER_ENABLE_ANON_USERS
:DEFAULT: true */
+5 -1
View File
@@ -12,6 +12,7 @@ var Parse = require('parse/node').Parse;
var RestQuery = require('./RestQuery');
var RestWrite = require('./RestWrite');
var triggers = require('./triggers');
const Auth = require('./Auth');
const { enforceRoleSecurity } = require('./SharedRest');
const { createSanitizedError } = require('./Error');
@@ -281,10 +282,13 @@ function update(config, auth, className, restWhere, restObject, clientSDK, conte
const hasLiveQuery = checkLiveQuery(className, config);
if (hasTriggers || hasLiveQuery) {
// Do not use find, as it runs the before finds
// Use master auth when protectedFieldsTriggerExempt is true to bypass
// protectedFields filtering, so triggers see the full original object
const queryAuth = config.protectedFieldsTriggerExempt ? Auth.master(config) : auth;
const query = await RestQuery({
method: RestQuery.Method.get,
config,
auth,
auth: queryAuth,
className,
restWhere,
runAfterFind: false,