Skip to content

Commit a40d82c

Browse files
committed
feat: header alias
1 parent f7f3542 commit a40d82c

12 files changed

Lines changed: 309 additions & 7 deletions

File tree

spec/Middlewares.spec.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,25 @@ describe('middlewares', () => {
339339
expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS);
340340
});
341341

342+
it('should append configured header aliases to Access-Control-Allow-Headers', () => {
343+
AppCachePut(fakeReq.body._ApplicationId, {
344+
headerAliases: {
345+
'X-Parse-Application-Id': ['X-App-Id'],
346+
'X-Parse-Session-Token': ['X-Session-Token-Alias'],
347+
},
348+
});
349+
const headers = {};
350+
const res = {
351+
header: (key, value) => {
352+
headers[key] = value;
353+
},
354+
};
355+
const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId);
356+
allowCrossDomain(fakeReq, res, () => {});
357+
expect(headers['Access-Control-Allow-Headers']).toContain('X-App-Id');
358+
expect(headers['Access-Control-Allow-Headers']).toContain('X-Session-Token-Alias');
359+
});
360+
342361
it('should set default Access-Control-Allow-Origin if allowOrigin is empty', () => {
343362
AppCachePut(fakeReq.body._ApplicationId, {
344363
allowOrigin: undefined,
@@ -409,6 +428,58 @@ describe('middlewares', () => {
409428
});
410429
});
411430

431+
it('should resolve app id from configured header alias', done => {
432+
AppCachePut(fakeReq.body._ApplicationId, {
433+
headerAliases: {
434+
'X-Parse-Application-Id': ['X-App-Id'],
435+
},
436+
masterKeyIps: ['0.0.0.0/0'],
437+
});
438+
fakeReq.headers['x-app-id'] = fakeReq.body._ApplicationId;
439+
middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
440+
expect(fakeReq.headers['x-parse-application-id']).toEqual(fakeReq.body._ApplicationId);
441+
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
442+
expect(fakeReq.info.appId).toEqual(fakeReq.body._ApplicationId);
443+
done();
444+
});
445+
});
446+
});
447+
448+
it('should resolve session token from configured header alias', done => {
449+
const sessionToken = 'session-token-via-alias';
450+
AppCachePut(fakeReq.body._ApplicationId, {
451+
headerAliases: {
452+
'X-Parse-Session-Token': ['X-Session-Token-Alias'],
453+
},
454+
masterKeyIps: ['0.0.0.0/0'],
455+
});
456+
fakeReq.headers['x-session-token-alias'] = sessionToken;
457+
middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => {
458+
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
459+
expect(fakeReq.info.sessionToken).toEqual(sessionToken);
460+
done();
461+
});
462+
});
463+
});
464+
465+
it('should resolve master key from configured alias in handleParseAuth', async () => {
466+
AppCachePut(fakeReq.body._ApplicationId, {
467+
headerAliases: {
468+
'X-Parse-Master-Key': ['X-Master-Key-Alias'],
469+
},
470+
masterKey: 'masterKey',
471+
masterKeyIps: ['0.0.0.0/0'],
472+
});
473+
fakeReq.headers['x-master-key-alias'] = 'masterKey';
474+
await new Promise(resolve =>
475+
middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, resolve)
476+
);
477+
await new Promise(resolve =>
478+
middlewares.handleParseAuth(fakeReq.body._ApplicationId)(fakeReq, fakeRes, resolve)
479+
);
480+
expect(fakeReq.auth.isMaster).toBe(true);
481+
});
482+
412483
it('should give invalid response when upload file without x-parse-application-id in header', () => {
413484
AppCachePut(fakeReq.body._ApplicationId, {
414485
masterKey: 'masterKey',

spec/ParseGraphQLServer.spec.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const {
2828
GraphQLList,
2929
} = require('graphql');
3030
const { ParseServer } = require('../');
31-
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
31+
const { ParseGraphQLServer, getCSRFRequestHeaders } = require('../lib/GraphQL/ParseGraphQLServer');
3232
const { ReadPreference, Collection } = require('mongodb');
3333
let uuidv4;
3434

@@ -135,6 +135,13 @@ describe('ParseGraphQLServer', () => {
135135
expect(server).toBe(firstServer);
136136
});
137137
});
138+
139+
it('should include application-id header aliases in GraphQL CSRF request headers', () => {
140+
const headers = getCSRFRequestHeaders({
141+
'X-Parse-Application-Id': ['X-App-Id', 'X-Client-App'],
142+
});
143+
expect(headers).toEqual(['X-Parse-Application-Id', 'X-App-Id', 'X-Client-App']);
144+
});
138145
});
139146

