Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DEPRECATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
152 changes: 152 additions & 0 deletions spec/ProtectedFields.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 5 additions & 0 deletions src/Deprecator/Deprecations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`.",
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
31 changes: 31 additions & 0 deletions src/RestWrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading