diff --git a/spec/MongoSchemaCollectionAdapter.spec.js b/spec/MongoSchemaCollectionAdapter.spec.js index 2c5e889afc..d8c84e9ac8 100644 --- a/spec/MongoSchemaCollectionAdapter.spec.js +++ b/spec/MongoSchemaCollectionAdapter.spec.js @@ -23,6 +23,7 @@ describe('MongoSchemaCollection', () => { create: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, indexes: { name1: { deviceToken: 1 }, @@ -72,6 +73,7 @@ describe('MongoSchemaCollection', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, indexes: { name1: { deviceToken: 1 }, diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index e2870b14ae..15ba12a0f7 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -257,6 +257,7 @@ describe('ParseLiveQueryServer', function() { find: {}, update: {}, delete: { '*': true }, + protectedFields: {}, }); expect(deleteSpy).toHaveBeenCalled(); @@ -270,6 +271,7 @@ describe('ParseLiveQueryServer', function() { find: {}, update: {}, delete: { '*': true }, + protectedFields: {}, }); done(); }) @@ -1920,6 +1922,7 @@ describe('LiveQueryController', () => { find: {}, update: {}, delete: { '*': true }, + protectedFields: {}, }); expect(deleteSpy).toHaveBeenCalled(); @@ -1933,6 +1936,7 @@ describe('LiveQueryController', () => { find: {}, update: {}, delete: { '*': true }, + protectedFields: {}, }); done(); }) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 5a7c90363d..6de696c557 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -320,6 +320,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -338,6 +339,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }; config.database.loadSchema().then(schema => { schema @@ -461,6 +463,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -653,6 +656,68 @@ describe('SchemaController', () => { }); }); + it('refuses to add CLP with incorrect find', done => { + const levelPermissions = { + find: { '*': false }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': ['email'] }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', {}) + .then(() => schema.reloadData()) + .then(() => + schema.updateClass( + 'NewClass', + {}, + levelPermissions, + {}, + config.database + ) + ) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + }); + + it('refuses to add CLP when incorrectly sending a string to protectedFields object value instead of an array', done => { + const levelPermissions = { + find: { '*': true }, + get: { '*': true }, + create: { '*': true }, + update: { '*': true }, + delete: { '*': true }, + addField: { '*': true }, + protectedFields: { '*': 'email' }, + }; + config.database.loadSchema().then(schema => { + schema + .validateObject('NewClass', {}) + .then(() => schema.reloadData()) + .then(() => + schema.updateClass( + 'NewClass', + {}, + levelPermissions, + {}, + config.database + ) + ) + .then(done.fail) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_JSON); + done(); + }); + }); + }); + it('will create classes', done => { config.database .loadSchema() @@ -706,6 +771,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -751,6 +817,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -782,6 +849,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -815,6 +883,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); @@ -1002,6 +1071,7 @@ describe('SchemaController', () => { update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }, }; expect(dd(actualSchema, expectedSchema)).toEqual(undefined); diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js index ffd445aec2..001a97c843 100644 --- a/spec/UserPII.spec.js +++ b/spec/UserPII.spec.js @@ -269,7 +269,7 @@ describe('Personally Identifiable Information', () => { .then(() => done()); }); - describe('with configured sensitive fields', () => { + describe('with deprecated configured sensitive fields', () => { beforeEach(done => { reconfigureServer({ userSensitiveFields: ['ssn', 'zip'] }).then(() => done() @@ -521,5 +521,696 @@ describe('Personally Identifiable Information', () => { .then(done) .catch(done.fail); }); + + // Explicit ACL should be able to read sensitive information + describe('with privileged user no CLP', () => { + let adminUser; + + beforeEach(async done => { + const adminRole = await new Parse.Role( + 'Administrator', + new Parse.ACL() + ).save(null, { useMasterKey: true }); + + const managementRole = new Parse.Role( + 'managementOf_user' + user.id, + new Parse.ACL(user) + ); + managementRole.getRoles().add(adminRole); + await managementRole.save(null, { useMasterKey: true }); + + const userACL = new Parse.ACL(); + userACL.setReadAccess(managementRole, true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + + adminUser = await Parse.User.signUp('administrator', 'secure'); + adminUser = await Parse.User.logIn(adminUser.get('username'), 'secure'); + await adminRole + .getUsers() + .add(adminUser) + .save(null, { useMasterKey: true }); + + done(); + }); + + it('privilaged user should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + + it('privilaged user should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('privilaged user should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('privilaged user should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': adminUser.getSessionToken(), + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, + e => console.error('error', e.message) + ) + .then(() => done()) + .catch(done.fail); + }); + }); + + // Public access ACL should always hide sensitive information + describe('with public read ACL', () => { + beforeEach(async done => { + const userACL = new Parse.ACL(); + userACL.setPublicReadAccess(true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + done(); + }); + + it('should not be able to get user PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should not be able to get user PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not be able to get user PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); + + // Even with an authenticated user, Public read ACL should never expose sensitive data. + describe('with another authenticated user', () => { + let anotherUser; + + beforeEach(async done => { + return Parse.User.signUp('another', 'abc') + .then(loggedInUser => (anotherUser = loggedInUser)) + .then(() => Parse.User.logIn(anotherUser.get('username'), 'abc')) + .then(() => done()); + }); + + it('should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then( + fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }, + e => console.error('error', e) + ) + .then(done) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + }); + }); + }); + + describe('with configured sensitive fields via CLP', () => { + beforeEach(done => { + reconfigureServer({ + protectedFields: { + _User: { '*': ['ssn', 'zip'], 'role:Administrator': [] }, + }, + }).then(done); + }); + + it('should be able to get own PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj.fetch().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }, done.fail); + }); + + it('should not be able to get PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then( + fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + }, + e => console.error('error', e) + ) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get PII via API with object using master key', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch({ useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + }, done.fail) + .then(done) + .catch(done.fail); + }); + }); + + it('should be able to get own PII via API with Find', done => { + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).first().then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Find using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .first({ useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should be able to get own PII via API with Get', done => { + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + it('should not get PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User).get(user.id).then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + it('should get PII via API with Get using master key', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id, { useMasterKey: true }) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + ); + }); + + it('should not get PII via REST', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.ssn).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, done.fail) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST with self credentials', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + () => {} + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST using master key', done => { + request({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should not get PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with self credentials', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': user.getSessionToken(), + }, + }) + .then( + response => { + const fetchedUser = response.data; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + () => {} + ) + .then(done) + .catch(done.fail); + }); + + it('should get PII via REST by ID with master key', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Master-Key': 'test', + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => done.fail(e.data) + ) + .then(done) + .catch(done.fail); + }); + + // Explicit ACL should be able to read sensitive information + describe('with privilaged user CLP', () => { + let adminUser; + + beforeEach(async done => { + const adminRole = await new Parse.Role( + 'Administrator', + new Parse.ACL() + ).save(null, { useMasterKey: true }); + + const managementRole = new Parse.Role( + 'managementOf_user' + user.id, + new Parse.ACL(user) + ); + managementRole.getRoles().add(adminRole); + await managementRole.save(null, { useMasterKey: true }); + + const userACL = new Parse.ACL(); + userACL.setReadAccess(managementRole, true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + + adminUser = await Parse.User.signUp('administrator', 'secure'); + adminUser = await Parse.User.logIn(adminUser.get('username'), 'secure'); + await adminRole + .getUsers() + .add(adminUser) + .save(null, { useMasterKey: true }); + + done(); + }); + + it('privilaged user should be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + }) + .then(done) + .catch(done.fail); + }); + + it('privilaged user should be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('privilaged user should be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }) + .catch(done.fail); + }); + + it('privilaged user should get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Session-Token': adminUser.getSessionToken(), + }, + }) + .then( + response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => console.error('error', e.message) + ) + .then(() => done()) + .catch(done.fail); + }); + }); + + // Public access ACL should always hide sensitive information + describe('with public read ACL', () => { + beforeEach(async done => { + const userACL = new Parse.ACL(); + userACL.setPublicReadAccess(true); + await user.setACL(userACL).save(null, { useMasterKey: true }); + done(); + }); + + it('should not be able to get user PII via API with object', done => { + Parse.User.logOut().then(() => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should not be able to get user PII via API with Find', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not be able to get user PII via API with Get', done => { + Parse.User.logOut().then(() => + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail) + ); + }); + + it('should not get user PII via REST by ID', done => { + request({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + }, + }) + .then(response => { + const result = response.data; + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }) + .then(() => done()) + .catch(done.fail); + }); + + // Even with an authenticated user, Public read ACL should never expose sensitive data. + describe('with another authenticated user', () => { + let anotherUser; + + beforeEach(async done => { + return Parse.User.signUp('another', 'abc') + .then(loggedInUser => (anotherUser = loggedInUser)) + .then(() => Parse.User.logIn(anotherUser.get('username'), 'abc')) + .then(() => done()); + }); + + it('should not be able to get user PII via API with object', done => { + const userObj = new (Parse.Object.extend(Parse.User))(); + userObj.id = user.id; + userObj + .fetch() + .then( + fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }, + e => console.error('error', e) + ) + .then(done) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Find', done => { + new Parse.Query(Parse.User) + .equalTo('objectId', user.id) + .find() + .then(fetchedUser => { + fetchedUser = fetchedUser[0]; + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + + it('should not be able to get user PII via API with Get', done => { + new Parse.Query(Parse.User) + .get(user.id) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + .catch(done.fail); + }); + }); + }); }); }); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 190f52d905..0e34da9a68 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -45,6 +45,9 @@ const defaultClassLevelPermissions = { delete: { '*': true, }, + protectedFields: { + '*': [], + }, }; const plainOldDataSchema = { @@ -753,7 +756,12 @@ describe('schemas', () => { newField: { type: 'String' }, ACL: { type: 'ACL' }, }, - classLevelPermissions: defaultClassLevelPermissions, + classLevelPermissions: { + ...defaultClassLevelPermissions, + protectedFields: { + '*': ['email'], + }, + }, }) ).toBeUndefined(); request({ @@ -1141,6 +1149,7 @@ describe('schemas', () => { update: {}, delete: {}, addField: {}, + protectedFields: {}, }); done(); }); @@ -2037,6 +2046,7 @@ describe('schemas', () => { update: {}, delete: {}, addField: {}, + protectedFields: {}, }); }) .then(done) diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index e2b2b2cbd1..160bccf450 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -62,6 +62,7 @@ const emptyCLPS = Object.freeze({ update: {}, delete: {}, addField: {}, + protectedFields: {}, }); const defaultCLPS = Object.freeze({ @@ -71,6 +72,7 @@ const defaultCLPS = Object.freeze({ update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }); function mongoSchemaToParseSchema(mongoSchema) { diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index a24aad0e87..cda9a52561 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -106,6 +106,7 @@ const emptyCLPS = Object.freeze({ update: {}, delete: {}, addField: {}, + protectedFields: {}, }); const defaultCLPS = Object.freeze({ @@ -115,6 +116,7 @@ const defaultCLPS = Object.freeze({ update: { '*': true }, delete: { '*': true }, addField: { '*': true }, + protectedFields: { '*': [] }, }); const toParseSchema = schema => { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 0fdf55a876..9308cb42b9 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -162,7 +162,15 @@ const validateQuery = (query: any): void => { }; // Filters out any data that shouldn't be on this REST-formatted object. -const filterSensitiveData = (isMaster, aclGroup, className, object) => { +const filterSensitiveData = ( + isMaster, + aclGroup, + className, + protectedFields, + object +) => { + protectedFields && protectedFields.forEach(k => delete object[k]); + if (className !== '_User') { return object; } @@ -1141,7 +1149,8 @@ class DatabaseController { distinct, pipeline, readPreference, - }: any = {} + }: any = {}, + auth: any = {} ): Promise { const isMaster = acl === undefined; const aclGroup = acl || []; @@ -1206,6 +1215,7 @@ class DatabaseController { this.reduceInRelation(className, query, schemaController) ) .then(() => { + let protectedFields; if (!isMaster) { query = this.addPointerPermissions( schemaController, @@ -1214,6 +1224,15 @@ class DatabaseController { query, aclGroup ); + // ProtectedFields is generated before executing the query so we + // can optimize the query using Mongo Projection at a later stage. + protectedFields = this.addProtectedFields( + schemaController, + className, + query, + aclGroup, + auth + ); } if (!query) { if (op === 'get') { @@ -1276,6 +1295,7 @@ class DatabaseController { isMaster, aclGroup, className, + protectedFields, object ); }) @@ -1390,6 +1410,42 @@ class DatabaseController { } } + addProtectedFields( + schema: SchemaController.SchemaController, + className: string, + query: any = {}, + aclGroup: any[] = [], + auth: any = {} + ) { + const perms = schema.getClassLevelPermissions(className); + if (!perms) return null; + + const protectedFields = perms.protectedFields; + if (!protectedFields) return null; + + if (aclGroup.indexOf(query.objectId) > -1) return null; + if ( + Object.keys(query).length === 0 && + auth && + auth.user && + aclGroup.indexOf(auth.user.id) > -1 + ) + return null; + + let protectedKeys = Object.values(protectedFields).reduce( + (acc, val) => acc.concat(val), + [] + ); //.flat(); + [...(auth.userRoles || [])].forEach(role => { + const fields = protectedFields[role]; + if (fields) { + protectedKeys = protectedKeys.filter(v => fields.includes(v)); + } + }); + + return protectedKeys; + } + // TODO: create indexes on first creation of a _User object. Otherwise it's impossible to // have a Parse app without it having a _User collection. performInitialization() { diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 40e240ce26..0fac3022c8 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -18,6 +18,9 @@ const Parse = require('parse/node').Parse; import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; import DatabaseController from './DatabaseController'; +import Config from '../Config'; +// @flow-disable-next +import deepcopy from 'deepcopy'; import type { Schema, SchemaFields, @@ -203,6 +206,7 @@ const CLPValidKeys = Object.freeze([ 'addField', 'readUserFields', 'writeUserFields', + 'protectedFields', ]); function validateCLP(perms: ClassLevelPermissions, fields: SchemaFields) { if (!perms) { @@ -250,7 +254,10 @@ function validateCLP(perms: ClassLevelPermissions, fields: SchemaFields) { verifyPermissionKey(key); // @flow-disable-next const perm = perms[operation][key]; - if (perm !== true) { + if ( + perm !== true && + (operation !== 'protectedFields' || !Array.isArray(perm)) + ) { // @flow-disable-next throw new Parse.Error( Parse.Error.INVALID_JSON, @@ -383,16 +390,34 @@ const convertAdapterSchemaToParseSchema = ({ ...schema }) => { class SchemaData { __data: any; - constructor(allSchemas = []) { + __protectedFields: any; + constructor(allSchemas = [], protectedFields = {}) { this.__data = {}; + this.__protectedFields = protectedFields; allSchemas.forEach(schema => { Object.defineProperty(this, schema.className, { get: () => { if (!this.__data[schema.className]) { const data = {}; data.fields = injectDefaultSchema(schema).fields; - data.classLevelPermissions = schema.classLevelPermissions; + data.classLevelPermissions = deepcopy(schema.classLevelPermissions); data.indexes = schema.indexes; + + const classProtectedFields = this.__protectedFields[ + schema.className + ]; + if (classProtectedFields) { + for (const key in classProtectedFields) { + const unq = new Set([ + ...(data.classLevelPermissions.protectedFields[key] || []), + ...classProtectedFields[key], + ]); + data.classLevelPermissions.protectedFields[key] = Array.from( + unq + ); + } + } + this.__data[schema.className] = data; } return this.__data[schema.className]; @@ -514,11 +539,13 @@ export default class SchemaController { schemaData: { [string]: Schema }; _cache: any; reloadDataPromise: Promise; + protectedFields: any; constructor(databaseAdapter: StorageAdapter, schemaCache: any) { this._dbAdapter = databaseAdapter; this._cache = schemaCache; this.schemaData = new SchemaData(); + this.protectedFields = Config.get(Parse.applicationId).protectedFields; } reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise { @@ -535,7 +562,7 @@ export default class SchemaController { .then(() => { return this.getAllClasses(options).then( allSchemas => { - this.schemaData = new SchemaData(allSchemas); + this.schemaData = new SchemaData(allSchemas, this.protectedFields); delete this.reloadDataPromise; }, err => { diff --git a/src/Controllers/types.js b/src/Controllers/types.js index a4419c5134..77a67f6c6d 100644 --- a/src/Controllers/types.js +++ b/src/Controllers/types.js @@ -26,4 +26,5 @@ export type ClassLevelPermissions = { addField?: { [string]: boolean }, readUserFields?: string[], writeUserFields?: string[], + protectedFields?: { [string]: string[] }, }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 0d0b715400..4ab352d0ea 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -148,10 +148,17 @@ module.exports.ParseServerOptions = { userSensitiveFields: { env: 'PARSE_SERVER_USER_SENSITIVE_FIELDS', help: - 'Personally identifiable information fields in the user table the should be removed for non-authorized users.', + 'Personally identifiable information fields in the user table the should be removed for non-authorized users. **Deprecated** @see protectedFields', action: parsers.arrayParser, default: ['email'], }, + protectedFields: { + env: 'PARSE_SERVER_PROTECTED_FIELDS', + help: + 'Personally identifiable information fields in the user table the should be removed for non-authorized users.', + action: parsers.objectParser, + default: { _User: { '*': ['email'] } }, + }, enableAnonymousUsers: { env: 'PARSE_SERVER_ENABLE_ANON_USERS', help: 'Enable (or disable) anon users, defaults to true', diff --git a/src/Options/index.js b/src/Options/index.js index 8ff3d371cd..0fb744cf5c 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -81,9 +81,12 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_PRESERVE_FILE_NAME :DEFAULT: false */ preserveFileName: ?boolean; - /* Personally identifiable information fields in the user table the should be removed for non-authorized users. + /* Personally identifiable information fields in the user table the should be removed for non-authorized users. Deprecated @see protectedFields :DEFAULT: ["email"] */ userSensitiveFields: ?(string[]); + /* Protected fields that should be treated with extra security when fetching details. + :DEFAULT: {"_User": {"*": ["email"]}} */ + protectedFields: ?any; /* Enable (or disable) anon users, defaults to true :ENV: PARSE_SERVER_ENABLE_ANON_USERS :DEFAULT: true */ diff --git a/src/ParseServer.js b/src/ParseServer.js index e28a75e39b..a2e0beb7dc 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -333,6 +333,8 @@ function addParseCloud() { } function injectDefaults(options: ParseServerOptions) { + const hasProtectedFields = !!options.protectedFields; + Object.keys(defaults).forEach(key => { if (!options.hasOwnProperty(key)) { options[key] = defaults[key]; @@ -343,14 +345,40 @@ function injectDefaults(options: ParseServerOptions) { options.serverURL = `http://localhost:${options.port}${options.mountPath}`; } - options.userSensitiveFields = Array.from( - new Set( - options.userSensitiveFields.concat( - defaults.userSensitiveFields, - options.userSensitiveFields - ) - ) - ); + // Backwards compatibility + if (!hasProtectedFields && options.userSensitiveFields) { + /* eslint-disable no-console */ + !process.env.TESTING && + console.warn( + `\nDEPRECATED: userSensitiveFields has been replaced by protectedFields allowing the ability to protect fields in all classes with CLP. \n` + ); + /* eslint-enable no-console */ + + const userSensitiveFields = Array.from( + new Set([ + ...(defaults.userSensitiveFields || []), + ...(options.userSensitiveFields || []), + ]) + ); + + options.protectedFields = { _User: { '*': userSensitiveFields } }; + } + + // Merge protectedFields options with defaults. + Object.keys(defaults.protectedFields).forEach(c => { + const cur = options.protectedFields[c]; + if (!cur) { + options.protectedFields[c] = defaults.protectedFields[c]; + } else { + Object.keys(defaults.protectedFields[c]).forEach(r => { + const unq = new Set([ + ...(options.protectedFields[c][r] || []), + ...defaults.protectedFields[c][r], + ]); + options.protectedFields[c][r] = Array.from(unq); + }); + } + }); options.masterKeyIps = Array.from( new Set( diff --git a/src/RestQuery.js b/src/RestQuery.js index 44b1645456..a9cceffded 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -565,19 +565,8 @@ RestQuery.prototype.replaceDontSelect = function() { }); }; -const cleanResultOfSensitiveUserInfo = function(result, auth, config) { - delete result.password; - - if (auth.isMaster || (auth.user && auth.user.id === result.objectId)) { - return; - } - - for (const field of config.userSensitiveFields) { - delete result[field]; - } -}; - const cleanResultAuthData = function(result) { + delete result.password; if (result.authData) { Object.keys(result.authData).forEach(provider => { if (result.authData[provider] === null) { @@ -641,11 +630,10 @@ RestQuery.prototype.runFind = function(options = {}) { findOptions.op = options.op; } return this.config.database - .find(this.className, this.restWhere, findOptions) + .find(this.className, this.restWhere, findOptions, this.auth) .then(results => { if (this.className === '_User') { for (var result of results) { - cleanResultOfSensitiveUserInfo(result, this.auth, this.config); cleanResultAuthData(result); } }