140147
describe('_getGraphQLOptions', () => {
@@ -201,6 +208,46 @@ describe('ParseGraphQLServer', () => {
201208
).not.toThrow();
202209
expect(useCount).toBeGreaterThan(0);
203210
});
211+
212+
it('registers header alias normalization before parse header handling', async () => {
213+
const parseServerWithAliases = await global.reconfigureServer({
214+
maintenanceKey: 'test2',
215+
maxUploadSize: '1kb',
216+
headerAliases: {
217+
'X-Parse-Application-Id': ['X-App-Id'],
218+
},
219+
});
220+
const graphQLServerWithAliases = new ParseGraphQLServer(parseServerWithAliases, {
221+
graphQLPath: '/graphql',
222+
playgroundPath: '/playground',
223+
});
224+
const middlewares = require('../lib/middlewares');
225+
const useCalls = [];
226+
const app = {
227+
use: (...args) => {
228+
useCalls.push(args);
229+
},
230+
};
231+
graphQLServerWithAliases.applyGraphQL(app);
232+
const parseHeadersIndex = useCalls.findIndex(
233+
([path, middleware]) => path === '/graphql' && middleware === middlewares.handleParseHeaders
234+
);
235+
expect(parseHeadersIndex).toBeGreaterThan(0);
236+
const [path, aliasMiddleware] = useCalls[parseHeadersIndex - 1];
237+
expect(path).toBe('/graphql');
238+
const req = {
239+
originalUrl: '/graphql',
240+
url: '/graphql',
241+
protocol: 'http',
242+
headers: {
243+
host: 'localhost',
244+
'x-app-id': parseServerWithAliases.config.appId,
245+
},
246+
get: key => req.headers[key.toLowerCase()],
247+
};
248+
await new Promise(resolve => aliasMiddleware(req, {}, resolve));
249+
expect(req.headers['x-parse-application-id']).toBe(parseServerWithAliases.config.appId);
250+
});
204251
});
205252

