From bbec2beb7a952f7bb536a32b74f03ced9de38f64 Mon Sep 17 00:00:00 2001 From: awgeorge Date: Fri, 18 Jan 2019 10:06:31 +0000 Subject: [PATCH 01/11] Add tests to prove a user with valid read ACLs still can't read PII data. --- spec/UserPII.spec.js | 89 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js index ffd445aec2..689a6d9e82 100644 --- a/spec/UserPII.spec.js +++ b/spec/UserPII.spec.js @@ -522,4 +522,93 @@ describe('Personally Identifiable Information', () => { .catch(done.fail); }); }); + + describe('with privilaged user', () => { + 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); + }, + e => console.error('error', e) + ) + .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 => { + expect(fetchedUser.get('email')).toBe(EMAIL); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); + + 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(); + }); + }); + + 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()); + }); + }); }); From 6ed3165dc9388bcfe055d121470ffea3f80a54a4 Mon Sep 17 00:00:00 2001 From: awgeorge Date: Fri, 18 Jan 2019 10:42:45 +0000 Subject: [PATCH 02/11] Added negative scenarios for #5301. - Public read ACL should never expose PII to authenticated and non-authenticated - Explicit ACL like custom user Role should be able to read PII --- spec/UserPII.spec.js | 264 +++++++++++++++++++++++++++++++------------ 1 file changed, 193 insertions(+), 71 deletions(-) diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js index 689a6d9e82..286f7ffb87 100644 --- a/spec/UserPII.spec.js +++ b/spec/UserPII.spec.js @@ -521,94 +521,216 @@ describe('Personally Identifiable Information', () => { .then(done) .catch(done.fail); }); - }); - - describe('with privilaged user', () => { - 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 }); + // Explict ACL should be able to read sensitive information + describe('with privilaged user', () => { + 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 }); - 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(); + }); - 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); + }, + e => console.error('error', e) + ) + .then(done) + .catch(done.fail); + }); - 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 => { + 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 => { expect(fetchedUser.get('email')).toBe(EMAIL); - }, - e => console.error('error', e) - ) - .then(done) - .catch(done.fail); - }); + expect(fetchedUser.get('zip')).toBe(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + done(); + }); + }); - 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 => { + 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(); }); + }); + + 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()); + }); }); - 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); + // Public access ACL should always hide sensitive information + describe('with public read ACL', () => { + beforeEach(async done => { + const userACL = new Parse.ACL(); + userACL.setPublicReadAccess(); + await user.setACL(userACL).save(null, { useMasterKey: true }); done(); }); - }); - 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); + 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); + }, + e => console.error('error', e) + ) + .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 => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }) + ); + }); + + 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(); + }) + ); + }); + + 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', }, - e => console.error('error', e.message) - ) - .then(() => done()); + }) + .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()); + }); + + // 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 => { + expect(fetchedUser.get('email')).toBe(undefined); + expect(fetchedUser.get('zip')).toBe(undefined); + expect(fetchedUser.get('ssn')).toBe(undefined); + done(); + }); + }); + + 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(); + }); + }); + }); }); }); }); From 1cf28f6782e619d6021beecfde6759f0467b3959 Mon Sep 17 00:00:00 2001 From: awgeorge Date: Sat, 19 Jan 2019 21:49:30 +0000 Subject: [PATCH 03/11] Fix tests to catch errors --- spec/UserPII.spec.js | 96 ++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js index 286f7ffb87..189413f2ac 100644 --- a/spec/UserPII.spec.js +++ b/spec/UserPII.spec.js @@ -558,12 +558,9 @@ describe('Personally Identifiable Information', () => { userObj.id = user.id; userObj .fetch() - .then( - fetchedUser => { - expect(fetchedUser.get('email')).toBe(EMAIL); - }, - e => console.error('error', e) - ) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(EMAIL); + }) .then(done) .catch(done.fail); }); @@ -573,20 +570,25 @@ describe('Personally Identifiable Information', () => { .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(); - }); + 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 => { @@ -608,7 +610,8 @@ describe('Personally Identifiable Information', () => { }, e => console.error('error', e.message) ) - .then(() => done()); + .then(() => done()) + .catch(done.fail); }); }); @@ -616,7 +619,7 @@ describe('Personally Identifiable Information', () => { describe('with public read ACL', () => { beforeEach(async done => { const userACL = new Parse.ACL(); - userACL.setPublicReadAccess(); + userACL.setPublicReadAccess(true); await user.setACL(userACL).save(null, { useMasterKey: true }); done(); }); @@ -627,12 +630,9 @@ describe('Personally Identifiable Information', () => { userObj.id = user.id; userObj .fetch() - .then( - fetchedUser => { - expect(fetchedUser.get('email')).toBe(undefined); - }, - e => console.error('error', e) - ) + .then(fetchedUser => { + expect(fetchedUser.get('email')).toBe(undefined); + }) .then(done) .catch(done.fail); }); @@ -644,22 +644,27 @@ describe('Personally Identifiable Information', () => { .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(); - }) + 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) ); }); @@ -672,16 +677,14 @@ describe('Personally Identifiable Information', () => { 'X-Parse-Javascript-Key': 'test', }, }) - .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()); + .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. @@ -715,20 +718,25 @@ describe('Personally Identifiable Information', () => { .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(); - }); + 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); }); }); }); From 506ee13afc52fa087eca207b4d9b7799003b033d Mon Sep 17 00:00:00 2001 From: awgeorge Date: Mon, 28 Jan 2019 07:46:36 +0000 Subject: [PATCH 04/11] Add new definition and update tests to reflect --- spec/UserPII.spec.js | 479 ++++++++++++++++++++++++++++++++++++- src/Controllers/types.js | 1 + src/Options/Definitions.js | 9 +- src/Options/index.js | 5 +- src/ParseServer.js | 17 +- 5 files changed, 499 insertions(+), 12 deletions(-) diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js index 189413f2ac..2357a07590 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() @@ -523,7 +523,482 @@ describe('Personally Identifiable Information', () => { }); // Explict ACL should be able to read sensitive information - describe('with privilaged user', () => { + describe('with privilaged 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(); + }, + e => done.fail(e) + ); + }); + + 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); + }); + + // Explict ACL should be able to read sensitive information + describe('with privilaged user CLP', () => { let adminUser; beforeEach(async done => { diff --git a/src/Controllers/types.js b/src/Controllers/types.js index a4419c5134..faf5975af4 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]: boolean }, }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 0d0b715400..283f81f093 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"]}} // For backwards compatiability, do not use a default here. + }, 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..e77a39dadd 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -343,14 +343,15 @@ 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 (!options.protectedFields && options.userSensitiveFields) { + /* eslint-disable no-console */ + 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 */ + options.protectedFields = { _User: { '*': options.userSensitiveFields } }; + } options.masterKeyIps = Array.from( new Set( From 5481e543da57e447d0d4b4db03d6ffc889b2d82a Mon Sep 17 00:00:00 2001 From: awgeorge Date: Mon, 28 Jan 2019 07:50:21 +0000 Subject: [PATCH 05/11] Set default protectedFields and remove previous filter logic --- spec/MongoSchemaCollectionAdapter.spec.js | 2 + spec/ParseLiveQueryServer.spec.js | 4 ++ spec/Schema.spec.js | 70 +++++++++++++++++++ spec/schemas.spec.js | 5 ++ .../Storage/Mongo/MongoSchemaCollection.js | 2 + .../Postgres/PostgresStorageAdapter.js | 2 + src/Controllers/SchemaController.js | 6 +- src/RestQuery.js | 14 +--- 8 files changed, 91 insertions(+), 14 deletions(-) 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..b949dd7f11 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 with incorrect protectedFields', 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/schemas.spec.js b/spec/schemas.spec.js index 190f52d905..a9c9f84da7 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -45,6 +45,9 @@ const defaultClassLevelPermissions = { delete: { '*': true, }, + protectedFields: { + '*': [], + }, }; const plainOldDataSchema = { @@ -1141,6 +1144,7 @@ describe('schemas', () => { update: {}, delete: {}, addField: {}, + protectedFields: {}, }); done(); }); @@ -2037,6 +2041,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/SchemaController.js b/src/Controllers/SchemaController.js index 40e240ce26..4e5ab4379f 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -203,6 +203,7 @@ const CLPValidKeys = Object.freeze([ 'addField', 'readUserFields', 'writeUserFields', + 'protectedFields', ]); function validateCLP(perms: ClassLevelPermissions, fields: SchemaFields) { if (!perms) { @@ -250,7 +251,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, diff --git a/src/RestQuery.js b/src/RestQuery.js index 44b1645456..20a1b79278 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) { @@ -645,7 +634,6 @@ RestQuery.prototype.runFind = function(options = {}) { .then(results => { if (this.className === '_User') { for (var result of results) { - cleanResultOfSensitiveUserInfo(result, this.auth, this.config); cleanResultAuthData(result); } } From 02bde644a04a3273e32f63899eee9f34062c6f4a Mon Sep 17 00:00:00 2001 From: awgeorge Date: Tue, 29 Jan 2019 08:52:49 +0000 Subject: [PATCH 06/11] Add filter sensitive fields logic that apply CLPs\nAdd protectedFields CLP\nAdd defaults for protectedFields CLP\nFix tests --- spec/schemas.spec.js | 7 +++- src/Controllers/DatabaseController.js | 60 ++++++++++++++++++++++++++- src/Controllers/SchemaController.js | 27 ++++++++++-- src/Controllers/types.js | 2 +- src/Options/Definitions.js | 2 +- src/ParseServer.js | 35 ++++++++++++++-- src/RestQuery.js | 2 +- 7 files changed, 122 insertions(+), 13 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index a9c9f84da7..0e34da9a68 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -756,7 +756,12 @@ describe('schemas', () => { newField: { type: 'String' }, ACL: { type: 'ACL' }, }, - classLevelPermissions: defaultClassLevelPermissions, + classLevelPermissions: { + ...defaultClassLevelPermissions, + protectedFields: { + '*': ['email'], + }, + }, }) ).toBeUndefined(); request({ diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 0fdf55a876..e198bc43c3 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; + [...(auth.userRoles || []), '*'].forEach(role => { + // If you are in multiple groups assign the role with the least protectedKeys. + // Technically this could fail if multiple roles protect different fields and produce the same count. + // But we have no way of knowing the role hierarchy here. + const fields = protectedFields[role]; + if (fields && (!protectedKeys || fields.length < protectedKeys.length)) { + protectedKeys = fields; + } + }); + + 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 4e5ab4379f..4bee7788d4 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -18,6 +18,8 @@ const Parse = require('parse/node').Parse; import { StorageAdapter } from '../Adapters/Storage/StorageAdapter'; import DatabaseController from './DatabaseController'; +import Config from '../Config'; +import deepcopy from 'deepcopy'; import type { Schema, SchemaFields, @@ -387,16 +389,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]; @@ -523,6 +543,7 @@ export default class SchemaController { this._dbAdapter = databaseAdapter; this._cache = schemaCache; this.schemaData = new SchemaData(); + this.protectedFields = Config.get(Parse.applicationId).protectedFields; } reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise { @@ -539,7 +560,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 faf5975af4..77a67f6c6d 100644 --- a/src/Controllers/types.js +++ b/src/Controllers/types.js @@ -26,5 +26,5 @@ export type ClassLevelPermissions = { addField?: { [string]: boolean }, readUserFields?: string[], writeUserFields?: string[], - protectedFields?: { [string]: boolean }, + protectedFields?: { [string]: string[] }, }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 283f81f093..4ab352d0ea 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -157,7 +157,7 @@ module.exports.ParseServerOptions = { help: 'Personally identifiable information fields in the user table the should be removed for non-authorized users.', action: parsers.objectParser, - //default: {"_User": {"*": ["email"]}} // For backwards compatiability, do not use a default here. + default: { _User: { '*': ['email'] } }, }, enableAnonymousUsers: { env: 'PARSE_SERVER_ENABLE_ANON_USERS', diff --git a/src/ParseServer.js b/src/ParseServer.js index e77a39dadd..7cebb2cd2c 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]; @@ -344,15 +346,40 @@ function injectDefaults(options: ParseServerOptions) { } // Backwards compatibility - if (!options.protectedFields && options.userSensitiveFields) { + if (!hasProtectedFields && options.userSensitiveFields) { /* eslint-disable no-console */ - console.warn( - `\nDEPRECATED: userSensitiveFields has been replaced by protectedFields allowing the ability to protect fields in all classes with CLP. \n` + !process.env.TESTING && + console.warn( + `\nDEPRECATED: userSensitiveFields has been replaced by protectedFields allowing the ability to protect fields in all classes with CLP. \n` + ); + + const userSensitiveFields = Array.from( + new Set([ + ...(defaults.userSensitiveFields || []), + ...(options.userSensitiveFields || []), + ]) ); + /* eslint-enable no-console */ - options.protectedFields = { _User: { '*': 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( options.masterKeyIps.concat(defaults.masterKeyIps, options.masterKeyIps) diff --git a/src/RestQuery.js b/src/RestQuery.js index 20a1b79278..a9cceffded 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -630,7 +630,7 @@ 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) { From 1f773c02711e22c977f4431a25837e26ffcd8114 Mon Sep 17 00:00:00 2001 From: awgeorge Date: Wed, 30 Jan 2019 22:28:36 +0000 Subject: [PATCH 07/11] Fix linter errors --- src/Controllers/SchemaController.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index 4bee7788d4..0fac3022c8 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -19,6 +19,7 @@ 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, @@ -538,6 +539,7 @@ export default class SchemaController { schemaData: { [string]: Schema }; _cache: any; reloadDataPromise: Promise; + protectedFields: any; constructor(databaseAdapter: StorageAdapter, schemaCache: any) { this._dbAdapter = databaseAdapter; From 4d24e3f7b998d51c5fdf34a6986d8e2ed54b3570 Mon Sep 17 00:00:00 2001 From: awgeorge Date: Thu, 31 Jan 2019 21:44:24 +0000 Subject: [PATCH 08/11] Update based on @milesrichardson comment https://github.com/parse-community/parse-server/pull/5334#discussion_r252693409 --- src/Controllers/DatabaseController.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index e198bc43c3..48175002a7 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1432,14 +1432,16 @@ class DatabaseController { ) return null; - let protectedKeys; - [...(auth.userRoles || []), '*'].forEach(role => { - // If you are in multiple groups assign the role with the least protectedKeys. - // Technically this could fail if multiple roles protect different fields and produce the same count. - // But we have no way of knowing the role hierarchy here. + let protectedKeys = Object.values(protectedFields).reduce( + (acc, val) => acc.concat(val), + [] + ); //.flat(); + [...(auth.userRoles || [])].forEach(role => { const fields = protectedFields[role]; - if (fields && (!protectedKeys || fields.length < protectedKeys.length)) { - protectedKeys = fields; + if (fields) { + protectedKeys = protectedKeys.filter( + value => -1 !== fields.indexOf(value) + ); } }); From 7fdbf03b4fb09e3f7b672930a872e4e8c1777a93 Mon Sep 17 00:00:00 2001 From: awgeorge Date: Fri, 1 Feb 2019 09:55:36 +0000 Subject: [PATCH 09/11] Use ES6 code --- src/Controllers/DatabaseController.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 48175002a7..9308cb42b9 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1439,9 +1439,7 @@ class DatabaseController { [...(auth.userRoles || [])].forEach(role => { const fields = protectedFields[role]; if (fields) { - protectedKeys = protectedKeys.filter( - value => -1 !== fields.indexOf(value) - ); + protectedKeys = protectedKeys.filter(v => fields.includes(v)); } }); From 68b9f2b3f27141736a2dee81b89eca07588602df Mon Sep 17 00:00:00 2001 From: awgeorge Date: Fri, 22 Feb 2019 09:45:22 +0000 Subject: [PATCH 10/11] Updates based on review --- spec/Schema.spec.js | 2 +- spec/UserPII.spec.js | 23 ++++++++++------------- src/Controllers/DatabaseController.js | 2 +- src/ParseServer.js | 2 +- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index b949dd7f11..6de696c557 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -687,7 +687,7 @@ describe('SchemaController', () => { }); }); - it('refuses to add CLP with incorrect protectedFields', 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 }, diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js index 2357a07590..001a97c843 100644 --- a/spec/UserPII.spec.js +++ b/spec/UserPII.spec.js @@ -522,8 +522,8 @@ describe('Personally Identifiable Information', () => { .catch(done.fail); }); - // Explict ACL should be able to read sensitive information - describe('with privilaged user no CLP', () => { + // Explicit ACL should be able to read sensitive information + describe('with privileged user no CLP', () => { let adminUser; beforeEach(async done => { @@ -748,21 +748,18 @@ describe('Personally Identifiable Information', () => { protectedFields: { _User: { '*': ['ssn', 'zip'], 'role:Administrator': [] }, }, - }).then(() => done()); + }).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(); - }, - e => done.fail(e) - ); + 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 => { @@ -997,7 +994,7 @@ describe('Personally Identifiable Information', () => { .catch(done.fail); }); - // Explict ACL should be able to read sensitive information + // Explicit ACL should be able to read sensitive information describe('with privilaged user CLP', () => { let adminUser; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 9308cb42b9..9e81050828 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1439,7 +1439,7 @@ class DatabaseController { [...(auth.userRoles || [])].forEach(role => { const fields = protectedFields[role]; if (fields) { - protectedKeys = protectedKeys.filter(v => fields.includes(v)); + protectedKeys = protectedKeys.filter(fields.includes); } }); diff --git a/src/ParseServer.js b/src/ParseServer.js index 7cebb2cd2c..a2e0beb7dc 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -352,6 +352,7 @@ function injectDefaults(options: ParseServerOptions) { 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([ @@ -360,7 +361,6 @@ function injectDefaults(options: ParseServerOptions) { ]) ); - /* eslint-enable no-console */ options.protectedFields = { _User: { '*': userSensitiveFields } }; } From 7c57ffa5e86b030fc36105bdb5ea7e8aca6294c1 Mon Sep 17 00:00:00 2001 From: awgeorge Date: Fri, 22 Feb 2019 22:38:54 +0000 Subject: [PATCH 11/11] Reverse update. --- src/Controllers/DatabaseController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 9e81050828..9308cb42b9 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1439,7 +1439,7 @@ class DatabaseController { [...(auth.userRoles || [])].forEach(role => { const fields = protectedFields[role]; if (fields) { - protectedKeys = protectedKeys.filter(fields.includes); + protectedKeys = protectedKeys.filter(v => fields.includes(v)); } });