diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 2ff8467731..cd5aea7dfb 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -298,10 +298,62 @@ describe('middlewares', () => { headers[key] = value; }, }; - middlewares.allowCrossDomain({}, res, () => {}); + const allowCrossDomain = middlewares.allowCrossDomain( + fakeReq.body._ApplicationId + ); + allowCrossDomain(fakeReq, res, () => {}); expect(Object.keys(headers).length).toBe(4); expect(headers['Access-Control-Expose-Headers']).toBe( 'X-Parse-Job-Status-Id, X-Parse-Push-Status-Id' ); }); + + it('should set default Access-Control-Allow-Headers if allowHeaders are empty', () => { + AppCache.put(fakeReq.body._ApplicationId, { + allowHeaders: undefined, + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain( + fakeReq.body._ApplicationId + ); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain( + middlewares.DEFAULT_ALLOWED_HEADERS + ); + + AppCache.put(fakeReq.body._ApplicationId, { + allowHeaders: [], + }); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain( + middlewares.DEFAULT_ALLOWED_HEADERS + ); + }); + + it('should append custom headers to Access-Control-Allow-Headers if allowHeaders provided', () => { + AppCache.put(fakeReq.body._ApplicationId, { + allowHeaders: ['Header-1', 'Header-2'], + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain( + fakeReq.body._ApplicationId + ); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain( + 'Header-1, Header-2' + ); + expect(headers['Access-Control-Allow-Headers']).toContain( + middlewares.DEFAULT_ALLOWED_HEADERS + ); + }); }); diff --git a/src/Config.js b/src/Config.js index 9eedddd332..8d31d3d9ea 100644 --- a/src/Config.js +++ b/src/Config.js @@ -73,6 +73,7 @@ export class Config { masterKeyIps, masterKey, readOnlyMasterKey, + allowHeaders, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -110,6 +111,8 @@ export class Config { this.validateMasterKeyIps(masterKeyIps); this.validateMaxLimit(maxLimit); + + this.validateAllowHeaders(allowHeaders); } static validateAccountLockoutPolicy(accountLockout) { @@ -254,6 +257,22 @@ export class Config { } } + static validateAllowHeaders(allowHeaders) { + if (![null, undefined].includes(allowHeaders)) { + if (Array.isArray(allowHeaders)) { + allowHeaders.forEach(header => { + if (typeof header !== 'string') { + throw 'Allow headers must only contain strings'; + } else if (!header.trim().length) { + throw 'Allow headers must not contain empty strings'; + } + }); + } else { + throw 'Allow headers must be an array'; + } + } + } + generateEmailVerifyTokenExpiresAt() { if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) { return undefined; @@ -328,9 +347,7 @@ export class Config { } get requestResetPasswordURL() { - return `${this.publicServerURL}/apps/${ - this.applicationId - }/request_password_reset`; + return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index dea72183cd..35aef3f6bb 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -17,6 +17,11 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: true, }, + allowHeaders: { + env: 'PARSE_SERVER_ALLOW_HEADERS', + help: 'Add headers to Access-Control-Allow-Headers', + action: parsers.arrayParser, + }, analyticsAdapter: { env: 'PARSE_SERVER_ANALYTICS_ADAPTER', help: 'Adapter module for the analytics', diff --git a/src/Options/docs.js b/src/Options/docs.js index b5e1304267..8c73f16d42 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -2,6 +2,7 @@ * @interface ParseServerOptions * @property {Any} accountLockout account lockout policy for failed login attempts * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to true + * @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers * @property {Adapter} analyticsAdapter Adapter module for the analytics * @property {String} appId Your Parse Application ID * @property {String} appName Sets the app name diff --git a/src/Options/index.js b/src/Options/index.js index b3ebc2f807..edbe3204e9 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -26,6 +26,8 @@ export interface ParseServerOptions { masterKeyIps: ?(string[]); /* Sets the app name */ appName: ?string; + /* Add headers to Access-Control-Allow-Headers */ + allowHeaders: ?(string[]); /* Adapter module for the analytics */ analyticsAdapter: ?Adapter; /* Adapter module for the files sub-system */ diff --git a/src/ParseServer.js b/src/ParseServer.js index 542e46ab36..4adc8b4a49 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -145,7 +145,7 @@ class ParseServer { // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); //api.use("/apps", express.static(__dirname + "/public")); - api.use(middlewares.allowCrossDomain); + api.use(middlewares.allowCrossDomain(appId)); // File handling needs to be before default middlewares are applied api.use( '/', diff --git a/src/middlewares.js b/src/middlewares.js index 476bcdd449..5374f3e899 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -5,6 +5,15 @@ import Config from './Config'; import ClientSDK from './ClientSDK'; import defaultLogger from './logger'; +export const DEFAULT_ALLOWED_HEADERS = + 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, Content-Type, Pragma, Cache-Control'; + +const getMountForRequest = function(req) { + const mountPathLength = req.originalUrl.length - req.url.length; + const mountPath = req.originalUrl.slice(0, mountPathLength); + return req.protocol + '://' + req.get('host') + mountPath; +}; + // Checks that the request is authorized for this app and checks user // auth too. // The bodyparser should run before this middleware. @@ -12,9 +21,7 @@ import defaultLogger from './logger'; // req.config - the Config for this app // req.auth - the Auth for this request export function handleParseHeaders(req, res, next) { - var mountPathLength = req.originalUrl.length - req.url.length; - var mountPath = req.originalUrl.slice(0, mountPathLength); - var mount = req.protocol + '://' + req.get('host') + mountPath; + var mount = getMountForRequest(req); var info = { appId: req.get('X-Parse-Application-Id'), @@ -279,23 +286,27 @@ function decodeBase64(str) { return Buffer.from(str, 'base64').toString(); } -export function allowCrossDomain(req, res, next) { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); - res.header( - 'Access-Control-Allow-Headers', - 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, Content-Type, Pragma, Cache-Control' - ); - res.header( - 'Access-Control-Expose-Headers', - 'X-Parse-Job-Status-Id, X-Parse-Push-Status-Id' - ); - // intercept OPTIONS method - if ('OPTIONS' == req.method) { - res.sendStatus(200); - } else { - next(); - } +export function allowCrossDomain(appId) { + return (req, res, next) => { + const config = Config.get(appId, getMountForRequest(req)); + let allowHeaders = DEFAULT_ALLOWED_HEADERS; + if (config && config.allowHeaders) { + allowHeaders += `, ${config.allowHeaders.join(', ')}`; + } + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); + res.header('Access-Control-Allow-Headers', allowHeaders); + res.header( + 'Access-Control-Expose-Headers', + 'X-Parse-Job-Status-Id, X-Parse-Push-Status-Id' + ); + // intercept OPTIONS method + if ('OPTIONS' == req.method) { + res.sendStatus(200); + } else { + next(); + } + }; } export function allowMethodOverride(req, res, next) {