diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js
new file mode 100644
index 0000000000..6c5091a9f6
--- /dev/null
+++ b/spec/HeaderAliasesValidation.spec.js
@@ -0,0 +1,211 @@
+'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 accept an empty aliases array for an allowlisted canonical header', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [],
+ })
+ ).not.toThrow();
+ });
+
+ it('should accept aliases with trimable surrounding whitespace', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [' X-App-Id '],
+ })
+ ).not.toThrow();
+ });
+
+ it('should accept differently cased allowlisted canonical headers', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'x-parse-application-id': ['X-App-Id'],
+ })
+ ).not.toThrow();
+ });
+
+ it('should reject a non-object headerAliases value', () => {
+ expect(() => Config.validateHeaderAliases([])).toThrow('Header aliases must be an object');
+ expect(() => Config.validateHeaderAliases('bad')).toThrow('Header aliases must be an object');
+ expect(() => Config.validateHeaderAliases(1)).toThrow('Header aliases must be an object');
+ });
+
+ it('should reject empty or whitespace-only canonical keys', () => {
+ expect(() => Config.validateHeaderAliases({ '': ['X-App-Id'] })).toThrow(
+ 'Header aliases must contain non-empty string keys'
+ );
+ expect(() => Config.validateHeaderAliases({ ' ': ['X-App-Id'] })).toThrow(
+ 'Header aliases must contain non-empty string keys'
+ );
+ });
+
+ it('should reject non-array aliases', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': 'X-App-Id',
+ })
+ ).toThrow("Header aliases for 'X-Parse-Application-Id' must be an array");
+ });
+
+ it('should reject non-string aliases', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [1],
+ })
+ ).toThrow("Header aliases for 'X-Parse-Application-Id' must only contain strings");
+ });
+
+ it('should reject empty string aliases', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [' '],
+ })
+ ).toThrow("Header aliases for 'X-Parse-Application-Id' must not contain empty strings");
+ });
+
+ it('should reject an alias that contains CRLF characters', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['X-Foo\r\nSet-Cookie: evil'],
+ })
+ ).toThrowError(/contains invalid characters/);
+ });
+
+ it('should reject an alias that contains a colon', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['X-Foo: bar'],
+ })
+ ).toThrowError(/contains invalid characters/);
+ });
+
+ it('should reject an alias that contains an internal space', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['X Foo'],
+ })
+ ).toThrowError(/contains invalid characters/);
+ });
+
+ it('should reject an alias that contains characters outside A-Za-z0-9-', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['X-App/Id'],
+ })
+ ).toThrowError(/contains invalid characters/);
+ });
+
+ it('should reject CORS-safelisted request-header names and Range as aliases', () => {
+ for (const alias of ['Accept', 'accept-language', 'Content-Language', 'Content-Type', 'Range']) {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [alias],
+ })
+ ).toThrowError(/CORS-safelisted request header/);
+ }
+ });
+
+ it('should reject a canonical header that contains invalid characters', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id:': ['X-App-Id'],
+ })
+ ).toThrowError(/contains invalid characters/);
+ });
+
+ it('should reject canonical headers that are not allowlisted Parse headers', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ Authorization: ['X-Auth-Alias'],
+ })
+ ).toThrowError(/is not an allowed Parse header/);
+ expect(() =>
+ Config.validateHeaderAliases({
+ Host: ['X-Host-Alias'],
+ })
+ ).toThrowError(/is not an allowed Parse header/);
+ expect(() =>
+ Config.validateHeaderAliases({
+ Cookie: ['X-Cookie-Alias'],
+ })
+ ).toThrowError(/is not an allowed Parse header/);
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Custom': ['X-Custom-Alias'],
+ })
+ ).toThrowError(/is not an allowed Parse header/);
+ });
+
+ it('should reject credential-bearing master and maintenance key aliases', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Master-Key': ['X-Master-Key-Alias'],
+ })
+ ).toThrowError(/is not an allowed Parse header/);
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Maintenance-Key': ['X-Maintenance-Key-Alias'],
+ })
+ ).toThrowError(/is not an allowed Parse header/);
+ });
+
+ it('should reject an alias that normalizes to the same value as its canonical header', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['x-parse-application-id'],
+ })
+ ).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-Parse-Application-Id': ['foo', 'FOO'],
+ })
+ ).toThrowError(/Duplicate normalized header alias/);
+ });
+
+ it('should reject two canonical keys that normalize to the same value', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [],
+ 'x-parse-application-id': [],
+ })
+ ).toThrowError(/collides with.*after trim and lowercasing/);
+ });
+
+ it('should reject the same normalized alias used for two different canonical headers', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['shared-alias'],
+ 'X-Parse-Session-Token': ['Shared-Alias'],
+ })
+ ).toThrowError(/collides with alias/);
+ });
+
+ it('should reject an alias that normalizes to another canonical header key', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Session-Token': [],
+ 'X-Parse-Application-Id': ['x-parse-session-token'],
+ })
+ ).toThrowError(/collides with canonical header/);
+ });
+});
diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js
index ff6fa003b5..688520d50a 100644
--- a/spec/Middlewares.spec.js
+++ b/spec/Middlewares.spec.js
@@ -338,6 +338,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,
@@ -408,6 +427,207 @@ 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 prefer canonical application id over alias when both headers are present', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-App-Id'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
+ fakeReq.headers['x-app-id'] = 'other-app-id';
+ 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 use the first matching alias according to config order', done => {
+ const firstAliasToken = 'session-token-alias-a';
+ const secondAliasToken = 'session-token-alias-b';
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Alias-A', 'X-Alias-B'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ // Insert B before A in req.headers so Object.entries order would prefer B
+ fakeReq.headers['x-alias-b'] = secondAliasToken;
+ fakeReq.headers['x-alias-a'] = firstAliasToken;
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
+ expect(fakeReq.info.sessionToken).toEqual(firstAliasToken);
+ done();
+ });
+ });
+ });
+
+ it('should use the second alias when the first configured alias is absent', done => {
+ const secondAliasToken = 'session-token-alias-b-only';
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Alias-A', 'X-Alias-B'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-alias-b'] = secondAliasToken;
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
+ expect(fakeReq.info.sessionToken).toEqual(secondAliasToken);
+ done();
+ });
+ });
+ });
+
+ it('should not rewrite a canonical header when only unrelated headers are present', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-unrelated'] = 'value';
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-session-token']).toBeUndefined();
+ done();
+ });
+ });
+
+ it('should not overwrite an empty-string canonical header with an alias value', done => {
+ 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'] = '';
+ fakeReq.headers['x-session-token-alias'] = 'session-token-alias-value';
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-session-token']).toEqual('');
+ done();
+ });
+ });
+
+ it('should not rewrite CORS-safelisted Accept into application-id', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['Accept'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['accept'] = 'test';
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-application-id']).toBeUndefined();
+ done();
+ });
+ });
+
+ it('should resolve aliases when canonical header key casing differs', done => {
+ 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'] = 'session-token-alias-value';
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-session-token']).toEqual('session-token-alias-value');
+ done();
+ });
+ });
+
+ it('should not rewrite master-key aliases when present in request headers', done => {
+ 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';
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-master-key']).toBeUndefined();
+ done();
+ });
+ });
+
+ 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 df5da2070d..aba2d0964c 100644
--- a/spec/ParseGraphQLServer.spec.js
+++ b/spec/ParseGraphQLServer.spec.js
@@ -32,7 +32,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');
const { randomUUID: uuidv4 } = require('crypto');
@@ -105,6 +105,42 @@ 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 resolve aliases when canonical header key casing differs', () => {
+ const headers = getCSRFRequestHeaders({
+ 'x-parse-application-id': ['X-App-Id'],
+ });
+ expect(headers).toEqual(['X-Parse-Application-Id', 'X-App-Id']);
+ });
+
+ 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']);
+ });
+
+ it('should tolerate null or undefined headerAliases without throwing', () => {
+ expect(getCSRFRequestHeaders(null)).toEqual(['X-Parse-Application-Id']);
+ expect(getCSRFRequestHeaders(undefined)).toEqual(['X-Parse-Application-Id']);
+ });
+ });
+
describe('_getServer', () => {
it('should only return new server on schema changes', async () => {
parseGraphQLServer.server = undefined;
@@ -135,6 +171,7 @@ describe('ParseGraphQLServer', () => {
expect(server).toBe(firstServer);
});
});
+
});
describe('_getGraphQLOptions', () => {
@@ -201,6 +238,132 @@ 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);
+ });
+
+ it('prefers canonical application-id over alias in GraphQL alias middleware', 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
+ );
+ const [, aliasMiddleware] = useCalls[parseHeadersIndex - 1];
+ const req = {
+ originalUrl: '/graphql',
+ url: '/graphql',
+ protocol: 'http',
+ headers: {
+ host: 'localhost',
+ 'x-parse-application-id': parseServerWithAliases.config.appId,
+ 'x-app-id': 'other-app-id',
+ },
+ get: key => req.headers[key.toLowerCase()],
+ };
+ await new Promise(resolve => aliasMiddleware(req, {}, resolve));
+ expect(req.headers['x-parse-application-id']).toBe(parseServerWithAliases.config.appId);
+ });
+
+ it('does not authorize GraphQL via Accept application-id alias before CSRF checks', async () => {
+ const AppCache = require('../lib/cache').default;
+ const parseServerWithAliases = await global.reconfigureServer({
+ maintenanceKey: 'test2',
+ maxUploadSize: '1kb',
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-App-Id'],
+ },
+ });
+ // Inject a blocked alias without re-validation to prove rewrite defense.
+ const cached = AppCache.get(parseServerWithAliases.config.appId);
+ AppCache.put(parseServerWithAliases.config.appId, {
+ ...cached,
+ headerAliases: {
+ 'X-Parse-Application-Id': ['Accept'],
+ },
+ });
+ 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
+ );
+ const [, aliasMiddleware] = useCalls[parseHeadersIndex - 1];
+ const req = {
+ originalUrl: '/graphql',
+ url: '/graphql',
+ protocol: 'http',
+ headers: {
+ host: 'localhost',
+ accept: parseServerWithAliases.config.appId,
+ },
+ get: key => req.headers[key.toLowerCase()],
+ };
+ await new Promise(resolve => aliasMiddleware(req, {}, resolve));
+ expect(req.headers['x-parse-application-id']).toBeUndefined();
+ });
});
describe('applyPlayground', () => {
@@ -9037,6 +9200,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..7012fa5445 100644
--- a/spec/rest.spec.js
+++ b/spec/rest.spec.js
@@ -1738,6 +1738,121 @@ describe('read-only masterKey', () => {
});
});
+describe('rest header aliases', () => {
+ const signUpViaRest = async username => {
+ const response = await request({
+ url: `${Parse.serverURL}/users`,
+ method: 'POST',
+ headers: {
+ 'X-Parse-Application-Id': Parse.applicationId,
+ 'X-Parse-REST-API-Key': 'rest',
+ 'Content-Type': 'application/json',
+ },
+ body: { username, password: 'password' },
+ });
+ return response.data;
+ };
+
+ 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();
+ }
+ });
+
+ it('prefers canonical session token over alias on /users/me', async () => {
+ await reconfigureServer({
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias'],
+ },
+ });
+ try {
+ // Create users via REST so the SDK current-user singleton is not shared/overwritten.
+ const canonicalUser = await signUpViaRest(`alias-rest-canonical-${Date.now()}`);
+ const aliasUser = await signUpViaRest(`alias-rest-alias-${Date.now()}`);
+ 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-Parse-Session-Token': canonicalUser.sessionToken,
+ 'X-Session-Token-Alias': aliasUser.sessionToken,
+ },
+ });
+ expect(response.data.objectId).toBe(canonicalUser.objectId);
+ } finally {
+ await reconfigureServer();
+ }
+ });
+
+ it('uses the first matching session-token alias over REST according to config order', async () => {
+ await reconfigureServer({
+ headerAliases: {
+ 'X-Parse-Session-Token': ['X-Session-Token-Alias-A', 'X-Session-Token-Alias-B'],
+ },
+ });
+ try {
+ // Create users via REST so the SDK current-user singleton is not shared/overwritten.
+ const firstAliasUser = await signUpViaRest(`alias-rest-a-${Date.now()}`);
+ const secondAliasUser = await signUpViaRest(`alias-rest-b-${Date.now()}`);
+ 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-B': secondAliasUser.sessionToken,
+ 'X-Session-Token-Alias-A': firstAliasUser.sessionToken,
+ },
+ });
+ expect(response.data.objectId).toBe(firstAliasUser.objectId);
+ } 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 95543c6c6b..9c1a3da5fd 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -43,7 +43,33 @@ function removeTrailingSlash(str) {
*/
const asyncKeys = ['publicServerURL'];
+// Canonical Parse headers that may be aliased. Credential-bearing headers
+// (master key, maintenance key) are intentionally excluded.
+const ALLOWED_HEADER_ALIAS_CANONICALS = new Set([
+ 'x-parse-application-id',
+ 'x-parse-session-token',
+ 'x-parse-installation-id',
+ 'x-parse-client-key',
+ 'x-parse-javascript-key',
+ 'x-parse-windows-key',
+ 'x-parse-rest-api-key',
+]);
+
+// CORS-safelisted request-header names (case-insensitive) plus Range.
+// These must never be configured as aliases: browsers attach them ambiently,
+// and rewriting them into Parse headers (especially application-id) before
+// Apollo CSRF validation would let simple cross-site multipart requests pass.
+const HEADER_ALIAS_CSRF_BLOCKLIST = new Set([
+ 'accept',
+ 'accept-language',
+ 'content-language',
+ 'content-type',
+ 'range',
+]);
+
export class Config {
+ static ALLOWED_HEADER_ALIAS_CANONICALS = ALLOWED_HEADER_ALIAS_CANONICALS;
+ static HEADER_ALIAS_CSRF_BLOCKLIST = HEADER_ALIAS_CSRF_BLOCKLIST;
static get(applicationId: string, mount: string) {
const cacheInfo = AppCache.get(applicationId);
if (!cacheInfo) {
@@ -130,6 +156,7 @@ export class Config {
readOnlyMasterKey,
readOnlyMasterKeyIps,
allowHeaders,
+ headerAliases,
idempotencyOptions,
fileUpload,
fileDownload,
@@ -183,6 +210,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);
@@ -769,6 +797,106 @@ 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 SAFE_HEADER_NAME = /^[A-Za-z0-9-]+$/;
+ 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';
+ }
+ const trimmedCanonical = canonicalHeader.trim();
+ if (!SAFE_HEADER_NAME.test(trimmedCanonical)) {
+ throw new Error(
+ `Header aliases canonical '${canonicalHeader}' contains invalid characters`
+ );
+ }
+ if (!Config.ALLOWED_HEADER_ALIAS_CANONICALS.has(trimmedCanonical.toLowerCase())) {
+ throw new Error(
+ `Header aliases canonical '${canonicalHeader}' is not an allowed Parse header`
+ );
+ }
+ 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 trimmedAlias = alias.trim();
+ if (!SAFE_HEADER_NAME.test(trimmedAlias)) {
+ throw new Error(
+ `Header alias '${alias}' for canonical header '${canonicalHeader}' contains invalid characters`
+ );
+ }
+ if (HEADER_ALIAS_CSRF_BLOCKLIST.has(trimmedAlias.toLowerCase())) {
+ throw new Error(
+ `Header alias '${alias}' for canonical header '${canonicalHeader}' is a CORS-safelisted request header and cannot be used as an alias`
+ );
+ }
+ });
+ }
+
+ 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 572a6fa71d..c21b45e37c 100644
--- a/src/GraphQL/ParseGraphQLServer.js
+++ b/src/GraphQL/ParseGraphQLServer.js
@@ -4,9 +4,17 @@ 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 Config from '../Config';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
import { createComplexityValidationPlugin } from './helpers/queryComplexity';
@@ -90,6 +98,18 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
});
+// Aliases matching CORS-safelisted names / Range must not appear on Apollo's
+// CSRF requestHeaders list. Config.validateHeaderAliases and applyHeaderAliases
+// also reject/skip these names so they cannot be rewritten into application-id
+// before Apollo's CSRF check.
+export const getCSRFRequestHeaders = headerAliases => {
+ const aliases = getHeaderAliases(headerAliases, 'X-Parse-Application-Id');
+ const safeAliases = aliases.filter(
+ alias => !Config.HEADER_ALIAS_CSRF_BLOCKLIST.has(alias.trim().toLowerCase())
+ );
+ return [...new Set(['X-Parse-Application-Id', ...safeAliases])];
+};
+
// graphql-js embeds "Did you mean ...?" hints sourced from the live schema in
// its error messages. They are produced in two distinct phases:
// - validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
@@ -285,11 +305,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
@@ -344,6 +366,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 f5230e7f1b..176fb376af 100644
--- a/src/Options/Definitions.js
+++ b/src/Options/Definitions.js
@@ -316,6 +316,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 allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. 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 91d58cbe9b..3e725203d9 100644
--- a/src/Options/docs.js
+++ b/src/Options/docs.js
@@ -60,6 +60,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 allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. 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 {InstallationOptions} installation Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`.
diff --git a/src/Options/index.js b/src/Options/index.js
index d8f56defca..77b76167ee 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 allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. 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 24ecc234b5..1e4e57d3cb 100644
--- a/src/middlewares.js
+++ b/src/middlewares.js
@@ -64,6 +64,71 @@ 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 target = String(canonicalHeader).trim().toLowerCase();
+ const matchedKey = Object.keys(headerAliases || {}).find(
+ key => String(key).trim().toLowerCase() === target
+ );
+ const aliases = matchedKey === undefined ? undefined : headerAliases[matchedKey];
+ 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 || {};
+ for (const [canonicalHeader, aliases] of Object.entries(headerAliases || {})) {
+ const canonicalKey = String(canonicalHeader).trim().toLowerCase();
+ // Only rewrite allowlisted, non-secret Parse headers (one-to-one mapping
+ // and destination allowlist are also enforced in Config.validateHeaderAliases).
+ if (!Config.ALLOWED_HEADER_ALIAS_CANONICALS.has(canonicalKey)) {
+ continue;
+ }
+ if (req.headers[canonicalKey] !== undefined) {
+ continue; // canonical wins
+ }
+ const matchedAlias = (aliases || []).find(alias => {
+ const aliasKey = String(alias).trim().toLowerCase();
+ // Never rewrite CORS-safelisted / Range headers (GraphQL CSRF defense).
+ if (Config.HEADER_ALIAS_CSRF_BLOCKLIST.has(aliasKey)) {
+ return false;
+ }
+ return req.headers[aliasKey] !== undefined;
+ });
+ if (matchedAlias) {
+ req.headers[canonicalKey] = req.headers[String(matchedAlias).trim().toLowerCase()];
+ }
+ }
+}
+
+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.
@@ -399,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..c2169addd8 100644
--- a/types/Options/index.d.ts
+++ b/types/Options/index.d.ts
@@ -52,6 +52,13 @@ export interface ParseServerOptions {
maintenanceKeyIps?: (string[]);
appName?: string;
allowHeaders?: (string[]);
+ /**
+ * Optional aliases for non-secret Parse request headers only
+ * (application ID, session token, installation ID, client/API keys).
+ * Credential-bearing headers such as X-Parse-Master-Key and
+ * X-Parse-Maintenance-Key cannot be aliased and are rejected at validation.
+ */
+ headerAliases?: { [headerName: string]: string[] };
allowOrigin?: StringOrStringArray;
analyticsAdapter?: Adapter;
filesAdapter?: Adapter;