diff --git a/README.md b/README.md index aad080e158..27708b0053 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo * `customPages` - A hash with urls to override email verification links, password reset links and specify frame url for masking user-facing pages. Available keys: `parseFrameURL`, `invalidLink`, `choosePassword`, `passwordResetSuccess`, `verifyEmailSuccess`. * `middleware` - (CLI only), a module name, function that is an express middleware. When using the CLI, the express app will load it just **before** mounting parse-server on the mount path. This option is useful for injecting a monitoring middleware. * `masterKeyIps` - The array of ip addresses where masterKey usage will be restricted to only these ips. (Default to [] which means allow all ips). If you're using this feature and have `useMasterKey: true` in cloudcode, make sure that you put your own ip in this list. +* `readOnlyMasterKey` - A masterKey that has full read access to the data, but no write access. This key should be treated the same way as your masterKey, keeping it private. ##### Logging diff --git a/spec/helper.js b/spec/helper.js index 83e0dd22a1..98182cfa0d 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -90,6 +90,7 @@ var defaultConfiguration = { restAPIKey: 'rest', webhookKey: 'hook', masterKey: 'test', + readOnlyMasterKey: 'read-only-test', fileKey: 'test', silent, logLevel, diff --git a/spec/rest.spec.js b/spec/rest.spec.js index b0a7e955bf..1e897ef3c0 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -4,6 +4,7 @@ var auth = require('../src/Auth'); var Config = require('../src/Config'); var Parse = require('parse/node').Parse; var rest = require('../src/rest'); +var RestWrite = require('../src/RestWrite'); var request = require('request'); var rp = require('request-promise'); @@ -623,5 +624,139 @@ describe('rest update', () => { done(); }); }); +}); + +describe('read-only masterKey', () => { + it('properly throws on rest.create, rest.update and rest.del', () => { + const config = Config.get('test'); + const readOnly = auth.readOnly(config); + expect(() => { + rest.create(config, readOnly, 'AnObject', {}) + }).toThrow(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, `read-only masterKey isn't allowed to perform the create operation.`)); + expect(() => { + rest.update(config, readOnly, 'AnObject', {}) + }).toThrow(); + expect(() => { + rest.del(config, readOnly, 'AnObject', {}) + }).toThrow(); + }); + + it('properly blocks writes', (done) => { + reconfigureServer({ + readOnlyMasterKey: 'yolo-read-only' + }).then(() => { + return rp.post(`${Parse.serverURL}/classes/MyYolo`, { + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'yolo-read-only', + }, + json: { foo: 'bar' } + }); + }).then(done.fail).catch((res) => { + expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.error.error).toBe('read-only masterKey isn\'t allowed to perform the create operation.'); + done(); + }); + }); + it('should throw when masterKey and readOnlyMasterKey are the same', (done) => { + reconfigureServer({ + masterKey: 'yolo', + readOnlyMasterKey: 'yolo' + }).then(done.fail).catch((err) => { + expect(err).toEqual(new Error('masterKey and readOnlyMasterKey should be different')); + done(); + }); + }); + + it('should throw when trying to create RestWrite', () => { + const config = Config.get('test'); + expect(() => { + new RestWrite(config, auth.readOnly(config)); + }).toThrow(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Cannot perform a write operation when using readOnlyMasterKey')); + }); + + it('should throw when trying to create schema', (done) => { + return rp.post(`${Parse.serverURL}/schemas`, { + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + json: {} + }).then(done.fail).catch((res) => { + expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.error.error).toBe('read-only masterKey isn\'t allowed to create a schema.'); + done(); + }); + }); + + it('should throw when trying to create schema with a name', (done) => { + return rp.post(`${Parse.serverURL}/schemas/MyClass`, { + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + json: {} + }).then(done.fail).catch((res) => { + expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.error.error).toBe('read-only masterKey isn\'t allowed to create a schema.'); + done(); + }); + }); + + it('should throw when trying to update schema', (done) => { + return rp.put(`${Parse.serverURL}/schemas/MyClass`, { + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + json: {} + }).then(done.fail).catch((res) => { + expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.error.error).toBe('read-only masterKey isn\'t allowed to update a schema.'); + done(); + }); + }); + + it('should throw when trying to delete schema', (done) => { + return rp.del(`${Parse.serverURL}/schemas/MyClass`, { + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + json: {} + }).then(done.fail).catch((res) => { + expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.error.error).toBe('read-only masterKey isn\'t allowed to delete a schema.'); + done(); + }); + }); + + it('should throw when trying to update the global config', (done) => { + return rp.put(`${Parse.serverURL}/config`, { + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + json: {} + }).then(done.fail).catch((res) => { + expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.error.error).toBe('read-only masterKey isn\'t allowed to update the config.'); + done(); + }); + }); + + it('should throw when trying to send push', (done) => { + return rp.post(`${Parse.serverURL}/push`, { + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-Master-Key': 'read-only-test', + }, + json: {} + }).then(done.fail).catch((res) => { + expect(res.error.code).toBe(Parse.Error.OPERATION_FORBIDDEN); + expect(res.error.error).toBe('read-only masterKey isn\'t allowed to send push notifications.'); + done(); + }); + }); }); diff --git a/src/Auth.js b/src/Auth.js index fcf59fcd01..60273b9638 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -4,11 +4,12 @@ var RestQuery = require('./RestQuery'); // An Auth object tells you who is requesting something and whether // the master key was used. // userObject is a Parse.User and can be null if there's no user. -function Auth({ config, isMaster = false, user, installationId } = {}) { +function Auth({ config, isMaster = false, isReadOnly = false, user, installationId } = {}) { this.config = config; this.installationId = installationId; this.isMaster = isMaster; this.user = user; + this.isReadOnly = isReadOnly; // Assuming a users roles won't change during a single request, we'll // only load them once. @@ -34,6 +35,11 @@ function master(config) { return new Auth({ config, isMaster: true }); } +// A helper to get a master-level Auth object +function readOnly(config) { + return new Auth({ config, isMaster: true, isReadOnly: true }); +} + // A helper to get a nobody-level Auth object function nobody(config) { return new Auth({ config, isMaster: false }); @@ -207,9 +213,10 @@ Auth.prototype._getAllRolesNamesForRoleIds = function(roleIDs, names = [], queri } module.exports = { - Auth: Auth, - master: master, - nobody: nobody, + Auth, + master, + nobody, + readOnly, getAuthForSessionToken, getAuthForLegacySessionToken }; diff --git a/src/Config.js b/src/Config.js index 7d3fdff538..d9eec85da7 100644 --- a/src/Config.js +++ b/src/Config.js @@ -60,8 +60,15 @@ export class Config { emailVerifyTokenValidityDuration, accountLockout, passwordPolicy, - masterKeyIps + masterKeyIps, + masterKey, + readOnlyMasterKey, }) { + + if (masterKey === readOnlyMasterKey) { + throw new Error('masterKey and readOnlyMasterKey should be different'); + } + const emailAdapter = userController.adapter; if (verifyUserEmails) { this.validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 4137b8d769..70ffdb76a4 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -123,6 +123,10 @@ module.exports.ParseServerOptions = { "env": "PARSE_SERVER_REST_API_KEY", "help": "Key for REST calls" }, + "readOnlyMasterKey": { + "env": "PARSE_SERVER_READ_ONLY_MASTER_KEY", + "help": "Read-only key, which has the same capabilities as MasterKey without writes" + }, "webhookKey": { "env": "PARSE_SERVER_WEBHOOK_KEY", "help": "Key sent with outgoing webhook calls" diff --git a/src/Options/index.js b/src/Options/index.js index 4b5020d285..d21e715270 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -58,6 +58,8 @@ export interface ParseServerOptions { /* Key for REST calls :ENV: PARSE_SERVER_REST_API_KEY */ restAPIKey: ?string; + /* Read-only key, which has the same capabilities as MasterKey without writes */ + readOnlyMasterKey: ?string; /* Key sent with outgoing webhook calls */ webhookKey: ?string; /* Key for your files */ diff --git a/src/RestWrite.js b/src/RestWrite.js index 8dd13a32f0..4057fda5a0 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -24,6 +24,9 @@ import _ from 'lodash'; // everything. It also knows to use triggers and special modifications // for the _User class. function RestWrite(config, auth, className, query, data, originalData, clientSDK) { + if (auth.isReadOnly) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Cannot perform a write operation when using readOnlyMasterKey'); + } this.config = config; this.auth = auth; this.className = className; diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js index dce5c280e5..42ba581938 100644 --- a/src/Routers/GlobalConfigRouter.js +++ b/src/Routers/GlobalConfigRouter.js @@ -1,5 +1,5 @@ // global_config.js - +import Parse from 'parse/node'; import PromiseRouter from '../PromiseRouter'; import * as middleware from "../middlewares"; @@ -16,6 +16,9 @@ export class GlobalConfigRouter extends PromiseRouter { } updateGlobalConfig(req) { + if (req.auth.isReadOnly) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to update the config.'); + } const params = req.body.params; // Transform in dot notation to make sure it works const update = Object.keys(params).reduce((acc, key) => { diff --git a/src/Routers/PushRouter.js b/src/Routers/PushRouter.js index 5f5cf656cc..14def667e6 100644 --- a/src/Routers/PushRouter.js +++ b/src/Routers/PushRouter.js @@ -9,6 +9,9 @@ export class PushRouter extends PromiseRouter { } static handlePOST(req) { + if (req.auth.isReadOnly) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to send push notifications.'); + } const pushController = req.config.pushController; if (!pushController) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Push controller is not set'); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 1fac1e88fa..0e572c88a5 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -34,6 +34,9 @@ function getOneSchema(req) { } function createSchema(req) { + if (req.auth.isReadOnly) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to create a schema.'); + } if (req.params.className && req.body.className) { if (req.params.className != req.body.className) { return classNameMismatchResponse(req.body.className, req.params.className); @@ -51,6 +54,9 @@ function createSchema(req) { } function modifySchema(req) { + if (req.auth.isReadOnly) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to update a schema.'); + } if (req.body.className && req.body.className != req.params.className) { return classNameMismatchResponse(req.body.className, req.params.className); } @@ -64,6 +70,9 @@ function modifySchema(req) { } const deleteSchema = req => { + if (req.auth.isReadOnly) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'read-only masterKey isn\'t allowed to delete a schema.'); + } if (!SchemaController.classNameIsValid(req.params.className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, SchemaController.invalidClassNameMessage(req.params.className)); } diff --git a/src/middlewares.js b/src/middlewares.js index d415c08013..0606ab8f38 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -126,6 +126,13 @@ export function handleParseHeaders(req, res, next) { return; } + var isReadOnlyMaster = (info.masterKey === req.config.readOnlyMasterKey); + if (typeof req.config.readOnlyMasterKey != 'undefined' && req.config.readOnlyMasterKey && isReadOnlyMaster) { + req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true, isReadOnly: true }); + next(); + return; + } + // Client keys are not required in parse-server, but if any have been configured in the server, validate them // to preserve original behavior. const keys = ["clientKey", "javascriptKey", "dotNetKey", "restAPIKey"]; diff --git a/src/rest.js b/src/rest.js index c71a9a3dfd..b428f43cb8 100644 --- a/src/rest.js +++ b/src/rest.js @@ -159,6 +159,12 @@ function enforceRoleSecurity(method, className, auth) { const error = `Clients aren't allowed to perform the ${method} operation on the ${className} collection.` throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); } + + // readOnly masterKey is not allowed + if (auth.isReadOnly && (method === 'delete' || method === 'create' || method === 'update')) { + const error = `read-only masterKey isn't allowed to perform the ${method} operation.` + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, error); + } } module.exports = {