206253
describe('applyPlayground', () => {
@@ -8325,6 +8372,37 @@ describe('ParseGraphQLServer', () => {
83258372
});
83268373

83278374
describe('Session Token', () => {
8375+
it('should retrieve me with session token header alias', async () => {
8376+
parseServer = await global.reconfigureServer({
8377+
headerAliases: {
8378+
'X-Parse-Session-Token': ['X-Session-Token-Alias'],
8379+
},
8380+
});
8381+
await createGQLFromParseServer(parseServer);
8382+
const username = `alias-gql-${uuidv4()}`;
8383+
const user = new Parse.User();
8384+
user.setUsername(username);
8385+
user.setPassword('password');
8386+
await user.signUp();
8387+
const result = await apolloClient.query({
8388+
query: gql`
8389+
query GetCurrentUser {
8390+
viewer {
8391+
user {
8392+
username
8393+
}
8394+
}
8395+
}
8396+
`,
8397+
context: {
8398+
headers: {
8399+
'X-Session-Token-Alias': user.getSessionToken(),
8400+
},
8401+
},
8402+
});
8403+
expect(result.data.viewer.user.username).toBe(username);
8404+
});
8405+
83288406
it('should fail due to invalid session token', async () => {
83298407
try {
83308408
await apolloClient.query({

spec/rest.spec.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,6 +1738,55 @@ describe('read-only masterKey', () => {
17381738
});
17391739
});
17401740

1741+
describe('rest header aliases', () => {
1742+
it('supports REST requests with application-id header alias only', async () => {
1743+
await reconfigureServer({
1744+
headerAliases: {
1745+
'X-Parse-Application-Id': ['X-App-Id'],
1746+
},
1747+
});
1748+
try {
1749+
const response = await request({
1750+
url: `${Parse.serverURL}/schemas`,
1751+
method: 'GET',
1752+
headers: {
1753+
'X-App-Id': Parse.applicationId,
1754+
'X-Parse-Master-Key': Parse.masterKey,
1755+
},
1756+
});
1757+
expect(response.data.results).toBeDefined();
1758+
expect(Array.isArray(response.data.results)).toBe(true);
1759+
} finally {
1760+
await reconfigureServer();
1761+
}
1762+
});
1763+
1764+
it('supports /users/me with session-token header alias', async () => {
1765+
await reconfigureServer({
1766+
headerAliases: {
1767+
'X-Parse-Session-Token': ['X-Session-Token-Alias'],
1768+
},
1769+
});
1770+
try {
1771+
const username = `alias-rest-${Date.now()}`;
1772+
const user = await Parse.User.signUp(username, 'password');
1773+
const response = await request({
1774+
url: `${Parse.serverURL}/users/me`,
1775+
method: 'GET',
1776+
headers: {
1777+
'X-Parse-Application-Id': Parse.applicationId,
1778+
'X-Parse-REST-API-Key': 'rest',
1779+
'X-Session-Token-Alias': user.getSessionToken(),
1780+
},
1781+
});
1782+
expect(response.data.objectId).toBe(user.id);
1783+
expect(response.data.username).toBe(username);
1784+
} finally {
1785+
await reconfigureServer();
1786+
}
1787+
});
1788+
});
1789+
17411790
describe('rest context', () => {
17421791
it('should support dependency injection on rest api', async () => {
17431792
const requestContextMiddleware = (req, res, next) => {

src/Config.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export class Config {
129129
readOnlyMasterKey,
130130
readOnlyMasterKeyIps,
131131
allowHeaders,
132+
headerAliases,
132133
idempotencyOptions,
133134
fileUpload,
134135
fileDownload,
@@ -181,6 +182,7 @@ export class Config {
181182
this.validateDefaultLimit(defaultLimit);
182183
this.validateMaxLimit(maxLimit);
183184
this.validateAllowHeaders(allowHeaders);
185+
this.validateHeaderAliases(headerAliases);
184186
this.validateIdempotencyOptions(idempotencyOptions);
185187
this.validatePagesOptions(pages);
186188
this.validateSecurityOptions(security);
@@ -722,6 +724,29 @@ export class Config {
722724
}
723725
}
724726

727+
static validateHeaderAliases(headerAliases) {
728+
if (![null, undefined].includes(headerAliases)) {
729+
if (Object.prototype.toString.call(headerAliases) !== '[object Object]') {
730+
throw 'Header aliases must be an object';
731+
}
732+
for (const [canonicalHeader, aliases] of Object.entries(headerAliases)) {
733+
if (typeof canonicalHeader !== 'string' || !canonicalHeader.trim().length) {
734+
throw 'Header aliases must contain non-empty string keys';
735+
}
736+
if (!Array.isArray(aliases)) {
737+
throw `Header aliases for '${canonicalHeader}' must be an array`;
738+
}
739+
aliases.forEach(alias => {
740+
if (typeof alias !== 'string') {
741+
throw `Header aliases for '${canonicalHeader}' must only contain strings`;
742+
} else if (!alias.trim().length) {
743+
throw `Header aliases for '${canonicalHeader}' must not contain empty strings`;
744+
}
745+
});
746+
}
747+
}
748+
}
749+
725750
static validateLogLevels(logLevels) {
726751
for (const key of Object.keys(LogLevels)) {
727752
if (logLevels[key]) {

src/GraphQL/ParseGraphQLServer.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { expressMiddleware } from '@as-integrations/express5';
44
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
55
import express from 'express';
66
import { GraphQLError, parse } from 'graphql';
7-
import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
7+
import {
8+
allowCrossDomain,
9+
getHeaderAliases,
10+
handleHeaderAliases,
11+
handleParseErrors,
12+
handleParseHeaders,
13+
handleParseSession,
14+
} from '../middlewares';
815
import requiredParameter from '../requiredParameter';
916
import defaultLogger from '../logger';
1017
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
@@ -90,6 +97,10 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
9097

9198
});
9299

100+
export const getCSRFRequestHeaders = headerAliases => {
101+
return [...new Set(['X-Parse-Application-Id', ...getHeaderAliases(headerAliases, 'X-Parse-Application-Id')])];
102+
};
103+
93104
class ParseGraphQLServer {
94105
parseGraphQLController: ParseGraphQLController;
95106

@@ -144,11 +155,12 @@ class ParseGraphQLServer {
144155
const createServer = async () => {
145156
try {
146157
const { schema, context } = await this._getGraphQLOptions();
158+
const csrfRequestHeaders = getCSRFRequestHeaders(this.parseServer.config.headerAliases);
147159
const apollo = new ApolloServer({
148160
csrfPrevention: {
149161
// See https://www.apollographql.com/docs/router/configuration/csrf/
150162
// needed since we use graphql upload
151-
requestHeaders: ['X-Parse-Application-Id'],
163+
requestHeaders: csrfRequestHeaders,
152164
},
153165
// We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable
154166
// we delegate the introspection control to the IntrospectionControlPlugin
@@ -203,6 +215,7 @@ class ParseGraphQLServer {
203215
requiredParameter('You must provide an Express.js app instance!');
204216
}
205217
app.use(this.config.graphQLPath, allowCrossDomain(this.parseServer.config.appId));
218+
app.use(this.config.graphQLPath, handleHeaderAliases(this.parseServer.config.appId));
206219
app.use(this.config.graphQLPath, handleParseHeaders);
207220
app.use(this.config.graphQLPath, handleParseSession);
208221
this.applyRequestContextMiddleware(app, this.parseServer.config);

src/Options/Definitions.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,11 @@ module.exports.ParseServerOptions = {
310310
env: 'PARSE_SERVER_GRAPH_QLSCHEMA',
311311
help: 'Full path to your GraphQL custom schema.graphql file',
312312
},
313+
headerAliases: {
314+
env: 'PARSE_SERVER_HEADER_ALIASES',
315+
help: '(Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.<br><br>Example:<br>`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`<br><br>When setting this option via an environment variable, provide a JSON object string.',
316+
action: parsers.objectParser,
317+
},
313318
host: {
314319
env: 'PARSE_SERVER_HOST',
315320
help: 'The host to serve ParseServer on, defaults to 0.0.0.0',

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export interface ParseServerOptions {
9191
appName: ?string;
9292
/* Add headers to Access-Control-Allow-Headers */
9393
allowHeaders: ?(string[]);
94+
/* (Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.<br><br>Example:<br>`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`<br><br>When setting this option via an environment variable, provide a JSON object string.
95+
:ENV: PARSE_SERVER_HEADER_ALIASES */
96+
headerAliases: ?{ [string]: string[] };
9497
/* Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. */
9598
allowOrigin: ?StringOrStringArray;
9699
/* Adapter module for the analytics */

src/ParseServer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ class ParseServer {
311311
//api.use("/apps", express.static(__dirname + "/public"));
312312
api.use(middlewares.allowCrossDomain(appId));
313313
api.use(middlewares.allowDoubleForwardSlash);
314+
api.use(middlewares.handleHeaderAliases(appId));
314315
api.use(middlewares.handleParseAuth(appId));
315316
// File handling needs to be before the default JSON body parser because file
316317
// uploads send binary data that should not be parsed as JSON.

src/defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const DefinitionDefaults = Object.keys(ParseServerOptions).reduce((memo, key) =>
2525
}, {});
2626

2727
const computedDefaults = {
28+
headerAliases: {},
2829
jsonLogs: process.env.JSON_LOGS || false,
2930
logsFolder,
3031
verbose,

0 commit comments

Comments
 (0)