From a40d82ccc6f9b6d9cb17818fdfd0973da8a0fbaa Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 5 Apr 2026 23:07:35 +0200 Subject: [PATCH 1/5] feat: header alias --- spec/Middlewares.spec.js | 71 +++++++++++++++++++++++++++ spec/ParseGraphQLServer.spec.js | 80 ++++++++++++++++++++++++++++++- spec/rest.spec.js | 49 +++++++++++++++++++ src/Config.js | 25 ++++++++++ src/GraphQL/ParseGraphQLServer.js | 17 ++++++- src/Options/Definitions.js | 5 ++ src/Options/docs.js | 1 + src/Options/index.js | 3 ++ src/ParseServer.ts | 1 + src/defaults.js | 1 + src/middlewares.js | 62 ++++++++++++++++++++++-- types/Options/index.d.ts | 1 + 12 files changed, 309 insertions(+), 7 deletions(-) diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index d05a56970b..2a287da488 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -339,6 +339,25 @@ describe('middlewares', () => { expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS); }); + it('should append configured header aliases to Access-Control-Allow-Headers', () => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain('X-App-Id'); + expect(headers['Access-Control-Allow-Headers']).toContain('X-Session-Token-Alias'); + }); + it('should set default Access-Control-Allow-Origin if allowOrigin is empty', () => { AppCachePut(fakeReq.body._ApplicationId, { allowOrigin: undefined, @@ -409,6 +428,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', diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index c0189433ef..8a3aa5bfce 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -28,7 +28,7 @@ const { GraphQLList, } = require('graphql'); const { ParseServer } = require('../'); -const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); +const { ParseGraphQLServer, getCSRFRequestHeaders } = require('../lib/GraphQL/ParseGraphQLServer'); const { ReadPreference, Collection } = require('mongodb'); let uuidv4; @@ -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', () => { @@ -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', () => { @@ -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({ diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 9416d9230e..6372cc3742 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -1738,6 +1738,55 @@ describe('read-only masterKey', () => { }); }); +describe('rest header aliases', () => { + it('supports REST requests with application-id header alias only', async () => { + await reconfigureServer({ + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + }); + try { + const response = await request({ + url: `${Parse.serverURL}/schemas`, + method: 'GET', + headers: { + 'X-App-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + expect(response.data.results).toBeDefined(); + expect(Array.isArray(response.data.results)).toBe(true); + } finally { + await reconfigureServer(); + } + }); + + it('supports /users/me with session-token header alias', async () => { + await reconfigureServer({ + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + }); + try { + const username = `alias-rest-${Date.now()}`; + const user = await Parse.User.signUp(username, 'password'); + const response = await request({ + url: `${Parse.serverURL}/users/me`, + method: 'GET', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Session-Token-Alias': user.getSessionToken(), + }, + }); + expect(response.data.objectId).toBe(user.id); + expect(response.data.username).toBe(username); + } finally { + await reconfigureServer(); + } + }); +}); + describe('rest context', () => { it('should support dependency injection on rest api', async () => { const requestContextMiddleware = (req, res, next) => { diff --git a/src/Config.js b/src/Config.js index c5a6a6593e..9302ab0eef 100644 --- a/src/Config.js +++ b/src/Config.js @@ -129,6 +129,7 @@ export class Config { readOnlyMasterKey, readOnlyMasterKeyIps, allowHeaders, + headerAliases, idempotencyOptions, fileUpload, fileDownload, @@ -181,6 +182,7 @@ export class Config { this.validateDefaultLimit(defaultLimit); this.validateMaxLimit(maxLimit); this.validateAllowHeaders(allowHeaders); + this.validateHeaderAliases(headerAliases); this.validateIdempotencyOptions(idempotencyOptions); this.validatePagesOptions(pages); this.validateSecurityOptions(security); @@ -722,6 +724,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`; + } + }); + } + } + } + static validateLogLevels(logLevels) { for (const key of Object.keys(LogLevels)) { if (logLevels[key]) { diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 0b2c17d232..b82776b489 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -4,7 +4,14 @@ import { expressMiddleware } from '@as-integrations/express5'; import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; import express from 'express'; import { GraphQLError, parse } from 'graphql'; -import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; +import { + allowCrossDomain, + getHeaderAliases, + handleHeaderAliases, + handleParseErrors, + handleParseHeaders, + handleParseSession, +} from '../middlewares'; import requiredParameter from '../requiredParameter'; import defaultLogger from '../logger'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; @@ -90,6 +97,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; @@ -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, }, // We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable // we delegate the introspection control to the IntrospectionControlPlugin @@ -203,6 +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); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 8c4f7e4a89..33b4ee79a6 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -310,6 +310,11 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_GRAPH_QLSCHEMA', help: 'Full path to your GraphQL custom schema.graphql file', }, + headerAliases: { + env: 'PARSE_SERVER_HEADER_ALIASES', + help: '(Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.

Example:
`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`

When setting this option via an environment variable, provide a JSON object string.', + action: parsers.objectParser, + }, host: { env: 'PARSE_SERVER_HOST', help: 'The host to serve ParseServer on, defaults to 0.0.0.0', diff --git a/src/Options/docs.js b/src/Options/docs.js index 09f9ef1852..ab0cdae8e1 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -59,6 +59,7 @@ * @property {String} graphQLPath The mount path for the GraphQL endpoint

⚠️ File upload inside the GraphQL mutation system requires Parse Server to be able to call itself by making requests to the URL set in `serverURL`.

Defaults is `/graphql`. * @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file + * @property {Object} headerAliases (Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.

Example:
`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`

When setting this option via an environment variable, provide a JSON object string. * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. * @property {String} javascriptKey Key for the Javascript SDK diff --git a/src/Options/index.js b/src/Options/index.js index e495b6fe79..13c9235503 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -91,6 +91,9 @@ export interface ParseServerOptions { appName: ?string; /* Add headers to Access-Control-Allow-Headers */ allowHeaders: ?(string[]); + /* (Optional) Define aliases for Parse request headers. For each canonical Parse header, set an array of accepted alias headers. If the canonical header is not present in a request, Parse Server uses the first matching alias.

Example:
`{ "X-Parse-Application-Id": ["X-App-Id"], "X-Parse-Session-Token": ["X-Session-Token"] }`

When setting this option via an environment variable, provide a JSON object string. + :ENV: PARSE_SERVER_HEADER_ALIASES */ + headerAliases: ?{ [string]: string[] }; /* Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. */ allowOrigin: ?StringOrStringArray; /* Adapter module for the analytics */ diff --git a/src/ParseServer.ts b/src/ParseServer.ts index 65d537ae68..128093863b 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -311,6 +311,7 @@ class ParseServer { //api.use("/apps", express.static(__dirname + "/public")); api.use(middlewares.allowCrossDomain(appId)); api.use(middlewares.allowDoubleForwardSlash); + api.use(middlewares.handleHeaderAliases(appId)); api.use(middlewares.handleParseAuth(appId)); // File handling needs to be before the default JSON body parser because file // uploads send binary data that should not be parsed as JSON. diff --git a/src/defaults.js b/src/defaults.js index b7d05f1550..b042cae53e 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -25,6 +25,7 @@ const DefinitionDefaults = Object.keys(ParseServerOptions).reduce((memo, key) => }, {}); const computedDefaults = { + headerAliases: {}, jsonLogs: process.env.JSON_LOGS || false, logsFolder, verbose, diff --git a/src/middlewares.js b/src/middlewares.js index 3c55278f33..5604062893 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -65,6 +65,59 @@ export const checkIp = (ip, ipRangeList, store) => { return result; }; +// Build a clean list of headers +const getHeaderList = headers => + headers + .split(',') + .map(header => header.trim()) + .filter(Boolean); + +// Merge all headers into a single list +const mergeHeaders = (...headerSources) => { + const reduced = headerSources.reduce((acc, source) => { + const headers = Array.isArray(source) ? source : getHeaderList(source || ''); + const trimmedHeaders = headers.map(header => header.trim()); + acc.push(...trimmedHeaders); + return acc; + }, []).filter(header => Boolean(header)); + return [...new Set(reduced)]; +}; + +export function getHeaderAliases(headerAliases, canonicalHeader) { + const aliases = headerAliases[canonicalHeader]; + if (!Array.isArray(aliases)) { + return []; + } + // Clean up the aliases and remove any empty strings + return aliases.map(alias => alias.trim()).filter(Boolean); +} + +function applyHeaderAliases(req, headerAliases) { + req.headers = req.headers || {}; + const indexHeaderByAlias = Object.fromEntries( + Object.entries(headerAliases) + .map(([source, aliases]) => + aliases + .map(alias => [alias.toLowerCase(), source.toLowerCase()]) + ) + .flat() + ); + Object.entries(req.headers).forEach(([header, value]) => { + const targetHeader = indexHeaderByAlias[header.toLowerCase()]; + if (targetHeader) { + req.headers[targetHeader] = value; + } + }); +} + +export function handleHeaderAliases(appId) { + return (req, res, next) => { + const config = Config.get(appId, getMountForRequest(req)); + applyHeaderAliases(req, config?.headerAliases); + next(); + }; +} + // Checks that the request is authorized for this app and checks user // auth too. // The bodyparser should run before this middleware. @@ -411,10 +464,11 @@ function decodeBase64(str) { export function allowCrossDomain(appId) { return (req, res, next) => { const config = Config.get(appId, getMountForRequest(req)); - let allowHeaders = DEFAULT_ALLOWED_HEADERS; - if (config && config.allowHeaders) { - allowHeaders += `, ${config.allowHeaders.join(', ')}`; - } + const allowHeaders = mergeHeaders( + DEFAULT_ALLOWED_HEADERS, + mergeHeaders(...Object.values(config?.headerAliases || {})), + config?.allowHeaders + ).join(', '); const baseOrigins = typeof config?.allowOrigin === 'string' ? [config.allowOrigin] : config?.allowOrigin ?? ['*']; diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index 6a8b1494ac..41a3448836 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -52,6 +52,7 @@ export interface ParseServerOptions { maintenanceKeyIps?: (string[]); appName?: string; allowHeaders?: (string[]); + headerAliases?: { [headerName: string]: string[] }; allowOrigin?: StringOrStringArray; analyticsAdapter?: Adapter; filesAdapter?: Adapter; From f41d2cb4892af095bae5eed573c774f8efb9c01e Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Mon, 6 Apr 2026 12:10:46 +0200 Subject: [PATCH 2/5] fix: option validation --- spec/HeaderAliasesValidation.spec.js | 62 ++++++++++++++++++++++++++++ src/Config.js | 56 ++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 spec/HeaderAliasesValidation.spec.js diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js new file mode 100644 index 0000000000..42c1cfdfce --- /dev/null +++ b/spec/HeaderAliasesValidation.spec.js @@ -0,0 +1,62 @@ +'use strict'; + +const Config = require('../lib/Config'); + +describe('Config.validateHeaderAliases', () => { + it('should accept null and undefined', () => { + expect(() => Config.validateHeaderAliases(null)).not.toThrow(); + expect(() => Config.validateHeaderAliases(undefined)).not.toThrow(); + }); + + it('should accept a valid headerAliases object', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['X-App-Id'], + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }) + ).not.toThrow(); + }); + + it('should reject an alias that normalizes to the same value as its canonical header', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Foo': ['x-foo'], + }) + ).toThrowError(/must not normalize to the same value as the canonical header name/); + }); + + it('should reject duplicate normalized aliases within the same aliases array', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-A': ['foo', 'FOO'], + }) + ).toThrowError(/Duplicate normalized header alias/); + }); + + it('should reject two canonical keys that normalize to the same value', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Foo': [], + 'x-foo': [], + }) + ).toThrowError(/collides with.*after trim and lowercasing/); + }); + + it('should reject the same normalized alias used for two different canonical headers', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-A': ['shared-alias'], + 'X-B': ['Shared-Alias'], + }) + ).toThrowError(/collides with alias/); + }); + + it('should reject an alias that normalizes to another canonical header key', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Bar': [], + 'X-Parse-Foo': ['x-parse-bar'], + }) + ).toThrowError(/collides with canonical header/); + }); +}); diff --git a/src/Config.js b/src/Config.js index 9302ab0eef..f12cb362c1 100644 --- a/src/Config.js +++ b/src/Config.js @@ -729,7 +729,8 @@ export class Config { if (Object.prototype.toString.call(headerAliases) !== '[object Object]') { throw 'Header aliases must be an object'; } - for (const [canonicalHeader, aliases] of Object.entries(headerAliases)) { + 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'; } @@ -744,6 +745,59 @@ export class Config { } }); } + + const normalizeHeaderAliasIdentifier = s => s.trim().toLowerCase(); + + const canonicalNormToKey = new Map(); + for (const [canonicalHeader] of entries) { + const norm = normalizeHeaderAliasIdentifier(canonicalHeader); + if (canonicalNormToKey.has(norm)) { + throw new Error( + `Header aliases canonical '${canonicalHeader}' collides with '${canonicalNormToKey.get( + norm + )}' after trim and lowercasing.` + ); + } + canonicalNormToKey.set(norm, canonicalHeader); + } + + const globalAliasNorm = new Map(); + + for (const [canonicalHeader, aliases] of entries) { + const normCanon = normalizeHeaderAliasIdentifier(canonicalHeader); + const seenInArray = new Set(); + + for (const alias of aliases) { + const normAlias = normalizeHeaderAliasIdentifier(alias); + + if (normAlias === normCanon) { + throw new Error( + `Header alias '${alias}' for canonical header '${canonicalHeader}' must not normalize to the same value as the canonical header name.` + ); + } + if (canonicalNormToKey.has(normAlias) && normAlias !== normCanon) { + throw new Error( + `Header alias '${alias}' for canonical header '${canonicalHeader}' collides with canonical header '${canonicalNormToKey.get( + normAlias + )}'.` + ); + } + if (seenInArray.has(normAlias)) { + throw new Error( + `Duplicate normalized header alias '${alias}' for canonical header '${canonicalHeader}'.` + ); + } + seenInArray.add(normAlias); + + if (globalAliasNorm.has(normAlias)) { + const prev = globalAliasNorm.get(normAlias); + throw new Error( + `Header alias '${alias}' for canonical header '${canonicalHeader}' collides with alias '${prev.alias}' for canonical header '${prev.canonicalHeader}'.` + ); + } + globalAliasNorm.set(normAlias, { canonicalHeader, alias }); + } + } } } From 74a6abb61c7ce03867c6726eccf57d03fc0b8088 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Mon, 6 Apr 2026 12:16:29 +0200 Subject: [PATCH 3/5] fix: undefined header alias --- spec/Middlewares.spec.js | 21 +++++++++++++++++++++ src/middlewares.js | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 2a287da488..d3f1f12bcc 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -480,6 +480,27 @@ describe('middlewares', () => { expect(fakeReq.auth.isMaster).toBe(true); }); + it('should call next without throwing when app is not in AppCache', () => { + const next = jasmine.createSpy('next'); + middlewares.handleHeaderAliases('NotInCacheAppId')(fakeReq, fakeRes, next); + expect(next).toHaveBeenCalled(); + }); + + it('should call next without throwing when headerAliases is missing or null', done => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['0.0.0.0/0'], + }); + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['0.0.0.0/0'], + headerAliases: null, + }); + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, done); + }); + }); + it('should give invalid response when upload file without x-parse-application-id in header', () => { AppCachePut(fakeReq.body._ApplicationId, { masterKey: 'masterKey', diff --git a/src/middlewares.js b/src/middlewares.js index 5604062893..8d19a2b204 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -113,7 +113,7 @@ function applyHeaderAliases(req, headerAliases) { export function handleHeaderAliases(appId) { return (req, res, next) => { const config = Config.get(appId, getMountForRequest(req)); - applyHeaderAliases(req, config?.headerAliases); + applyHeaderAliases(req, config?.headerAliases || {}); next(); }; } From 0bdbc305fc08448a3c6d6a4d0ed3b3fef98faf09 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Mon, 6 Apr 2026 12:30:29 +0200 Subject: [PATCH 4/5] fix: upload headers --- spec/ParseGraphQLServer.spec.js | 30 ++++++++++++++++++++++++------ src/GraphQL/ParseGraphQLServer.js | 22 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 8a3aa5bfce..049785efdb 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -105,6 +105,30 @@ describe('ParseGraphQLServer', () => { }); }); + describe('getCSRFRequestHeaders', () => { + it('should include safe application-id header aliases with canonical header', () => { + const headers = getCSRFRequestHeaders({ + 'X-Parse-Application-Id': ['X-App-Id', 'X-Client-App'], + }); + expect(headers).toEqual(['X-Parse-Application-Id', 'X-App-Id', 'X-Client-App']); + }); + + it('should exclude CORS-safelisted request-header names and Range from CSRF whitelist', () => { + const headers = getCSRFRequestHeaders({ + 'X-Parse-Application-Id': [ + 'Accept', + 'accept-language', + 'Content-Language', + 'Content-Type', + 'Range', + 'rAnGe', + 'X-Safe-Custom', + ], + }); + expect(headers).toEqual(['X-Parse-Application-Id', 'X-Safe-Custom']); + }); + }); + describe('_getServer', () => { it('should only return new server on schema changes', async () => { parseGraphQLServer.server = undefined; @@ -136,12 +160,6 @@ describe('ParseGraphQLServer', () => { }); }); - 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', () => { diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index b82776b489..df6cdfcf43 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -97,8 +97,25 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({ }); +// Fetch no-CORS-safelisted request-header names (case-insensitive) plus Range, which +// can also be CORS-safelisted for certain values. Apollo preventCsrf treats any +// whitelisted header with a non-empty value as sufficient for multipart/simple +// bodies; aliases that match these names must not be listed or browsers could +// satisfy CSRF with ambient headers. +const APOLLO_CSRF_ALIAS_BLOCKLIST = new Set([ + 'accept', + 'accept-language', + 'content-language', + 'content-type', + 'range', +]); + export const getCSRFRequestHeaders = headerAliases => { - return [...new Set(['X-Parse-Application-Id', ...getHeaderAliases(headerAliases, 'X-Parse-Application-Id')])]; + const aliases = getHeaderAliases(headerAliases, 'X-Parse-Application-Id'); + const safeAliases = aliases.filter( + alias => !APOLLO_CSRF_ALIAS_BLOCKLIST.has(alias.trim().toLowerCase()) + ); + return [...new Set(['X-Parse-Application-Id', ...safeAliases])]; }; class ParseGraphQLServer { @@ -159,7 +176,8 @@ class ParseGraphQLServer { const apollo = new ApolloServer({ csrfPrevention: { // See https://www.apollographql.com/docs/router/configuration/csrf/ - // needed since we use graphql upload + // 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 From 4a87fa9333536bb278c88786eebcf46b0137c1cf Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Mon, 6 Apr 2026 12:44:09 +0200 Subject: [PATCH 5/5] fix: prefer canonical --- spec/Middlewares.spec.js | 19 +++++++++++++++++++ src/middlewares.js | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index d3f1f12bcc..004f3e10f9 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -462,6 +462,25 @@ describe('middlewares', () => { }); }); + it('should prefer canonical session token over alias when both headers are present', done => { + const canonicalToken = 'session-token-canonical'; + const aliasToken = 'session-token-alias-value'; + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-parse-session-token'] = canonicalToken; + fakeReq.headers['x-session-token-alias'] = aliasToken; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual(canonicalToken); + done(); + }); + }); + }); + it('should resolve master key from configured alias in handleParseAuth', async () => { AppCachePut(fakeReq.body._ApplicationId, { headerAliases: { diff --git a/src/middlewares.js b/src/middlewares.js index 8d19a2b204..355d0c60c0 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -104,7 +104,7 @@ function applyHeaderAliases(req, headerAliases) { ); Object.entries(req.headers).forEach(([header, value]) => { const targetHeader = indexHeaderByAlias[header.toLowerCase()]; - if (targetHeader) { + if (targetHeader && !req.headers[targetHeader]) { req.headers[targetHeader] = value; } });