diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js
new file mode 100644
index 0000000000..22b54ad6d8
--- /dev/null
+++ b/spec/HeaderAliasesValidation.spec.js
@@ -0,0 +1,263 @@
+'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 browser-generated headers as aliases', () => {
+ for (const alias of ['Origin', 'Cookie', 'Referer', 'User-Agent']) {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [alias],
+ })
+ ).toThrowError(/browser-controlled, or CORS-safelisted request header/);
+ }
+ });
+
+ it('should reject reserved sec- and proxy- prefix aliases', () => {
+ for (const alias of ['Sec-Fetch-Site', 'Proxy-Authorization']) {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': [alias],
+ })
+ ).toThrowError(/reserved, browser-controlled, or 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/);
+ });
+
+ it('should reject an alias that is another allowlisted Parse header even if that header is not in the mapping', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['x-parse-session-token'],
+ })
+ ).toThrowError(/collides with canonical header/);
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Session-Token': ['X-Parse-Installation-Id'],
+ })
+ ).toThrowError(/collides with canonical header/);
+ });
+
+ it('should reject an alias that is a credential-bearing Parse header', () => {
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Application-Id': ['X-Parse-Master-Key'],
+ })
+ ).toThrowError(/collides with canonical header/);
+ expect(() =>
+ Config.validateHeaderAliases({
+ 'X-Parse-Session-Token': ['x-parse-maintenance-key'],
+ })
+ ).toThrowError(/collides with canonical header/);
+ });
+});
diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js
index ff6fa003b5..6a2d6a3f2e 100644
--- a/spec/Middlewares.spec.js
+++ b/spec/Middlewares.spec.js
@@ -128,43 +128,49 @@ describe('middlewares', () => {
const otherKeys = BodyKeys.filter(
otherKey => otherKey !== infoKey && otherKey !== 'javascriptKey'
);
- it_id('f9abd7ac-b1f4-4607-b9b0-365ff0559d84')(it)(`it should pull ${bodyKey} into req.info`, done => {
- AppCachePut(fakeReq.body._ApplicationId, {
- masterKeyIps: ['0.0.0.0/0'],
- });
- fakeReq.ip = '127.0.0.1';
- fakeReq.body[bodyKey] = keyValue;
- middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
- expect(fakeReq.body[bodyKey]).toEqual(undefined);
- expect(fakeReq.info[infoKey]).toEqual(keyValue);
-
- otherKeys.forEach(otherKey => {
- expect(fakeReq.info[otherKey]).toEqual(undefined);
+ it_id('f9abd7ac-b1f4-4607-b9b0-365ff0559d84')(it)(
+ `it should pull ${bodyKey} into req.info`,
+ done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ masterKeyIps: ['0.0.0.0/0'],
});
+ fakeReq.ip = '127.0.0.1';
+ fakeReq.body[bodyKey] = keyValue;
+ middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
+ expect(fakeReq.body[bodyKey]).toEqual(undefined);
+ expect(fakeReq.info[infoKey]).toEqual(keyValue);
- done();
- });
- });
+ otherKeys.forEach(otherKey => {
+ expect(fakeReq.info[otherKey]).toEqual(undefined);
+ });
+
+ done();
+ });
+ }
+ );
});
- it_id('4a0bce41-c536-4482-a873-12ed023380e2')(it)('should not succeed and log if the ip does not belong to masterKeyIps list', async () => {
- const logger = require('../lib/logger').logger;
- spyOn(logger, 'error').and.callFake(() => {});
- AppCachePut(fakeReq.body._ApplicationId, {
- masterKey: 'masterKey',
- masterKeyIps: ['10.0.0.1'],
- });
- fakeReq.ip = '127.0.0.1';
- fakeReq.headers['x-parse-master-key'] = 'masterKey';
+ it_id('4a0bce41-c536-4482-a873-12ed023380e2')(it)(
+ 'should not succeed and log if the ip does not belong to masterKeyIps list',
+ async () => {
+ const logger = require('../lib/logger').logger;
+ spyOn(logger, 'error').and.callFake(() => {});
+ AppCachePut(fakeReq.body._ApplicationId, {
+ masterKey: 'masterKey',
+ masterKeyIps: ['10.0.0.1'],
+ });
+ fakeReq.ip = '127.0.0.1';
+ fakeReq.headers['x-parse-master-key'] = 'masterKey';
- const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
+ const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
- expect(error).toBeDefined();
- expect(error.message).toEqual(`unauthorized`);
- expect(logger.error).toHaveBeenCalledWith(
- `Request using master key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'masterKeyIps'.`
- );
- });
+ expect(error).toBeDefined();
+ expect(error.message).toEqual(`unauthorized`);
+ expect(logger.error).toHaveBeenCalledWith(
+ `Request using master key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'masterKeyIps'.`
+ );
+ }
+ );
it('should not succeed and log if the ip does not belong to maintenanceKeyIps list', async () => {
const logger = require('../lib/logger').logger;
@@ -185,52 +191,61 @@ describe('middlewares', () => {
);
});
- it_id('5b8b9280-53ec-445a-b868-6992931d2236')(it)('should reject maintenance key from non-allowed IP instead of downgrading to anonymous auth', async () => {
- await reconfigureServer({
- maintenanceKeyIps: ['10.0.0.1'],
- });
- const logger = require('../lib/logger').logger;
- spyOn(logger, 'error').and.callFake(() => {});
- AppCachePut(fakeReq.body._ApplicationId, {
- maintenanceKey: 'maintenanceKey',
- maintenanceKeyIps: ['10.0.0.1'],
- masterKey: 'masterKey',
- masterKeyIps: ['0.0.0.0/0', '::0'],
- });
- fakeReq.ip = '127.0.0.1';
- fakeReq.headers['x-parse-maintenance-key'] = 'maintenanceKey';
-
- const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
+ it_id('5b8b9280-53ec-445a-b868-6992931d2236')(it)(
+ 'should reject maintenance key from non-allowed IP instead of downgrading to anonymous auth',
+ async () => {
+ await reconfigureServer({
+ maintenanceKeyIps: ['10.0.0.1'],
+ });
+ const logger = require('../lib/logger').logger;
+ spyOn(logger, 'error').and.callFake(() => {});
+ AppCachePut(fakeReq.body._ApplicationId, {
+ maintenanceKey: 'maintenanceKey',
+ maintenanceKeyIps: ['10.0.0.1'],
+ masterKey: 'masterKey',
+ masterKeyIps: ['0.0.0.0/0', '::0'],
+ });
+ fakeReq.ip = '127.0.0.1';
+ fakeReq.headers['x-parse-maintenance-key'] = 'maintenanceKey';
- expect(error).toBeDefined();
- expect(error.status).toBe(403);
- expect(error.message).toEqual('unauthorized');
- expect(logger.error).toHaveBeenCalledWith(
- `Request using maintenance key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'maintenanceKeyIps'.`
- );
- });
+ const error = await middlewares.handleParseHeaders(fakeReq, fakeRes, () => {}).catch(e => e);
- it_id('2f7fadec-a87c-4626-90d1-65c75653aea9')(it)('should succeed if the ip does belong to masterKeyIps list', async () => {
- AppCachePut(fakeReq.body._ApplicationId, {
- masterKey: 'masterKey',
- masterKeyIps: ['10.0.0.1'],
- });
- fakeReq.ip = '10.0.0.1';
- fakeReq.headers['x-parse-master-key'] = 'masterKey';
- await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
- expect(fakeReq.auth.isMaster).toBe(true);
- });
+ expect(error).toBeDefined();
+ expect(error.status).toBe(403);
+ expect(error.message).toEqual('unauthorized');
+ expect(logger.error).toHaveBeenCalledWith(
+ `Request using maintenance key rejected as the request IP address '127.0.0.1' is not set in Parse Server option 'maintenanceKeyIps'.`
+ );
+ }
+ );
- it_id('2b251fd4-d43c-48f4-ada9-c8458e40c12a')(it)('should allow any ip to use masterKey if masterKeyIps is empty', async () => {
- AppCachePut(fakeReq.body._ApplicationId, {
- masterKey: 'masterKey',
- masterKeyIps: ['0.0.0.0/0'],
- });
- fakeReq.ip = '10.0.0.1';
- fakeReq.headers['x-parse-master-key'] = 'masterKey';
- await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
- expect(fakeReq.auth.isMaster).toBe(true);
- });
+ it_id('2f7fadec-a87c-4626-90d1-65c75653aea9')(it)(
+ 'should succeed if the ip does belong to masterKeyIps list',
+ async () => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ masterKey: 'masterKey',
+ masterKeyIps: ['10.0.0.1'],
+ });
+ fakeReq.ip = '10.0.0.1';
+ fakeReq.headers['x-parse-master-key'] = 'masterKey';
+ await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
+ expect(fakeReq.auth.isMaster).toBe(true);
+ }
+ );
+
+ it_id('2b251fd4-d43c-48f4-ada9-c8458e40c12a')(it)(
+ 'should allow any ip to use masterKey if masterKeyIps is empty',
+ async () => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ masterKey: 'masterKey',
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.ip = '10.0.0.1';
+ fakeReq.headers['x-parse-master-key'] = 'masterKey';
+ await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
+ expect(fakeReq.auth.isMaster).toBe(true);
+ }
+ );
it('should not succeed and log if the ip does not belong to readOnlyMasterKeyIps list', async () => {
const logger = require('../lib/logger').logger;
@@ -338,6 +353,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 +442,269 @@ 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 not rewrite Origin into application-id', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['Origin'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['origin'] = '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 a shared alias into more than one canonical header', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-Shared-Alias'],
+ 'X-Parse-Session-Token': ['X-Shared-Alias'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-shared-alias'] = fakeReq.body._ApplicationId;
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-application-id']).toEqual(fakeReq.body._ApplicationId);
+ expect(fakeReq.headers['x-parse-session-token']).toBeUndefined();
+ done();
+ });
+ });
+
+ it('should not apply a claimed alias to a later canonical when the first already has a value', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['X-Shared-Alias'],
+ 'X-Parse-Session-Token': ['X-Shared-Alias'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId;
+ fakeReq.headers['x-shared-alias'] = 'session-token-via-shared';
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-application-id']).toEqual(fakeReq.body._ApplicationId);
+ expect(fakeReq.headers['x-parse-session-token']).toBeUndefined();
+ done();
+ });
+ });
+
+ it('should not copy a Parse canonical header into a different canonical header', done => {
+ AppCachePut(fakeReq.body._ApplicationId, {
+ headerAliases: {
+ 'X-Parse-Application-Id': ['x-parse-session-token'],
+ },
+ masterKeyIps: ['0.0.0.0/0'],
+ });
+ fakeReq.headers['x-parse-session-token'] = 'session-token-value';
+ middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
+ expect(fakeReq.headers['x-parse-application-id']).toBeUndefined();
+ expect(fakeReq.headers['x-parse-session-token']).toEqual('session-token-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..dd4daea57e 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,57 @@ 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 exclude browser-generated headers and reserved prefixes from CSRF whitelist', () => {
+ const headers = getCSRFRequestHeaders({
+ 'X-Parse-Application-Id': [
+ 'Origin',
+ 'Cookie',
+ 'Referer',
+ 'User-Agent',
+ 'Sec-Fetch-Site',
+ 'Proxy-Authorization',
+ '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 +186,7 @@ describe('ParseGraphQLServer', () => {
expect(server).toBe(firstServer);
});
});
+
});
describe('_getGraphQLOptions', () => {
@@ -201,6 +253,179 @@ 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();
+ });
+
+ it('does not authorize GraphQL via Origin 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': ['Origin'],
+ },
+ });
+ 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',
+ origin: 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 +9262,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..213e8a3c3d 100644
--- a/src/Config.js
+++ b/src/Config.js
@@ -43,7 +43,61 @@ 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',
+]);
+
+// Parse header names that cannot be used as aliases. Using a canonical
+// name as an alias would copy one request header into another Parse slot.
+const RESERVED_HEADER_ALIAS_NAMES = new Set([
+ ...ALLOWED_HEADER_ALIAS_CANONICALS,
+ 'x-parse-master-key',
+ 'x-parse-maintenance-key',
+]);
+
+// CORS-safelisted request-header names (case-insensitive) plus Range, and
+// browser-generated headers. 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
+// requests pass.
+const HEADER_ALIAS_CSRF_BLOCKLIST = new Set([
+ 'accept',
+ 'accept-language',
+ 'content-language',
+ 'content-type',
+ 'range',
+ 'origin',
+ 'cookie',
+ 'referer',
+ 'user-agent',
+]);
+
+const HEADER_ALIAS_CSRF_PREFIX_BLOCKLIST = ['sec-', 'proxy-'];
+
+function isCsrfBlockedAlias(name) {
+ const key = String(name).trim().toLowerCase();
+ if (!key) {
+ return false;
+ }
+ if (HEADER_ALIAS_CSRF_BLOCKLIST.has(key)) {
+ return true;
+ }
+ return HEADER_ALIAS_CSRF_PREFIX_BLOCKLIST.some(prefix => key.startsWith(prefix));
+}
+
export class Config {
+ static ALLOWED_HEADER_ALIAS_CANONICALS = ALLOWED_HEADER_ALIAS_CANONICALS;
+ static RESERVED_HEADER_ALIAS_NAMES = RESERVED_HEADER_ALIAS_NAMES;
+ static HEADER_ALIAS_CSRF_BLOCKLIST = HEADER_ALIAS_CSRF_BLOCKLIST;
+ static isCsrfBlockedAlias = isCsrfBlockedAlias;
static get(applicationId: string, mount: string) {
const cacheInfo = AppCache.get(applicationId);
if (!cacheInfo) {
@@ -130,6 +184,7 @@ export class Config {
readOnlyMasterKey,
readOnlyMasterKeyIps,
allowHeaders,
+ headerAliases,
idempotencyOptions,
fileUpload,
fileDownload,
@@ -183,6 +238,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 +825,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 (isCsrfBlockedAlias(trimmedAlias)) {
+ throw new Error(
+ `Header alias '${alias}' for canonical header '${canonicalHeader}' cannot be used as an alias because it is a reserved, browser-controlled, or CORS-safelisted request header`
+ );
+ }
+ });
+ }
+
+ 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 (RESERVED_HEADER_ALIAS_NAMES.has(normAlias)) {
+ throw new Error(
+ `Header alias '${alias}' for canonical header '${canonicalHeader}' collides with canonical header '${
+ canonicalNormToKey.get(normAlias) || alias
+ }'.`
+ );
+ }
+ 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..be96bd6426 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,17 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
});
+// Aliases matching CORS-safelisted names, Range, browser-generated headers, or
+// reserved sec-/proxy- prefixes 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.isCsrfBlockedAlias(alias));
+ 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 +304,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 +365,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..71cf5c59ab 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 supported for application ID, session token, installation ID, and client/API keys. Aliases for session tokens require the same protection as the canonical session-token header. 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..f6dd1e64f3 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 supported for application ID, session token, installation ID, and client/API keys. Aliases for session tokens require the same protection as the canonical session-token header. 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..5f78128071 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 supported for application ID, session token, installation ID, and client/API keys. Aliases for session tokens require the same protection as the canonical session-token header. 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..05dbbde3e3 100644
--- a/src/middlewares.js
+++ b/src/middlewares.js
@@ -25,7 +25,9 @@ const getMountForRequest = function (req) {
};
const getBlockList = (ipRangeList, store) => {
- if (store.get('blockList')) { return store.get('blockList'); }
+ if (store.get('blockList')) {
+ return store.get('blockList');
+ }
const blockList = new BlockList();
ipRangeList.forEach(fullIp => {
if (fullIp === '::/0' || fullIp === '::' || fullIp === '::0') {
@@ -51,9 +53,15 @@ export const checkIp = (ip, ipRangeList, store) => {
const incomingIpIsV4 = isIPv4(ip);
const blockList = getBlockList(ipRangeList, store);
- if (store.get(ip)) { return true; }
- if (store.get('allowAllIpv4') && incomingIpIsV4) { return true; }
- if (store.get('allowAllIpv6') && !incomingIpIsV4) { return true; }
+ if (store.get(ip)) {
+ return true;
+ }
+ if (store.get('allowAllIpv4') && incomingIpIsV4) {
+ return true;
+ }
+ if (store.get('allowAllIpv6') && !incomingIpIsV4) {
+ return true;
+ }
const result = blockList.check(ip, incomingIpIsV4 ? 'ipv4' : 'ipv6');
// If the ip is in the list, we store the result in the store
@@ -64,6 +72,89 @@ 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 || {};
+ const claimedAliases = new Set();
+ 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;
+ }
+ const normalizedAliases = (Array.isArray(aliases) ? aliases : []).map(alias =>
+ String(alias).trim().toLowerCase()
+ );
+ if (req.headers[canonicalKey] === undefined) {
+ const matchedAlias = normalizedAliases.find(aliasKey => {
+ if (!aliasKey || claimedAliases.has(aliasKey)) {
+ return false;
+ }
+ // Never rewrite CORS-safelisted, browser-generated, or reserved-prefix
+ // headers (GraphQL CSRF defense).
+ if (Config.isCsrfBlockedAlias(aliasKey)) {
+ return false;
+ }
+ // Never copy one Parse canonical header into another.
+ if (Config.RESERVED_HEADER_ALIAS_NAMES.has(aliasKey)) {
+ return false;
+ }
+ return req.headers[aliasKey] !== undefined;
+ });
+ if (matchedAlias) {
+ req.headers[canonicalKey] = req.headers[matchedAlias];
+ }
+ }
+ // Claim every alias for this canonical so a later mapping cannot reuse it.
+ for (const aliasKey of normalizedAliases) {
+ if (aliasKey) {
+ claimedAliases.add(aliasKey);
+ }
+ }
+ }
+}
+
+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.
@@ -360,7 +451,9 @@ function getClientIp(req) {
}
function httpAuth(req) {
- if (!(req.req || req).headers.authorization) { return; }
+ if (!(req.req || req).headers.authorization) {
+ return;
+ }
var header = (req.req || req).headers.authorization;
var appId, masterKey, javascriptKey;
@@ -399,13 +492,16 @@ 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 ?? ['*'];
+ typeof config?.allowOrigin === 'string'
+ ? [config.allowOrigin]
+ : (config?.allowOrigin ?? ['*']);
const requestOrigin = req.headers.origin;
const allowOrigins =
requestOrigin && baseOrigins.includes(requestOrigin) ? requestOrigin : baseOrigins[0];
@@ -599,8 +695,7 @@ export function handleParseErrors(err, req, res, next) {
if (req.config && req.config.enableExpressErrorHandler) {
return next(err);
}
- const signupUsernameTakenLevel =
- req.config?.logLevels?.signupUsernameTaken || 'info';
+ const signupUsernameTakenLevel = req.config?.logLevels?.signupUsernameTaken || 'info';
let httpStatus;
// TODO: fill out this mapping
switch (err.code) {
@@ -683,10 +778,12 @@ export const addRateLimit = (route, config, cloud) => {
const client = createClient({
url: route.redisUrl,
});
- client.on('error', err => { log.error('Middlewares addRateLimit Redis client error', { error: err }) });
- client.on('connect', () => { });
- client.on('reconnecting', () => { });
- client.on('ready', () => { });
+ client.on('error', err => {
+ log.error('Middlewares addRateLimit Redis client error', { error: err });
+ });
+ client.on('connect', () => {});
+ client.on('reconnecting', () => {});
+ client.on('ready', () => {});
redisStore.connectionPromise = async () => {
if (client.isOpen) {
return;
@@ -711,7 +808,8 @@ export const addRateLimit = (route, config, cloud) => {
requestMethods: route.requestMethods,
includeMasterKey: route.includeMasterKey,
includeInternalRequests: route.includeInternalRequests,
- errorResponseMessage: route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default,
+ errorResponseMessage:
+ route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default,
handler: rateLimit({
windowMs: route.requestTimeWindow,
max: route.requestCount,
diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts
index 6a8b1494ac..2f7fd9316f 100644
--- a/types/Options/index.d.ts
+++ b/types/Options/index.d.ts
@@ -52,6 +52,14 @@ export interface ParseServerOptions {
maintenanceKeyIps?: (string[]);
appName?: string;
allowHeaders?: (string[]);
+ /**
+ * Optional aliases for Parse request headers
+ * (application ID, session token, installation ID, client/API keys).
+ * Aliases for session tokens require the same protection as the canonical
+ * session-token header. 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;