Skip to content

Commit

Permalink
feat: Add dynamic master key by setting Parse Server option `masterKe…
Browse files Browse the repository at this point in the history
…y` to a function (#9582)
  • Loading branch information
dblythy authored Feb 12, 2025
1 parent 4153737 commit 6f1d161
Show file tree
Hide file tree
Showing 8 changed files with 102 additions and 25 deletions.
28 changes: 8 additions & 20 deletions spec/Middlewares.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,32 +46,32 @@ describe('middlewares', () => {
});
});

it('should give invalid response when keys are configured but no key supplied', () => {
it('should give invalid response when keys are configured but no key supplied', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
restAPIKey: 'restAPIKey',
});
middlewares.handleParseHeaders(fakeReq, fakeRes);
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});

it('should give invalid response when keys are configured but supplied key is incorrect', () => {
it('should give invalid response when keys are configured but supplied key is incorrect', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
restAPIKey: 'restAPIKey',
});
fakeReq.headers['x-parse-rest-api-key'] = 'wrongKey';
middlewares.handleParseHeaders(fakeReq, fakeRes);
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});

it('should give invalid response when keys are configured but different key is supplied', () => {
it('should give invalid response when keys are configured but different key is supplied', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
restAPIKey: 'restAPIKey',
});
fakeReq.headers['x-parse-client-key'] = 'clientKey';
middlewares.handleParseHeaders(fakeReq, fakeRes);
await middlewares.handleParseHeaders(fakeReq, fakeRes);
expect(fakeRes.status).toHaveBeenCalledWith(403);
});

