feat: add allowHeaders to Options (#6044)

* feat: add allowHeaders to Options

This allows developers to use custom headers in their API requests, and they will be accepted by their mounted app.

* refactor: convert allowCrossDomain to generator to add appId in scope

This is necessary as the middleware may run in OPTIONS request that do not contain the appId within the header.

* chore: update Definitions and docs

* fix: update test to use new allowCrossDomain params

* chore: add tests for allowCustomDomain middleware re: allowHeadrs
This commit is contained in:
Omair Vaiyani
2019-09-12 22:03:57 +01:00
committed by Antonio Davi Macedo Coelho de Castro
parent 1361bb3020
commit eef530b1e3
7 changed files with 113 additions and 25 deletions

View File

@@ -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
);
});
});

View File

@@ -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() {

View File

@@ -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',

View File

@@ -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>} analyticsAdapter Adapter module for the analytics
* @property {String} appId Your Parse Application ID
* @property {String} appName Sets the app name

View File

@@ -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<AnalyticsAdapter>;
/* Adapter module for the files sub-system */

View File

@@ -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(
'/',

View File

@@ -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) {