diff --git a/spec/UserPII.spec.js b/spec/UserPII.spec.js new file mode 100644 index 0000000000..2d0c26082c --- /dev/null +++ b/spec/UserPII.spec.js @@ -0,0 +1,503 @@ +'use strict'; + +const Parse = require('parse/node'); +const request = require('request-promise'); + +// const Config = require('../src/Config'); + +const EMAIL = 'foo@bar.com'; +const ZIP = '10001'; +const SSN = '999-99-9999'; + +describe('Personally Identifiable Information', () => { + let user; + + beforeEach(done => { + return Parse.User.signUp('tester', 'abc') + .then(loggedInUser => user = loggedInUser) + .then(() => Parse.User.logIn(user.get('username'), 'abc')) + .then(() => user + .set('email', EMAIL) + .set('zip', ZIP) + .set('ssn', SSN) + .save()) + .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); + }, e => console.error('error', e)) + .done(() => done()); + }); + + 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); + }) + .fail(e => { + done.fail(JSON.stringify(e)); + }) + .done(() => done()); + }); + }); + + 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); + }, e => console.error('error', e)) + .done(() => done()); + }); + }); + + + 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(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + 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(ZIP); + expect(fetchedUser.get('ssn')).toBe(SSN); + 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.get({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test' + } + }) + .then( + result => { + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(undefined); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST with self credentials', (done) => { + request.get({ + 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( + result => { + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST using master key', (done) => { + request.get({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test' + } + }) + .then( + result => { + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should not get PII via REST by ID', (done) => { + request.get({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test' + } + }) + .then( + result => { + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(undefined); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST by ID with self credentials', (done) => { + request.get({ + 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( + result => { + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST by ID with master key', (done) => { + request.get({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Master-Key': 'test', + } + }) + .then( + result => { + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + describe('with configured sensitive fields', () => { + beforeEach((done) => { + reconfigureServer({ userSensitiveFields: ['ssn', 'zip'] }) + .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)) + .done(() => done()); + }); + }); + + 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); + }, e => console.error('error', e)) + .done(() => done()); + }); + }); + + + 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.get({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test' + } + }) + .then( + result => { + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.ssn).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST with self credentials', (done) => { + request.get({ + 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( + result => { + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST using master key', (done) => { + request.get({ + url: 'http://localhost:8378/1/classes/_User', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test' + } + }) + .then( + result => { + const fetchedUser = result.results[0]; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + expect(fetchedUser.ssn).toBe(SSN); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should not get PII via REST by ID', (done) => { + request.get({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test' + } + }) + .then( + result => { + const fetchedUser = result; + expect(fetchedUser.zip).toBe(undefined); + expect(fetchedUser.email).toBe(undefined); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST by ID with self credentials', (done) => { + request.get({ + 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( + result => { + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + + it('should get PII via REST by ID with master key', (done) => { + request.get({ + url: `http://localhost:8378/1/classes/_User/${user.id}`, + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test', + 'X-Parse-Master-Key': 'test', + } + }) + .then( + result => { + const fetchedUser = result; + expect(fetchedUser.zip).toBe(ZIP); + expect(fetchedUser.email).toBe(EMAIL); + }, + e => console.error('error', e.message) + ).done(() => done()); + }); + }); +}); diff --git a/src/Config.js b/src/Config.js index a7d9de9d79..1cb0771f62 100644 --- a/src/Config.js +++ b/src/Config.js @@ -34,6 +34,7 @@ export class Config { this.fileKey = cacheInfo.fileKey; this.facebookAppIds = cacheInfo.facebookAppIds; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; + this.userSensitiveFields = cacheInfo.userSensitiveFields; // Create a new DatabaseController per request if (cacheInfo.databaseController) { diff --git a/src/ParseServer.js b/src/ParseServer.js index ca6205e819..e578250d04 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -113,6 +113,7 @@ class ParseServer { webhookKey, fileKey, facebookAppIds = [], + userSensitiveFields = [], enableAnonymousUsers = defaults.enableAnonymousUsers, allowClientClassCreation = defaults.allowClientClassCreation, oauth = {}, @@ -155,6 +156,11 @@ class ParseServer { throw 'When using an explicit database adapter, you must also use an explicit filesAdapter.'; } + userSensitiveFields = Array.from(new Set(userSensitiveFields.concat( + defaults.userSensitiveFields, + userSensitiveFields + ))); + const loggerControllerAdapter = loadAdapter(loggerAdapter, WinstonLoggerAdapter, { jsonLogs, logsFolder, verbose, logLevel, silent }); const loggerController = new LoggerController(loggerControllerAdapter, appId); logging.setLogger(loggerController); @@ -222,7 +228,8 @@ class ParseServer { revokeSessionOnPasswordReset, databaseController, schemaCacheTTL, - enableSingleSchemaCache + enableSingleSchemaCache, + userSensitiveFields }); // To maintain compatibility. TODO: Remove in some version that breaks backwards compatibility diff --git a/src/RestQuery.js b/src/RestQuery.js index e23c322f43..2cdfc2aaed 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -386,6 +386,32 @@ 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) { + if (result.authData) { + Object.keys(result.authData).forEach((provider) => { + if (result.authData[provider] === null) { + delete result.authData[provider]; + } + }); + + if (Object.keys(result.authData).length == 0) { + delete result.authData; + } + } +}; + // Returns a promise for whether it was successful. // Populates this.response with an object that only has 'results'. RestQuery.prototype.runFind = function(options = {}) { @@ -406,18 +432,8 @@ RestQuery.prototype.runFind = function(options = {}) { this.className, this.restWhere, findOptions).then((results) => { if (this.className === '_User') { for (var result of results) { - delete result.password; - - if (result.authData) { - Object.keys(result.authData).forEach((provider) => { - if (result.authData[provider] === null) { - delete result.authData[provider]; - } - }); - if (Object.keys(result.authData).length == 0) { - delete result.authData; - } - } + cleanResultOfSensitiveUserInfo(result, this.auth, this.config); + cleanResultAuthData(result); } } diff --git a/src/cli/definitions/parse-server.js b/src/cli/definitions/parse-server.js index 1fce229b27..78bc8eefae 100644 --- a/src/cli/definitions/parse-server.js +++ b/src/cli/definitions/parse-server.js @@ -160,6 +160,10 @@ export default { help: "Max file size for uploads.", default: "20mb" }, + "userSensitiveFields": { + help: "Personally identifiable information fields in the user table the should be removed for non-authorized users.", + default: "email" + }, "sessionLength": { env: "PARSE_SERVER_SESSION_LENGTH", help: "Session duration, defaults to 1 year", diff --git a/src/defaults.js b/src/defaults.js index 8bf8784f63..c2d647c4ca 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -31,5 +31,6 @@ export default { sessionLength: 31536000, expireInactiveSessions: true, revokeSessionOnPasswordReset: true, - schemaCacheTTL: 5000 // in ms + schemaCacheTTL: 5000, // in ms + userSensitiveFields: ['email'] }