diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js
new file mode 100644
index 0000000000..42c1cfdfce
--- /dev/null
+++ b/spec/HeaderAliasesValidation.spec.js
@@ -0,0 +1,62 @@
+'use strict';
+
+const Config = require('../lib/Config');
+
+describe('Config.validateHeaderAliases', () => {
+ it('should accept null and undefined', () => {
+ expect(() => Config.validateHeaderAliases(null)).not.toThrow();
+ expect(() => Config.validateHeaderAliases(undefined)).not.toThrow();
+ });
+
+ it('should accept a valid headerAliases object', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['X-App-Id'],
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ })
+ ).not.toThrow();
+ });
+
+ it('should reject an alias that normalizes to the same value as its canonical header', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Foo': ['x-foo'],
+ })
+ ).toThrowError(/must not normalize to the same value as the canonical header name/);
+ });
+
+ it('should reject duplicate normalized aliases within the same aliases array', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-A': ['foo', 'FOO'],
+ })
+ ).toThrowError(/Duplicate normalized header alias/);
+ });
+
+ it('should reject two canonical keys that normalize to the same value', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Foo': [],
+ 'x-foo': [],
+ })
+ ).toThrowError(/collides with.*after trim and lowercasing/);
+ });
+
+ it('should reject the same normalized alias used for two different canonical headers', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-A': ['shared-alias'],
+ 'X-B': ['Shared-Alias'],
+ })
+ ).toThrowError(/collides with alias/);
+ });
+
+ it('should reject an alias that normalizes to another canonical header key', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Bar': [],
+ 'X-Parse-Foo': ['x-parse-bar'],
+ })
+ ).toThrowError(/collides with canonical header/);
+ });
+});
diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js
index d05a56970b..004f3e10f9 100644
--- a/spec/Middlewares.spec.js
+++ b/spec/Middlewares.spec.js
@@ -339,6 +339,25 @@ describe('middlewares', () => {
expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS);
});
+ it('should append configured header aliases to Access-Control-Allow-Headers', () => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-App-Id'],
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ },
+ });
+ 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('X-App-Id');
+ expect(headers['Access-Control-Allow-Headers']).toContain('X-Session-Token-Alias');
+ });
+
it('should set default Access-Control-Allow-Origin if allowOrigin is empty', () => {
AppCachePut(fakeReq.body._ApplicationId, {
allowOrigin: undefined,
@@ -409,6 +428,98 @@ describe('middlewares', () => {
});
});
+ it('should resolve app id from configured header alias', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-App-Id'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-app-id'] = fakeReq.body._ApplicationId;
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-application-id']).toEqual(fakeReq.body._ApplicationId);
+ middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
+ expect(fakeReq.info.appId).toEqual(fakeReq.body._ApplicationId);
+ done();
+ });
+ });
+ });
+
+ it('should resolve session token from configured header alias', done => {
+ const sessionToken = 'session-token-via-alias';
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-session-token-alias'] = sessionToken;
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
+ expect(fakeReq.info.sessionToken).toEqual(sessionToken);
+ done();
+ });
+ });
+ });
+
+ it('should prefer canonical session token over alias when both headers are present', done => {
+ const canonicalToken = 'session-token-canonical';
+ const aliasToken = 'session-token-alias-value';
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-parse-session-token'] = canonicalToken;
+ fakeReq.headers['x-session-token-alias'] = aliasToken;
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
+ expect(fakeReq.info.sessionToken).toEqual(canonicalToken);
+ done();
+ });
+ });
+ });
+
+ it('should resolve master key from configured alias in handleParseAuth', async () => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Master-Key': ['X-Master-Key-Alias'],
+ },
+ masterKey: 'masterKey',
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-master-key-alias'] = 'masterKey';
+ await new Promise(resolve =>
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, resolve)
+ );
+ await new Promise(resolve =>
+ middlewares.handleParseAuth(fakeReq.body._ApplicationId)(fakeReq, fakeRes, resolve)
+ );
+ expect(fakeReq.auth.isMaster).toBe(true);
+ });
+
+ it('should call next without throwing when app is not in AppCache', () => {
+ const next = jasmine.createSpy('next');
+ middlewares.handleHeaderAliases('NotInCacheAppId')(fakeReq, fakeRes, next);
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('should call next without throwing when headerAliases is missing or null', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ masterKey: 'masterKey',
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ masterKey: 'masterKey',
+ masterKeyIps: ['0.0.0.0/0'],
+ headerAliases: null,
+ });
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, done);
+ });
+ });
+
it('should give invalid response when upload file without x-parse-application-id in header', () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js
index c0189433ef..049785efdb 100644
--- a/spec/ParseGraphQLServer.spec.js
+++ b/spec/ParseGraphQLServer.spec.js
@@ -28,7 +28,7 @@ const {
GraphQLList,
} = require('graphql');
const { ParseServer } = require('../');
-const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
+const { ParseGraphQLServer, getCSRFRequestHeaders } = require('../lib/GraphQL/ParseGraphQLServer');
const { ReadPreference, Collection } = require('mongodb');
let uuidv4;
@@ -105,6 +105,30 @@ describe('ParseGraphQLServer', () => {
});
});
+ describe('getCSRFRequestHeaders', () => {
+ it('should include safe application-id header aliases with canonical header', () => {
+ const headers = getCSRFRequestHeaders({
+ 'X-Parse-Application-Id': ['X-App-Id', 'X-Client-App'],
+ });
+ expect(headers).toEqual(['X-Parse-Application-Id', 'X-App-Id', 'X-Client-App']);
+ });
+
+ it('should exclude CORS-safelisted request-header names and Range from CSRF whitelist', () => {
+ const headers = getCSRFRequestHeaders({
+ 'X-Parse-Application-Id': [
+ 'Accept',
+ 'accept-language',
+ 'Content-Language',
+ 'Content-Type',
+ 'Range',
+ 'rAnGe',
+ 'X-Safe-Custom',
+ ],
+ });
+ expect(headers).toEqual(['X-Parse-Application-Id', 'X-Safe-Custom']);
+ });
+ });
+
describe('_getServer', () => {
it('should only return new server on schema changes', async () => {
parseGraphQLServer.server = undefined;
@@ -135,6 +159,7 @@ describe('ParseGraphQLServer', () => {
expect(server).toBe(firstServer);
});
});
+
});
describe('_getGraphQLOptions', () => {
@@ -201,6 +226,46 @@ describe('ParseGraphQLServer', () => {
).not.toThrow();
expect(useCount).toBeGreaterThan(0);
});
+
+ it('registers header alias normalization before parse header handling', async () => {
+ const parseServerWithAliases = await global.reconfigureServer({
+ maintenanceKey: 'test2',
+ maxUploadSize: '1kb',
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-App-Id'],
+ },
+ });
+ const graphQLServerWithAliases = new ParseGraphQLServer(parseServerWithAliases, {
+ graphQLPath: '/graphql',
+ playgroundPath: '/playground',
+ });
+ const middlewares = require('../lib/middlewares');
+ const useCalls = [];
+ const app = {
+ use: (...args) => {
+ useCalls.push(args);
+ },
+ };
+ graphQLServerWithAliases.applyGraphQL(app);
+ const parseHeadersIndex = useCalls.findIndex(
+ ([path, middleware]) => path === '/graphql' && middleware === middlewares.handleParseHeaders
+ );
+ expect(parseHeadersIndex).toBeGreaterThan(0);
+ const [path, aliasMiddleware] = useCalls[parseHeadersIndex - 1];
+ expect(path).toBe('/graphql');
+ const req = {
+ originalUrl: '/graphql',
+ url: '/graphql',
+ protocol: 'http',
+ headers: {
+ host: 'localhost',
+ 'x-app-id': parseServerWithAliases.config.appId,
+ },
+ get: key => req.headers[key.toLowerCase()],
+ };
+ await new Promise(resolve => aliasMiddleware(req, {}, resolve));
+ expect(req.headers['x-parse-application-id']).toBe(parseServerWithAliases.config.appId);
+ });
});
describe('applyPlayground', () => {
@@ -8325,6 +8390,37 @@ describe('ParseGraphQLServer', () => {
});
describe('Session Token', () => {
+ it('should retrieve me with session token header alias', async () => {
+ parseServer = await global.reconfigureServer({
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ },
+ });
+ await createGQLFromParseServer(parseServer);
+ const username = `alias-gql-${uuidv4()}`;
+ const user = new Parse.User();
+ user.setUsername(username);
+ user.setPassword('password');
+ await user.signUp();
+ const result = await apolloClient.query({
+ query: gql`
+ query GetCurrentUser {
+ viewer {
+ user {
+ username
+ }
+ }
+ }
+ `,
+ context: {
+ headers: {
+ 'X-Session-Token-Alias': user.getSessionToken(),
+ },
+ },
+ });
+ expect(result.data.viewer.user.username).toBe(username);
+ });
+
it('should fail due to invalid session token', async () => {
try {
await apolloClient.query({
diff --git a/spec/rest.spec.js b/spec/rest.spec.js
index 9416d9230e..6372cc3742 100644
--- a/spec/rest.spec.js
+++ b/spec/rest.spec.js
@@ -1738,6 +1738,55 @@ describe('read-only masterKey', () => {
});
});
+describe('rest header aliases', () => {
+ it('supports REST requests with application-id header alias only', async () => {
+ await reconfigureServer({
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-App-Id'],
+ },
+ });
+ try {
+ const response = await request({
+ url: `${Parse.serverURL}/schemas`,
+ method: 'GET',
+ headers: {
+ 'X-App-Id': Parse.applicationId,
+ 'X-Parse-Master-Key': Parse.masterKey,
+ },
+ });
+ expect(response.data.results).toBeDefined();
+ expect(Array.isArray(response.data.results)).toBe(true);
+ } finally {
+ await reconfigureServer();
+ }
+ });
+
+ it('supports /users/me with session-token header alias', async () => {
+ await reconfigureServer({
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ },
+ });
+ try {
+ const username = `alias-rest-${Date.now()}`;
+ const user = await Parse.User.signUp(username, 'password');
+ const response = await request({
+ url: `${Parse.serverURL}/users/me`,
+ method: 'GET',
+ headers: {
+ 'X-Parse-Application-Id': Parse.applicationId,
+ 'X-Parse-REST-API-Key': 'rest',
+ 'X-Session-Token-Alias': user.getSessionToken(),
+ },
+ });
+ expect(response.data.objectId).toBe(user.id);
+ expect(response.data.username).toBe(username);
+ } finally {
+ await reconfigureServer();
+ }
+ });
+});
+
describe('rest context', () => {
it('should support dependency injection on rest api', async () => {
const requestContextMiddleware = (req, res, next) => {
diff --git a/src/Config.js b/src/Config.js
index c5a6a6593e..f12cb362c1 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -129,6 +129,7 @@ export class Config {
readOnlyMasterKey,
readOnlyMasterKeyIps,
allowHeaders,
+ headerAliases,
idempotencyOptions,
fileUpload,
fileDownload,
@@ -181,6 +182,7 @@ export class Config {
this.validateDefaultLimit(defaultLimit);
this.validateMaxLimit(maxLimit);
this.validateAllowHeaders(allowHeaders);
+ this.validateHeaderAliases(headerAliases);
this.validateIdempotencyOptions(idempotencyOptions);
this.validatePagesOptions(pages);
this.validateSecurityOptions(security);
@@ -722,6 +724,83 @@ export class Config {
}
}
+ static validateHeaderAliases(headerAliases) {
+ if (![null, undefined].includes(headerAliases)) {
+ if (Object.prototype.toString.call(headerAliases) !== '[object Object]') {
+ throw 'Header aliases must be an object';
+ }
+ const entries = Object.entries(headerAliases);
+ for (const [canonicalHeader, aliases] of entries) {
+ if (typeof canonicalHeader !== 'string' || !canonicalHeader.trim().length) {
+ throw 'Header aliases must contain non-empty string keys';
+ }
+ if (!Array.isArray(aliases)) {
+ throw `Header aliases for '${canonicalHeader}' must be an array`;
+ }
+ aliases.forEach(alias => {
+ if (typeof alias !== 'string') {
+ throw `Header aliases for '${canonicalHeader}' must only contain strings`;
+ } else if (!alias.trim().length) {
+ throw `Header aliases for '${canonicalHeader}' must not contain empty strings`;
+ }
+ });
+ }
+
+ const normalizeHeaderAliasIdentifier = s => s.trim().toLowerCase();
+
+ const canonicalNormToKey = new Map();
+ for (const [canonicalHeader] of entries) {
+ const norm = normalizeHeaderAliasIdentifier(canonicalHeader);
+ if (canonicalNormToKey.has(norm)) {
+ throw new Error(
+ `Header aliases canonical '${canonicalHeader}' collides with '${canonicalNormToKey.get(
+ norm
+ )}' after trim and lowercasing.`
+ );
+ }
+ canonicalNormToKey.set(norm, canonicalHeader);
+ }
+
+ const globalAliasNorm = new Map();
+
+ for (const [canonicalHeader, aliases] of entries) {
+ const normCanon = normalizeHeaderAliasIdentifier(canonicalHeader);
+ const seenInArray = new Set();
+
+ for (const alias of aliases) {
+ const normAlias = normalizeHeaderAliasIdentifier(alias);
+
+ if (normAlias === normCanon) {
+ throw new Error(
+ `Header alias '${alias}' for canonical header '${canonicalHeader}' must not normalize to the same value as the canonical header name.`
+ );
+ }
+ if (canonicalNormToKey.has(normAlias) && normAlias !== normCanon) {
+ throw new Error(
+ `Header alias '${alias}' for canonical header '${canonicalHeader}' collides with canonical header '${canonicalNormToKey.get(
+ normAlias
+ )}'.`
+ );
+ }
+ if (seenInArray.has(normAlias)) {
+ throw new Error(
+ `Duplicate normalized header alias '${alias}' for canonical header '${canonicalHeader}'.`
+ );
+ }
+ seenInArray.add(normAlias);
+
+ if (globalAliasNorm.has(normAlias)) {
+ const prev = globalAliasNorm.get(normAlias);
+ throw new Error(
+ `Header alias '${alias}' for canonical header '${canonicalHeader}' collides with alias '${prev.alias}' for canonical header '${prev.canonicalHeader}'.`
+ );
+ }
+ globalAliasNorm.set(normAlias, { canonicalHeader, alias });
+ }
+ }
+ }
+ }
+
static validateLogLevels(logLevels) {
for (const key of Object.keys(LogLevels)) {
if (logLevels[key]) {
diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js
index 0b2c17d232..df6cdfcf43 100644
--- a/src/GraphQL/ParseGraphQLServer.js
+++ b/src/GraphQL/ParseGraphQLServer.js
@@ -4,7 +4,14 @@ import { expressMiddleware } from '@as-integrations/express5';
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
import express from 'express';
import { GraphQLError, parse } from 'graphql';
-import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
+import {
+ allowCrossDomain,
+ getHeaderAliases,
+ handleHeaderAliases,
+ handleParseErrors,
+ handleParseHeaders,
+ handleParseSession,
+} from '../middlewares';
import requiredParameter from '../requiredParameter';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
@@ -90,6 +97,27 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
});
+// Fetch no-CORS-safelisted request-header names (case-insensitive) plus Range, which
+// can also be CORS-safelisted for certain values. Apollo preventCsrf treats any
+// whitelisted header with a non-empty value as sufficient for multipart/simple
+// bodies; aliases that match these names must not be listed or browsers could
+// satisfy CSRF with ambient headers.
+const APOLLO_CSRF_ALIAS_BLOCKLIST = new Set([
+ 'accept',
+ 'accept-language',
+ 'content-language',
+ 'content-type',
+ 'range',
+]);
+
+export const getCSRFRequestHeaders = headerAliases => {
+ const aliases = getHeaderAliases(headerAliases, 'X-Parse-Application-Id');
+ const safeAliases = aliases.filter(
+ alias => !APOLLO_CSRF_ALIAS_BLOCKLIST.has(alias.trim().toLowerCase())
+ );
+ return [...new Set(['X-Parse-Application-Id', ...safeAliases])];
+};
+
class ParseGraphQLServer {
parseGraphQLController: ParseGraphQLController;
@@ -144,11 +172,13 @@ class ParseGraphQLServer {
const createServer = async () => {
try {
const { schema, context } = await this._getGraphQLOptions();
+ const csrfRequestHeaders = getCSRFRequestHeaders(this.parseServer.config.headerAliases);
const apollo = new ApolloServer({
csrfPrevention: {
// See https://www.apollographql.com/docs/router/configuration/csrf/
- // needed since we use graphql upload
- requestHeaders: ['X-Parse-Application-Id'],
+ // needed since we use graphql upload. handleHeaderAliases runs on this path
+ // before Apollo; getCSRFRequestHeaders lists canonical + safe aliases only.
+ requestHeaders: csrfRequestHeaders,
},
// We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable
// we delegate the introspection control to the IntrospectionControlPlugin
@@ -203,6 +233,7 @@ class ParseGraphQLServer {
requiredParameter('You must provide an Express.js app instance!');
}
app.use(this.config.graphQLPath, allowCrossDomain(this.parseServer.config.appId));
+ app.use(this.config.graphQLPath, handleHeaderAliases(this.parseServer.config.appId));
app.use(this.config.graphQLPath, handleParseHeaders);
app.use(this.config.graphQLPath, handleParseSession);
this.applyRequestContextMiddleware(app, this.parseServer.config);
diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js
index 8c4f7e4a89..33b4ee79a6 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -310,6 +310,11 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_GRAPH_QLSCHEMA',
help: 'Full path to your GraphQL custom schema.graphql file',
},
+ headerAliases: {
+ env: 'PARSE_SERVER_HEADER_ALIASES',
+ help: '(Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.
Example:
`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`
When setting this option via an environment variable, provide a JSON object string.',
+ action: parsers.objectParser,
+ },
host: {
env: 'PARSE_SERVER_HOST',
help: 'The host to serve ParseServer on, defaults to 0.0.0.0',
diff --git a/src/Options/docs.js b/src/Options/docs.js
index 09f9ef1852..ab0cdae8e1 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -59,6 +59,7 @@
* @property {String} graphQLPath The mount path for the GraphQL endpoint
⚠️ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.
Defaults is `/graphql`.
* @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false
* @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file
+ * @property {Object} headerAliases (Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.
Example:
`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`
When setting this option via an environment variable, provide a JSON object string.
* @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0
* @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production.
* @property {String} javascriptKey Key for the Javascript SDK
diff --git a/src/Options/index.js b/src/Options/index.js
index e495b6fe79..13c9235503 100644
--- a/src/Options/index.js
+++ b/src/Options/index.js
@@ -91,6 +91,9 @@ export interface ParseServerOptions {
appName: ?string;
/* Add headers to Access-Control-Allow-Headers */
allowHeaders: ?(string[]);
+ /* (Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.
Example:
`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`
When setting this option via an environment variable, provide a JSON object string.
+ :ENV: PARSE_SERVER_HEADER_ALIASES */
+ headerAliases: ?{ [string]: string[] };
/* Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. */
allowOrigin: ?StringOrStringArray;
/* Adapter module for the analytics */
diff --git a/src/ParseServer.ts b/src/ParseServer.ts
index 65d537ae68..128093863b 100644
--- a/src/ParseServer.ts
+++ b/src/ParseServer.ts
@@ -311,6 +311,7 @@ class ParseServer {
//api.use("/apps", express.static(__dirname + "/public"));
api.use(middlewares.allowCrossDomain(appId));
api.use(middlewares.allowDoubleForwardSlash);
+ api.use(middlewares.handleHeaderAliases(appId));
api.use(middlewares.handleParseAuth(appId));
// File handling needs to be before the default JSON body parser because file
// uploads send binary data that should not be parsed as JSON.
diff --git a/src/defaults.js b/src/defaults.js
index b7d05f1550..b042cae53e 100644
--- a/src/defaults.js
+++ b/src/defaults.js
@@ -25,6 +25,7 @@ const DefinitionDefaults = Object.keys(ParseServerOptions).reduce((memo, key) =>
}, {});
const computedDefaults = {
+ headerAliases: {},
jsonLogs: process.env.JSON_LOGS || false,
logsFolder,
verbose,
diff --git a/src/middlewares.js b/src/middlewares.js
index 3c55278f33..355d0c60c0 100644
--- a/src/middlewares.js
+++ b/src/middlewares.js
@@ -65,6 +65,59 @@ export const checkIp = (ip, ipRangeList, store) => {
return result;
};
+// Build a clean list of headers
+const getHeaderList = headers =>
+ headers
+ .split(',')
+ .map(header => header.trim())
+ .filter(Boolean);
+
+// Merge all headers into a single list
+const mergeHeaders = (...headerSources) => {
+ const reduced = headerSources.reduce((acc, source) => {
+ const headers = Array.isArray(source) ? source : getHeaderList(source || '');
+ const trimmedHeaders = headers.map(header => header.trim());
+ acc.push(...trimmedHeaders);
+ return acc;
+ }, []).filter(header => Boolean(header));
+ return [...new Set(reduced)];
+};
+
+export function getHeaderAliases(headerAliases, canonicalHeader) {
+ const aliases = headerAliases[canonicalHeader];
+ if (!Array.isArray(aliases)) {
+ return [];
+ }
+ // Clean up the aliases and remove any empty strings
+ return aliases.map(alias => alias.trim()).filter(Boolean);
+}
+
+function applyHeaderAliases(req, headerAliases) {
+ req.headers = req.headers || {};
+ const indexHeaderByAlias = Object.fromEntries(
+ Object.entries(headerAliases)
+ .map(([source, aliases]) =>
+ aliases
+ .map(alias => [alias.toLowerCase(), source.toLowerCase()])
+ )
+ .flat()
+ );
+ Object.entries(req.headers).forEach(([header, value]) => {
+ const targetHeader = indexHeaderByAlias[header.toLowerCase()];
+ if (targetHeader && !req.headers[targetHeader]) {
+ req.headers[targetHeader] = value;
+ }
+ });
+}
+
+export function handleHeaderAliases(appId) {
+ return (req, res, next) => {
+ const config = Config.get(appId, getMountForRequest(req));
+ applyHeaderAliases(req, config?.headerAliases || {});
+ next();
+ };
+}
+
// Checks that the request is authorized for this app and checks user
// auth too.
// The bodyparser should run before this middleware.
@@ -411,10 +464,11 @@ function decodeBase64(str) {
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(', ')}`;
- }
+ const allowHeaders = mergeHeaders(
+ DEFAULT_ALLOWED_HEADERS,
+ mergeHeaders(...Object.values(config?.headerAliases || {})),
+ config?.allowHeaders
+ ).join(', ');
const baseOrigins =
typeof config?.allowOrigin === 'string' ? [config.allowOrigin] : config?.allowOrigin ?? ['*'];
diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts
index 6a8b1494ac..41a3448836 100644
--- a/types/Options/index.d.ts
+++ b/types/Options/index.d.ts
@@ -52,6 +52,7 @@ export interface ParseServerOptions {
maintenanceKeyIps?: (string[]);
appName?: string;
allowHeaders?: (string[]);
+ headerAliases?: { [headerName: string]: string[] };
allowOrigin?: StringOrStringArray;
analyticsAdapter?: Adapter;
filesAdapter?: Adapter;