feat: Add protectedFieldsSaveResponseExempt option to strip protected fields from save responses (#10289)

This commit is contained in:
Manuel
2026-03-23 03:20:56 +00:00
committed by GitHub
parent d131dc1be0
commit 4f7cb53bd1
7 changed files with 200 additions and 0 deletions
+1
View File
@@ -26,6 +26,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
| 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 | - |
| DEPPS23 | Config option `protectedFieldsSaveResponseExempt` defaults to `false` | | 9.7.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."
+152
View File
@@ -2216,4 +2216,156 @@ describe('ProtectedFields', function () {
expect(triggerOriginal.hasSecret).toBe(false);
});
});
describe('protectedFieldsSaveResponseExempt', function () {
it('should strip protected fields from update response when protectedFieldsSaveResponseExempt is false', async function () {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
protectedFieldsTriggerExempt: true,
protectedFieldsSaveResponseExempt: false,
});
// Create object with master key
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 });
// beforeSave trigger modifies the protected field
Parse.Cloud.beforeSave('MyClass', req => {
req.object.set('secretField', 'trigger-modified-value');
});
// Update via raw HTTP to inspect the actual server response
const user = await Parse.User.signUp('testuser', 'password');
const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/MyClass/${obj.id}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': user.getSessionToken(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ publicField: 'updated-value' }),
});
// The server response should NOT contain the protected field
expect(response.data.updatedAt).toBeDefined();
expect(response.data.secretField).toBeUndefined();
});
it('should strip protected fields from update response for _User class when protectedFieldsSaveResponseExempt is false', async function () {
await reconfigureServer({
protectedFields: { _User: { '*': ['email'] } },
protectedFieldsOwnerExempt: false,
protectedFieldsTriggerExempt: true,
protectedFieldsSaveResponseExempt: false,
});
// 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();
// beforeSave trigger modifies the protected field
Parse.Cloud.beforeSave(Parse.User, req => {
req.object.set('email', 'trigger-modified@example.com');
});
// Update via raw HTTP
const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/users/${user.id}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': user.getSessionToken(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ publicField: 'updated-value' }),
});
// The server response should NOT contain the protected field
expect(response.data.updatedAt).toBeDefined();
expect(response.data.email).toBeUndefined();
});
it('should include protected fields in update response when protectedFieldsSaveResponseExempt is true', async function () {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
protectedFieldsTriggerExempt: true,
protectedFieldsSaveResponseExempt: true,
});
// Create object with master key
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 });
// beforeSave trigger modifies the protected field
Parse.Cloud.beforeSave('MyClass', req => {
req.object.set('secretField', 'trigger-modified-value');
});
// Update via raw HTTP
const user = await Parse.User.signUp('testuser', 'password');
const response = await request({
method: 'PUT',
url: `http://localhost:8378/1/classes/MyClass/${obj.id}`,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': user.getSessionToken(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ publicField: 'updated-value' }),
});
// The server response SHOULD contain the protected field (current behavior preserved)
expect(response.data.secretField).toBe('trigger-modified-value');
});
it('should strip protected fields from create response when protectedFieldsSaveResponseExempt is false', async function () {
await reconfigureServer({
protectedFields: { MyClass: { '*': ['secretField'] } },
protectedFieldsSaveResponseExempt: false,
});
// Create via raw HTTP as a regular user
const user = await Parse.User.signUp('testuser', 'password');
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/classes/MyClass',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Session-Token': user.getSessionToken(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
secretField: 'hidden-value',
publicField: 'visible-value',
ACL: { '*': { read: true, write: true } },
}),
});
// The server response should NOT contain the protected field
expect(response.data.objectId).toBeDefined();
expect(response.data.createdAt).toBeDefined();
expect(response.data.secretField).toBeUndefined();
});
});
});
+5
View File
@@ -96,4 +96,9 @@ module.exports = [
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.",
},
{
optionKey: 'protectedFieldsSaveResponseExempt',
changeNewDefault: 'false',
solution: "Set 'protectedFieldsSaveResponseExempt' to 'false' to strip protected fields from write operation responses (create, update), consistent with how they are stripped from query results. Set to 'true' to keep the current behavior where protected fields are included in write responses.",
},
];
+6
View File
@@ -484,6 +484,12 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: true,
},
protectedFieldsSaveResponseExempt: {
env: 'PARSE_SERVER_PROTECTED_FIELDS_SAVE_RESPONSE_EXEMPT',
help: 'Whether save operation responses (create, update) are exempt from `protectedFields`. If `true` (default), protected fields modified during a save are included in the response to the client. If `false`, protected fields are stripped from save responses, consistent with how they are stripped from query results. Defaults to `true`.',
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`.",
+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} protectedFieldsSaveResponseExempt Whether save operation responses (create, update) are exempt from `protectedFields`. If `true` (default), protected fields modified during a save are included in the response to the client. If `false`, protected fields are stripped from save responses, consistent with how they are stripped from query results. 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
+4
View File
@@ -179,6 +179,10 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_PROTECTED_FIELDS_TRIGGER_EXEMPT
:DEFAULT: false */
protectedFieldsTriggerExempt: ?boolean;
/* Whether save operation responses (create, update) are exempt from `protectedFields`. If `true` (default), protected fields modified during a save are included in the response to the client. If `false`, protected fields are stripped from save responses, consistent with how they are stripped from query results. Defaults to `true`.
:ENV: PARSE_SERVER_PROTECTED_FIELDS_SAVE_RESPONSE_EXEMPT
:DEFAULT: true */
protectedFieldsSaveResponseExempt: ?boolean;
/* Enable (or disable) anonymous users, defaults to true
:ENV: PARSE_SERVER_ENABLE_ANON_USERS
:DEFAULT: true */
+31
View File
@@ -158,6 +158,9 @@ RestWrite.prototype.execute = function () {
.then(() => {
return this.cleanUserAuthData();
})
.then(() => {
return this.filterProtectedFieldsInResponse();
})
.then(() => {
// Append the authDataResponse if exists
if (this.authDataResponse) {
@@ -1894,6 +1897,34 @@ RestWrite.prototype.cleanUserAuthData = function () {
}
};
// Strips protected fields from the write response when protectedFieldsSaveResponseExempt is false.
RestWrite.prototype.filterProtectedFieldsInResponse = async function () {
if (this.config.protectedFieldsSaveResponseExempt !== false) {
return;
}
if (this.auth.isMaster || this.auth.isMaintenance) {
return;
}
if (!this.response || !this.response.response) {
return;
}
const schemaController = await this.config.database.loadSchema();
const protectedFields = this.config.database.addProtectedFields(
schemaController,
this.className,
this.query ? { objectId: this.query.objectId } : {},
this.auth.user ? [this.auth.user.id].concat(this.auth.userRoles || []) : [],
this.auth,
{}
);
if (!protectedFields) {
return;
}
for (const field of protectedFields) {
delete this.response.response[field];
}
};
RestWrite.prototype._updateResponseWithData = function (response, data) {
const stateController = Parse.CoreManager.getObjectStateController();
const [pending] = stateController.getPendingOps(this.pendingOps.identifier);