Expand Down Expand Up @@ -157,13 +157,7 @@ describe('middlewares', () => {
fakeReq.ip = '127.0.0.1';
fakeReq.headers['x-parse-master-key'] = 'masterKey';

let error;

try {
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
} catch (err) {
error = err;
}
const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);

expect(error).toBeDefined();
expect(error.message).toEqual(`unauthorized`);
Expand All @@ -182,13 +176,7 @@ describe('middlewares', () => {
fakeReq.ip = '10.0.0.2';
fakeReq.headers['x-parse-maintenance-key'] = 'masterKey';

let error;

try {
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
} catch (err) {
error = err;
}
const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);

expect(error).toBeDefined();
expect(error.message).toEqual(`unauthorized`);
Expand Down
57 changes: 57 additions & 0 deletions spec/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,63 @@ describe('server', () => {
await new Promise(resolve => server.close(resolve));
});

it('should load masterKey', async () => {
await reconfigureServer({
masterKey: () => 'testMasterKey',
masterKeyTtl: 1000, // TTL is set
});

await new Parse.Object('TestObject').save();

const config = Config.get(Parse.applicationId);
expect(config.masterKeyCache.masterKey).toEqual('testMasterKey');
expect(config.masterKeyCache.expiresAt.getTime()).toBeGreaterThan(Date.now());
});

it('should not reload if ttl is not set', async () => {
const masterKeySpy = jasmine.createSpy().and.returnValue(Promise.resolve('initialMasterKey'));

await reconfigureServer({
masterKey: masterKeySpy,
masterKeyTtl: null, // No TTL set
});

await new Parse.Object('TestObject').save();

const config = Config.get(Parse.applicationId);
const firstMasterKey = config.masterKeyCache.masterKey;

// Simulate calling the method again
await config.loadMasterKey();
const secondMasterKey = config.masterKeyCache.masterKey;

expect(firstMasterKey).toEqual('initialMasterKey');
expect(secondMasterKey).toEqual('initialMasterKey');
expect(masterKeySpy).toHaveBeenCalledTimes(1); // Should only be called once
expect(config.masterKeyCache.expiresAt).toBeNull(); // TTL is not set, so expiresAt should remain null
});

it('should reload masterKey if ttl is set and expired', async () => {
const masterKeySpy = jasmine.createSpy()
.and.returnValues(Promise.resolve('firstMasterKey'), Promise.resolve('secondMasterKey'));

await reconfigureServer({
masterKey: masterKeySpy,
masterKeyTtl: 1 / 1000, // TTL is set to 1ms
});

await new Parse.Object('TestObject').save();

await new Promise(resolve => setTimeout(resolve, 10));

await new Parse.Object('TestObject').save();

const config = Config.get(Parse.applicationId);
expect(masterKeySpy).toHaveBeenCalledTimes(2);
expect(config.masterKeyCache.masterKey).toEqual('secondMasterKey');
});


it('should not fail when Google signin is introduced without the optional clientId', done => {
const jwt = require('jsonwebtoken');
const authUtils = require('../lib/Adapters/Auth/utils');
Expand Down
22 changes: 22 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,28 @@ export class Config {
return `${this.publicServerURL}/${this.pagesEndpoint}/${this.applicationId}/verify_email`;
}

async loadMasterKey() {
if (typeof this.masterKey === 'function') {
const ttlIsEmpty = !this.masterKeyTtl;
const isExpired = this.masterKeyCache?.expiresAt && this.masterKeyCache.expiresAt < new Date();

if ((!isExpired || ttlIsEmpty) && this.masterKeyCache?.masterKey) {
return this.masterKeyCache.masterKey;
}

const masterKey = await this.masterKey();

const expiresAt = this.masterKeyTtl ? new Date(Date.now() + 1000 * this.masterKeyTtl) : null
this.masterKeyCache = { masterKey, expiresAt };
Config.put(this);

return this.masterKeyCache.masterKey;
}

return this.masterKey;
}


// TODO: Remove this function once PagesRouter replaces the PublicAPIRouter;
// the (default) endpoint has to be defined in PagesRouter only.
get pagesEndpoint() {
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,12 @@ module.exports.ParseServerOptions = {
action: parsers.arrayParser,
default: ['127.0.0.1', '::1'],
},
masterKeyTtl: {
env: 'PARSE_SERVER_MASTER_KEY_TTL',
help:
'(Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server.',
action: parsers.numberParser('masterKeyTtl'),
},
maxLimit: {
env: 'PARSE_SERVER_MAX_LIMIT',
help: 'Max value for limit option on queries, defaults to unlimited',
Expand Down
3 changes: 2 additions & 1 deletion src/Options/docs.js

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

4 changes: 3 additions & 1 deletion src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_APPLICATION_ID */
appId: string;
/* Your Parse Master Key */
masterKey: string;
masterKey: (() => void) | string;
/* (Optional) The duration in seconds for which the current `masterKey` is being used before it is requested again if `masterKey` is set to a function. If `masterKey` is not set to a function, this option has no effect. Default is `0`, which means the master key is requested by invoking the `masterKey` function every time the master key is used internally by Parse Server. */
masterKeyTtl: ?number;
/* (Optional) The maintenance key is used for modifying internal and read-only fields of Parse Server.<br><br>⚠️ This key is not intended to be used as part of a regular operation of Parse Server. This key is intended to conduct out-of-band changes such as one-time migrations or data correction tasks. Internal fields are not officially documented and may change at any time without publication in release changelogs. We strongly advice not to rely on internal fields as part of your regular operation and to investigate the implications of any planned changes *directly in the source code* of your current version of Parse Server. */
maintenanceKey: string;
/* URL to your parse server with http:// or https://.
Expand Down
2 changes: 1 addition & 1 deletion src/ParseServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class ParseServer {
}
const pushController = await controllers.getPushController(this.config);
await hooksController.load();
const startupPromises = [];
const startupPromises = [this.config.loadMasterKey?.()];
if (schema) {
startupPromises.push(new DefinedSchemas(schema, this.config).execute());
}
Expand Down
5 changes: 3 additions & 2 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const checkIp = (ip, ipRangeList, store) => {
// Adds info to the request:
// req.config - the Config for this app
// req.auth - the Auth for this request
export function handleParseHeaders(req, res, next) {
export async function handleParseHeaders(req, res, next) {
var mount = getMountForRequest(req);

let context = {};
Expand Down Expand Up @@ -238,7 +238,8 @@ export function handleParseHeaders(req, res, next) {
);
}

let isMaster = info.masterKey === req.config.masterKey;
const masterKey = await req.config.loadMasterKey();
let isMaster = info.masterKey === masterKey;

if (isMaster && !checkIp(clientIp, req.config.masterKeyIps || [], req.config.masterKeyIpsStore)) {
const log = req.config?.loggerController || defaultLogger;
Expand Down

0 comments on commit 6f1d161

Please sign in to comment.