diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md
index 7039659ca9..18e8d4275d 100644
--- a/DEPRECATIONS.md
+++ b/DEPRECATIONS.md
@@ -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."
diff --git a/spec/ProtectedFields.spec.js b/spec/ProtectedFields.spec.js
index fb7ffda9f5..fbfc5bf296 100644
--- a/spec/ProtectedFields.spec.js
+++ b/spec/ProtectedFields.spec.js
@@ -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();
+ });
+ });
});
diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js
index da556a13eb..c11d6f1221 100644
--- a/src/Deprecator/Deprecations.js
+++ b/src/Deprecator/Deprecations.js
@@ -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.",
+ },
];
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index faeb203796..0fcebf53b0 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -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`.",
diff --git a/src/Options/docs.js b/src/Options/docs.js
index 0e8946c72b..6543125fe0 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -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.
Default is `false`.
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
diff --git a/src/Options/index.js b/src/Options/index.js
index 839b2ecb85..7b0abe303a 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -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 */
diff --git a/src/RestWrite.js b/src/RestWrite.js
index bdbac02148..355622b888 100644
--- a/src/RestWrite.js
+++ b/src/RestWrite.js
@@ -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);