Skip to content
71 changes: 71 additions & 0 deletions spec/Middlewares.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,25 @@ describe('middlewares', () => {
expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS);
});

it('should append configured header aliases to Access-Control-Allow-Headers', () => {
AppCachePut(fakeReq.body._ApplicationId, {
headerAliases: {
'X-Parse-Application-Id': ['X-App-Id'],
'X-Parse-Session-Token': ['X-Session-Token-Alias'],
},
});
const headers = {};
const res = {
header: (key, value) => {
headers[key] = value;
},
};
const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId);
allowCrossDomain(fakeReq, res, () => {});
expect(headers['Access-Control-Allow-Headers']).toContain('X-App-Id');
expect(headers['Access-Control-Allow-Headers']).toContain('X-Session-Token-Alias');
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
it('should set default Access-Control-Allow-Origin if allowOrigin is empty', () => {
AppCachePut(fakeReq.body._ApplicationId, {
allowOrigin: undefined,
Expand Down Expand Up @@ -409,6 +428,58 @@ 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 resolve master key from configured alias in handleParseAuth', async () => {
AppCachePut(fakeReq.body._ApplicationId, {
headerAliases: {
'X-Parse-Master-Key': ['X-Master-Key-Alias'],
},
masterKey: 'masterKey',
masterKeyIps: ['0.0.0.0/0'],
});
fakeReq.headers['x-master-key-alias'] = 'masterKey';
await new Promise(resolve =>
middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, resolve)
);
await new Promise(resolve =>
middlewares.handleParseAuth(fakeReq.body._ApplicationId)(fakeReq, fakeRes, resolve)
);
expect(fakeReq.auth.isMaster).toBe(true);
});

it('should give invalid response when upload file without x-parse-application-id in header', () => {
AppCachePut(fakeReq.body._ApplicationId, {
masterKey: 'masterKey',
Expand Down
80 changes: 79 additions & 1 deletion spec/ParseGraphQLServer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const {
GraphQLList,
} = require('graphql');
const { ParseServer } = require('../');
const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer');
const { ParseGraphQLServer, getCSRFRequestHeaders } = require('../lib/GraphQL/ParseGraphQLServer');
const { ReadPreference, Collection } = require('mongodb');
let uuidv4;

Expand Down Expand Up @@ -135,6 +135,13 @@ describe('ParseGraphQLServer', () => {
expect(server).toBe(firstServer);
});
});

it('should include application-id header aliases in GraphQL CSRF request headers', () => {
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']);
});
});

describe('_getGraphQLOptions', () => {
Expand Down Expand Up @@ -201,6 +208,46 @@ describe('ParseGraphQLServer', () => {
).not.toThrow();
expect(useCount).toBeGreaterThan(0);
});

it('registers header alias normalization before parse header handling', async () => {
const parseServerWithAliases = await global.reconfigureServer({
maintenanceKey: 'test2',
maxUploadSize: '1kb',
headerAliases: {
'X-Parse-Application-Id': ['X-App-Id'],
},
});
const graphQLServerWithAliases = new ParseGraphQLServer(parseServerWithAliases, {
graphQLPath: '/graphql',
playgroundPath: '/playground',
});
const middlewares = require('../lib/middlewares');
const useCalls = [];
const app = {
use: (...args) => {
useCalls.push(args);
},
};
graphQLServerWithAliases.applyGraphQL(app);
const parseHeadersIndex = useCalls.findIndex(
([path, middleware]) => path === '/graphql' && middleware === middlewares.handleParseHeaders
);
expect(parseHeadersIndex).toBeGreaterThan(0);
const [path, aliasMiddleware] = useCalls[parseHeadersIndex - 1];
expect(path).toBe('/graphql');
const req = {
originalUrl: '/graphql',
url: '/graphql',
protocol: 'http',
headers: {
host: 'localhost',
'x-app-id': parseServerWithAliases.config.appId,
},
get: key => req.headers[key.toLowerCase()],
};
await new Promise(resolve => aliasMiddleware(req, {}, resolve));
expect(req.headers['x-parse-application-id']).toBe(parseServerWithAliases.config.appId);
});
});

describe('applyPlayground', () => {
Expand Down Expand Up @@ -8325,6 +8372,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({
Expand Down
49 changes: 49 additions & 0 deletions spec/rest.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,55 @@ describe('read-only masterKey', () => {
});
});

describe('rest header aliases', () => {
it('supports REST requests with application-id header alias only', async () => {
await reconfigureServer({
headerAliases: {
'X-Parse-Application-Id': ['X-App-Id'],
},
});
try {
const response = await request({
url: `${Parse.serverURL}/schemas`,
method: 'GET',
headers: {
'X-App-Id': Parse.applicationId,
'X-Parse-Master-Key': Parse.masterKey,
},
});
expect(response.data.results).toBeDefined();
expect(Array.isArray(response.data.results)).toBe(true);
} finally {
await reconfigureServer();
}
});

it('supports /users/me with session-token header alias', async () => {
await reconfigureServer({
headerAliases: {
'X-Parse-Session-Token': ['X-Session-Token-Alias'],
},
});
try {
const username = `alias-rest-${Date.now()}`;
const user = await Parse.User.signUp(username, 'password');
const response = await request({
url: `${Parse.serverURL}/users/me`,
method: 'GET',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest',
'X-Session-Token-Alias': user.getSessionToken(),
},
});
expect(response.data.objectId).toBe(user.id);
expect(response.data.username).toBe(username);
} finally {
await reconfigureServer();
}
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('rest context', () => {
it('should support dependency injection on rest api', async () => {
const requestContextMiddleware = (req, res, next) => {
Expand Down
25 changes: 25 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export class Config {
readOnlyMasterKey,
readOnlyMasterKeyIps,
allowHeaders,
headerAliases,
idempotencyOptions,
fileUpload,
fileDownload,
Expand Down Expand Up @@ -181,6 +182,7 @@ export class Config {
this.validateDefaultLimit(defaultLimit);
this.validateMaxLimit(maxLimit);
this.validateAllowHeaders(allowHeaders);
this.validateHeaderAliases(headerAliases);
this.validateIdempotencyOptions(idempotencyOptions);
this.validatePagesOptions(pages);
this.validateSecurityOptions(security);
Expand Down Expand Up @@ -722,6 +724,29 @@ 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';
}
for (const [canonicalHeader, aliases] of Object.entries(headerAliases)) {
if (typeof canonicalHeader !== 'string' || !canonicalHeader.trim().length) {
throw 'Header aliases must contain non-empty string keys';
}
if (!Array.isArray(aliases)) {
throw `Header aliases for '${canonicalHeader}' must be an array`;
}
aliases.forEach(alias => {
if (typeof alias !== 'string') {
throw `Header aliases for '${canonicalHeader}' must only contain strings`;
} else if (!alias.trim().length) {
throw `Header aliases for '${canonicalHeader}' must not contain empty strings`;
}
});
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

static validateLogLevels(logLevels) {
for (const key of Object.keys(LogLevels)) {
if (logLevels[key]) {
Expand Down
17 changes: 15 additions & 2 deletions src/GraphQL/ParseGraphQLServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { expressMiddleware } from '@as-integrations/express5';
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
import express from 'express';
import { GraphQLError, parse } from 'graphql';
import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import {
allowCrossDomain,
getHeaderAliases,
handleHeaderAliases,
handleParseErrors,
handleParseHeaders,
handleParseSession,
} from '../middlewares';
import requiredParameter from '../requiredParameter';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
Expand Down Expand Up @@ -90,6 +97,10 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({

});

export const getCSRFRequestHeaders = headerAliases => {
return [...new Set(['X-Parse-Application-Id', ...getHeaderAliases(headerAliases, 'X-Parse-Application-Id')])];
};

class ParseGraphQLServer {
parseGraphQLController: ParseGraphQLController;

Expand Down Expand Up @@ -144,11 +155,12 @@ 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'],
requestHeaders: csrfRequestHeaders,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
// 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
Expand Down Expand Up @@ -203,6 +215,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);
Expand Down
5 changes: 5 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ module.exports.ParseServerOptions = {
env: 'PARSE_SERVER_GRAPH_QLSCHEMA',
help: 'Full path to your GraphQL custom schema.graphql file',
},
headerAliases: {
env: 'PARSE_SERVER_HEADER_ALIASES',
help: '(Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.<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.',
action: parsers.objectParser,
},
host: {
env: 'PARSE_SERVER_HOST',
help: 'The host to serve ParseServer on, defaults to 0.0.0.0',
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export interface ParseServerOptions {
appName: ?string;
/* Add headers to Access-Control-Allow-Headers */
allowHeaders: ?(string[]);
/* (Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.<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.
: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 */
Expand Down
1 change: 1 addition & 0 deletions src/ParseServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
Expand Down
1 change: 1 addition & 0 deletions src/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const DefinitionDefaults = Object.keys(ParseServerOptions).reduce((memo, key) =>
}, {});

const computedDefaults = {
headerAliases: {},
jsonLogs: process.env.JSON_LOGS || false,
logsFolder,
verbose,
Expand Down
Loading
Loading