From a40d82ccc6f9b6d9cb17818fdfd0973da8a0fbaa Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 5 Apr 2026 23:07:35 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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; } }); From 08ccc17330b508feef5bd2bf8eef112c543ac4b6 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 17 Jul 2026 11:20:36 +0200 Subject: [PATCH 06/10] fix: Harden headerAliases validation and honor first matching alias Reject unsafe/non-Parse canonical headers and resolve aliases by config order so canonical headers always win. --- spec/HeaderAliasesValidation.spec.js | 142 +++++++++++++++++++++++++-- spec/Middlewares.spec.js | 93 ++++++++++++++++++ spec/ParseGraphQLServer.spec.js | 39 ++++++++ spec/rest.spec.js | 50 ++++++++++ src/Config.js | 29 ++++++ src/middlewares.js | 24 +++-- 6 files changed, 356 insertions(+), 21 deletions(-) diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js index 42c1cfdfce..968bda48e7 100644 --- a/spec/HeaderAliasesValidation.spec.js +++ b/spec/HeaderAliasesValidation.spec.js @@ -17,10 +17,136 @@ describe('Config.validateHeaderAliases', () => { ).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 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 an alias that normalizes to the same value as its canonical header', () => { expect(() => Config.validateHeaderAliases({ - 'X-Foo': ['x-foo'], + 'X-Parse-Application-Id': ['x-parse-application-id'], }) ).toThrowError(/must not normalize to the same value as the canonical header name/); }); @@ -28,7 +154,7 @@ describe('Config.validateHeaderAliases', () => { it('should reject duplicate normalized aliases within the same aliases array', () => { expect(() => Config.validateHeaderAliases({ - 'X-A': ['foo', 'FOO'], + 'X-Parse-Application-Id': ['foo', 'FOO'], }) ).toThrowError(/Duplicate normalized header alias/); }); @@ -36,8 +162,8 @@ describe('Config.validateHeaderAliases', () => { it('should reject two canonical keys that normalize to the same value', () => { expect(() => Config.validateHeaderAliases({ - 'X-Foo': [], - 'x-foo': [], + 'X-Parse-Application-Id': [], + 'x-parse-application-id': [], }) ).toThrowError(/collides with.*after trim and lowercasing/); }); @@ -45,8 +171,8 @@ describe('Config.validateHeaderAliases', () => { it('should reject the same normalized alias used for two different canonical headers', () => { expect(() => Config.validateHeaderAliases({ - 'X-A': ['shared-alias'], - 'X-B': ['Shared-Alias'], + 'X-Parse-Application-Id': ['shared-alias'], + 'X-Parse-Session-Token': ['Shared-Alias'], }) ).toThrowError(/collides with alias/); }); @@ -54,8 +180,8 @@ describe('Config.validateHeaderAliases', () => { it('should reject an alias that normalizes to another canonical header key', () => { expect(() => Config.validateHeaderAliases({ - 'X-Parse-Bar': [], - 'X-Parse-Foo': ['x-parse-bar'], + 'X-Parse-Session-Token': [], + 'X-Parse-Application-Id': ['x-parse-session-token'], }) ).toThrowError(/collides with canonical header/); }); diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index d499ac3344..b5fb56b0a2 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -480,6 +480,99 @@ describe('middlewares', () => { }); }); + 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 reject CRLF aliases at validation time before they can be served', () => { + const Config = require('../lib/Config'); + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['X-Foo\r\nSet-Cookie: evil'], + }) + ).toThrowError(/contains invalid characters/); + }); + it('should resolve master key from configured alias in handleParseAuth', async () => { AppCachePut(fakeReq.body._ApplicationId, { headerAliases: { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 4aee07b544..28f6b39139 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -266,6 +266,45 @@ describe('ParseGraphQLServer', () => { 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); + }); }); describe('applyPlayground', () => { diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 6372cc3742..b271a1213f 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -1785,6 +1785,56 @@ describe('rest header aliases', () => { 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 { + const canonicalUser = await Parse.User.signUp(`alias-rest-canonical-${Date.now()}`, 'password'); + const aliasUser = await Parse.User.signUp(`alias-rest-alias-${Date.now()}`, '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-Parse-Session-Token': canonicalUser.getSessionToken(), + 'X-Session-Token-Alias': aliasUser.getSessionToken(), + }, + }); + expect(response.data.objectId).toBe(canonicalUser.id); + } 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 { + const firstAliasUser = await Parse.User.signUp(`alias-rest-a-${Date.now()}`, 'password'); + const secondAliasUser = await Parse.User.signUp(`alias-rest-b-${Date.now()}`, '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-B': secondAliasUser.getSessionToken(), + 'X-Session-Token-Alias-A': firstAliasUser.getSessionToken(), + }, + }); + expect(response.data.objectId).toBe(firstAliasUser.id); + } finally { + await reconfigureServer(); + } + }); }); describe('rest context', () => { diff --git a/src/Config.js b/src/Config.js index eb9d95de83..f20a54bd38 100644 --- a/src/Config.js +++ b/src/Config.js @@ -776,11 +776,34 @@ export class Config { if (Object.prototype.toString.call(headerAliases) !== '[object Object]') { throw 'Header aliases must be an object'; } + const SAFE_HEADER_NAME = /^[A-Za-z0-9-]+$/; + const ALLOWED_CANONICAL_HEADERS = new Set([ + 'x-parse-application-id', + 'x-parse-session-token', + 'x-parse-master-key', + 'x-parse-maintenance-key', + 'x-parse-installation-id', + 'x-parse-client-key', + 'x-parse-javascript-key', + 'x-parse-windows-key', + 'x-parse-rest-api-key', + ]); 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 (!ALLOWED_CANONICAL_HEADERS.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`; } @@ -790,6 +813,12 @@ export class Config { } 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` + ); + } }); } diff --git a/src/middlewares.js b/src/middlewares.js index 43603b4507..8b335e52f5 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -93,20 +93,18 @@ export function getHeaderAliases(headerAliases, canonicalHeader) { 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]) { - req.headers[targetHeader] = value; + for (const [canonicalHeader, aliases] of Object.entries(headerAliases || {})) { + const canonicalKey = String(canonicalHeader).trim().toLowerCase(); + if (req.headers[canonicalKey] !== undefined) { + continue; // canonical wins } - }); + const matchedAlias = (aliases || []).find( + alias => req.headers[String(alias).trim().toLowerCase()] !== undefined + ); + if (matchedAlias) { + req.headers[canonicalKey] = req.headers[String(matchedAlias).trim().toLowerCase()]; + } + } } export function handleHeaderAliases(appId) { From e193221ed41988dcd2a84a7f53a3c7d0a2f1139c Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Wed, 29 Jul 2026 17:29:41 +0200 Subject: [PATCH 07/10] fix: Disallow header aliases for master and maintenance keys Prevent credential-bearing Parse headers from being aliased so secrets cannot be smuggled under alternate names. --- spec/HeaderAliasesValidation.spec.js | 13 ++++++++++ spec/Middlewares.spec.js | 27 ++++++++++++++------- spec/ParseGraphQLServer.spec.js | 5 ++++ spec/rest.spec.js | 36 ++++++++++++++++++++-------- src/Config.js | 26 ++++++++++---------- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- src/middlewares.js | 7 +++++- types/Options/index.d.ts | 6 +++++ 10 files changed, 92 insertions(+), 34 deletions(-) diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js index 968bda48e7..5aab55a62f 100644 --- a/spec/HeaderAliasesValidation.spec.js +++ b/spec/HeaderAliasesValidation.spec.js @@ -143,6 +143,19 @@ describe('Config.validateHeaderAliases', () => { ).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({ diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index b5fb56b0a2..6e26517462 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -573,7 +573,21 @@ describe('middlewares', () => { ).toThrowError(/contains invalid characters/); }); - it('should resolve master key from configured alias in handleParseAuth', async () => { + it('should reject privileged master and maintenance key aliases at validation time', () => { + const Config = require('../lib/Config'); + 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 not rewrite master-key aliases when present in request headers', done => { AppCachePut(fakeReq.body._ApplicationId, { headerAliases: { 'X-Parse-Master-Key': ['X-Master-Key-Alias'], @@ -582,13 +596,10 @@ describe('middlewares', () => { 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); + 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', () => { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 28f6b39139..181327a329 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -127,6 +127,11 @@ describe('ParseGraphQLServer', () => { }); 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', () => { diff --git a/spec/rest.spec.js b/spec/rest.spec.js index b271a1213f..7012fa5445 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -1739,6 +1739,20 @@ 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: { @@ -1793,19 +1807,20 @@ describe('rest header aliases', () => { }, }); try { - const canonicalUser = await Parse.User.signUp(`alias-rest-canonical-${Date.now()}`, 'password'); - const aliasUser = await Parse.User.signUp(`alias-rest-alias-${Date.now()}`, 'password'); + // 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.getSessionToken(), - 'X-Session-Token-Alias': aliasUser.getSessionToken(), + 'X-Parse-Session-Token': canonicalUser.sessionToken, + 'X-Session-Token-Alias': aliasUser.sessionToken, }, }); - expect(response.data.objectId).toBe(canonicalUser.id); + expect(response.data.objectId).toBe(canonicalUser.objectId); } finally { await reconfigureServer(); } @@ -1818,19 +1833,20 @@ describe('rest header aliases', () => { }, }); try { - const firstAliasUser = await Parse.User.signUp(`alias-rest-a-${Date.now()}`, 'password'); - const secondAliasUser = await Parse.User.signUp(`alias-rest-b-${Date.now()}`, 'password'); + // 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.getSessionToken(), - 'X-Session-Token-Alias-A': firstAliasUser.getSessionToken(), + 'X-Session-Token-Alias-B': secondAliasUser.sessionToken, + 'X-Session-Token-Alias-A': firstAliasUser.sessionToken, }, }); - expect(response.data.objectId).toBe(firstAliasUser.id); + expect(response.data.objectId).toBe(firstAliasUser.objectId); } finally { await reconfigureServer(); } diff --git a/src/Config.js b/src/Config.js index f20a54bd38..c0a1301424 100644 --- a/src/Config.js +++ b/src/Config.js @@ -43,7 +43,20 @@ 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', +]); + export class Config { + static ALLOWED_HEADER_ALIAS_CANONICALS = ALLOWED_HEADER_ALIAS_CANONICALS; static get(applicationId: string, mount: string) { const cacheInfo = AppCache.get(applicationId); if (!cacheInfo) { @@ -777,17 +790,6 @@ export class Config { throw 'Header aliases must be an object'; } const SAFE_HEADER_NAME = /^[A-Za-z0-9-]+$/; - const ALLOWED_CANONICAL_HEADERS = new Set([ - 'x-parse-application-id', - 'x-parse-session-token', - 'x-parse-master-key', - 'x-parse-maintenance-key', - 'x-parse-installation-id', - 'x-parse-client-key', - 'x-parse-javascript-key', - 'x-parse-windows-key', - 'x-parse-rest-api-key', - ]); const entries = Object.entries(headerAliases); for (const [canonicalHeader, aliases] of entries) { if (typeof canonicalHeader !== 'string' || !canonicalHeader.trim().length) { @@ -799,7 +801,7 @@ export class Config { `Header aliases canonical '${canonicalHeader}' contains invalid characters` ); } - if (!ALLOWED_CANONICAL_HEADERS.has(trimmedCanonical.toLowerCase())) { + if (!Config.ALLOWED_HEADER_ALIAS_CANONICALS.has(trimmedCanonical.toLowerCase())) { throw new Error( `Header aliases canonical '${canonicalHeader}' is not an allowed Parse header` ); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 45e1b24f7f..176fb376af 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -318,7 +318,7 @@ module.exports.ParseServerOptions = { }, 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.', + help: '(Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. If the canonical header is not present in a request, Parse Server uses the first matching alias.

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

When setting this option via an environment variable, provide a JSON object string.', action: parsers.objectParser, }, host: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 634beb0d2e..3e725203d9 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -60,7 +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 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 {Object} headerAliases (Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. If the canonical header is not present in a request, Parse Server uses the first matching alias.

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

When setting this option via an environment variable, provide a JSON object string. * @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 * @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production. * @property {InstallationOptions} installation Options controlling how Parse Server deduplicates `_Installation` records that share the same `deviceToken`. diff --git a/src/Options/index.js b/src/Options/index.js index c9ab8feae6..77b76167ee 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -91,7 +91,7 @@ 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. + /* (Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. If the canonical header is not present in a request, Parse Server uses the first matching alias.

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

When setting this option via an environment variable, provide a JSON object string. :ENV: PARSE_SERVER_HEADER_ALIASES */ headerAliases: ?{ [string]: string[] }; /* Sets origins for Access-Control-Allow-Origin. This can be a string for a single origin or an array of strings for multiple origins. */ diff --git a/src/middlewares.js b/src/middlewares.js index 8b335e52f5..580e10974f 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -83,7 +83,7 @@ const mergeHeaders = (...headerSources) => { }; export function getHeaderAliases(headerAliases, canonicalHeader) { - const aliases = headerAliases[canonicalHeader]; + const aliases = headerAliases?.[canonicalHeader]; if (!Array.isArray(aliases)) { return []; } @@ -95,6 +95,11 @@ function applyHeaderAliases(req, headerAliases) { req.headers = req.headers || {}; for (const [canonicalHeader, aliases] of Object.entries(headerAliases || {})) { const canonicalKey = String(canonicalHeader).trim().toLowerCase(); + // Only rewrite allowlisted, non-secret Parse headers (one-to-one mapping + // and destination allowlist are also enforced in Config.validateHeaderAliases). + if (!Config.ALLOWED_HEADER_ALIAS_CANONICALS.has(canonicalKey)) { + continue; + } if (req.headers[canonicalKey] !== undefined) { continue; // canonical wins } diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index 41a3448836..c2169addd8 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -52,6 +52,12 @@ export interface ParseServerOptions { maintenanceKeyIps?: (string[]); appName?: string; allowHeaders?: (string[]); + /** + * Optional aliases for non-secret Parse request headers only + * (application ID, session token, installation ID, client/API keys). + * Credential-bearing headers such as X-Parse-Master-Key and + * X-Parse-Maintenance-Key cannot be aliased and are rejected at validation. + */ headerAliases?: { [headerName: string]: string[] }; allowOrigin?: StringOrStringArray; analyticsAdapter?: Adapter; From 798ece94915bab5b7b9b65d498fa854ef77a9147 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Wed, 29 Jul 2026 18:16:35 +0200 Subject: [PATCH 08/10] fix: Block CORS-safelisted header aliases to protect GraphQL CSRF Reject Accept/Range-style aliases at config time and skip them at rewrite so ambient browser headers cannot become application-id before Apollo CSRF checks; also match canonical alias keys case-insensitively. --- spec/HeaderAliasesValidation.spec.js | 10 ++++++ spec/Middlewares.spec.js | 47 +++++++++++++----------- spec/ParseGraphQLServer.spec.js | 54 ++++++++++++++++++++++++++++ src/Config.js | 18 ++++++++++ src/GraphQL/ParseGraphQLServer.js | 20 ++++------- src/middlewares.js | 17 ++++++--- 6 files changed, 127 insertions(+), 39 deletions(-) diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js index 5aab55a62f..6c5091a9f6 100644 --- a/spec/HeaderAliasesValidation.spec.js +++ b/spec/HeaderAliasesValidation.spec.js @@ -112,6 +112,16 @@ describe('Config.validateHeaderAliases', () => { ).toThrowError(/contains invalid characters/); }); + it('should reject CORS-safelisted request-header names and Range as aliases', () => { + for (const alias of ['Accept', 'accept-language', 'Content-Language', 'Content-Type', 'Range']) { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': [alias], + }) + ).toThrowError(/CORS-safelisted request header/); + } + }); + it('should reject a canonical header that contains invalid characters', () => { expect(() => Config.validateHeaderAliases({ diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 6e26517462..688520d50a 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -564,27 +564,32 @@ describe('middlewares', () => { }); }); - it('should reject CRLF aliases at validation time before they can be served', () => { - const Config = require('../lib/Config'); - expect(() => - Config.validateHeaderAliases({ - 'X-Parse-Application-Id': ['X-Foo\r\nSet-Cookie: evil'], - }) - ).toThrowError(/contains invalid characters/); - }); - - it('should reject privileged master and maintenance key aliases at validation time', () => { - const Config = require('../lib/Config'); - 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 not rewrite CORS-safelisted Accept into application-id', done => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Application-Id': ['Accept'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['accept'] = 'test'; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-application-id']).toBeUndefined(); + done(); + }); + }); + + it('should resolve aliases when canonical header key casing differs', done => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'x-parse-session-token': ['X-Session-Token-Alias'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-session-token-alias'] = 'session-token-alias-value'; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-session-token']).toEqual('session-token-alias-value'); + done(); + }); }); it('should not rewrite master-key aliases when present in request headers', done => { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 181327a329..aba2d0964c 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -113,6 +113,13 @@ describe('ParseGraphQLServer', () => { 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': [ @@ -310,6 +317,53 @@ describe('ParseGraphQLServer', () => { await new Promise(resolve => aliasMiddleware(req, {}, resolve)); expect(req.headers['x-parse-application-id']).toBe(parseServerWithAliases.config.appId); }); + + it('does not authorize GraphQL via Accept application-id alias before CSRF checks', async () => { + const AppCache = require('../lib/cache').default; + const parseServerWithAliases = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + }); + // Inject a blocked alias without re-validation to prove rewrite defense. + const cached = AppCache.get(parseServerWithAliases.config.appId); + AppCache.put(parseServerWithAliases.config.appId, { + ...cached, + headerAliases: { + 'X-Parse-Application-Id': ['Accept'], + }, + }); + const graphQLServerWithAliases = new ParseGraphQLServer(parseServerWithAliases, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + }); + const middlewares = require('../lib/middlewares'); + const useCalls = []; + const app = { + use: (...args) => { + useCalls.push(args); + }, + }; + graphQLServerWithAliases.applyGraphQL(app); + const parseHeadersIndex = useCalls.findIndex( + ([path, middleware]) => path === '/graphql' && middleware === middlewares.handleParseHeaders + ); + const [, aliasMiddleware] = useCalls[parseHeadersIndex - 1]; + const req = { + originalUrl: '/graphql', + url: '/graphql', + protocol: 'http', + headers: { + host: 'localhost', + accept: parseServerWithAliases.config.appId, + }, + get: key => req.headers[key.toLowerCase()], + }; + await new Promise(resolve => aliasMiddleware(req, {}, resolve)); + expect(req.headers['x-parse-application-id']).toBeUndefined(); + }); }); describe('applyPlayground', () => { diff --git a/src/Config.js b/src/Config.js index c0a1301424..9c1a3da5fd 100644 --- a/src/Config.js +++ b/src/Config.js @@ -55,8 +55,21 @@ const ALLOWED_HEADER_ALIAS_CANONICALS = new Set([ 'x-parse-rest-api-key', ]); +// CORS-safelisted request-header names (case-insensitive) plus Range. +// These must never be configured as aliases: browsers attach them ambiently, +// and rewriting them into Parse headers (especially application-id) before +// Apollo CSRF validation would let simple cross-site multipart requests pass. +const HEADER_ALIAS_CSRF_BLOCKLIST = new Set([ + 'accept', + 'accept-language', + 'content-language', + 'content-type', + 'range', +]); + export class Config { static ALLOWED_HEADER_ALIAS_CANONICALS = ALLOWED_HEADER_ALIAS_CANONICALS; + static HEADER_ALIAS_CSRF_BLOCKLIST = HEADER_ALIAS_CSRF_BLOCKLIST; static get(applicationId: string, mount: string) { const cacheInfo = AppCache.get(applicationId); if (!cacheInfo) { @@ -821,6 +834,11 @@ export class Config { `Header alias '${alias}' for canonical header '${canonicalHeader}' contains invalid characters` ); } + if (HEADER_ALIAS_CSRF_BLOCKLIST.has(trimmedAlias.toLowerCase())) { + throw new Error( + `Header alias '${alias}' for canonical header '${canonicalHeader}' is a CORS-safelisted request header and cannot be used as an alias` + ); + } }); } diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 6fe0aea4c3..c21b45e37c 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -14,6 +14,7 @@ import { } 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'; @@ -97,23 +98,14 @@ 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', -]); - +// Aliases matching CORS-safelisted names / Range must not appear on Apollo's +// CSRF requestHeaders list. Config.validateHeaderAliases and applyHeaderAliases +// also reject/skip these names so they cannot be rewritten into application-id +// before Apollo's CSRF check. export const getCSRFRequestHeaders = headerAliases => { const aliases = getHeaderAliases(headerAliases, 'X-Parse-Application-Id'); const safeAliases = aliases.filter( - alias => !APOLLO_CSRF_ALIAS_BLOCKLIST.has(alias.trim().toLowerCase()) + alias => !Config.HEADER_ALIAS_CSRF_BLOCKLIST.has(alias.trim().toLowerCase()) ); return [...new Set(['X-Parse-Application-Id', ...safeAliases])]; }; diff --git a/src/middlewares.js b/src/middlewares.js index 580e10974f..1e4e57d3cb 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -83,7 +83,11 @@ const mergeHeaders = (...headerSources) => { }; export function getHeaderAliases(headerAliases, canonicalHeader) { - const aliases = 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 []; } @@ -103,9 +107,14 @@ function applyHeaderAliases(req, headerAliases) { if (req.headers[canonicalKey] !== undefined) { continue; // canonical wins } - const matchedAlias = (aliases || []).find( - alias => req.headers[String(alias).trim().toLowerCase()] !== undefined - ); + const matchedAlias = (aliases || []).find(alias => { + const aliasKey = String(alias).trim().toLowerCase(); + // Never rewrite CORS-safelisted / Range headers (GraphQL CSRF defense). + if (Config.HEADER_ALIAS_CSRF_BLOCKLIST.has(aliasKey)) { + return false; + } + return req.headers[aliasKey] !== undefined; + }); if (matchedAlias) { req.headers[canonicalKey] = req.headers[String(matchedAlias).trim().toLowerCase()]; } From 848ec3f865a91d1170001fca0258886d04ef51a6 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 11 Sep 2026 11:17:27 +0200 Subject: [PATCH 09/10] fix: Enforce one-to-one header alias mapping Reject reserved Parse header names as aliases and prevent a single alias from populating multiple canonical headers. --- spec/HeaderAliasesValidation.spec.js | 34 ++++- spec/Middlewares.spec.js | 211 +++++++++++++++++---------- src/Config.js | 17 ++- src/middlewares.js | 91 ++++++++---- 4 files changed, 244 insertions(+), 109 deletions(-) diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js index 6c5091a9f6..ae9ada18cd 100644 --- a/spec/HeaderAliasesValidation.spec.js +++ b/spec/HeaderAliasesValidation.spec.js @@ -113,7 +113,13 @@ describe('Config.validateHeaderAliases', () => { }); it('should reject CORS-safelisted request-header names and Range as aliases', () => { - for (const alias of ['Accept', 'accept-language', 'Content-Language', 'Content-Type', 'Range']) { + for (const alias of [ + 'Accept', + 'accept-language', + 'Content-Language', + 'Content-Type', + 'Range', + ]) { expect(() => Config.validateHeaderAliases({ 'X-Parse-Application-Id': [alias], @@ -208,4 +214,30 @@ describe('Config.validateHeaderAliases', () => { }) ).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 688520d50a..91d540e3b9 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; @@ -592,6 +607,54 @@ describe('middlewares', () => { }); }); + 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: { diff --git a/src/Config.js b/src/Config.js index 9c1a3da5fd..c75172d673 100644 --- a/src/Config.js +++ b/src/Config.js @@ -55,6 +55,14 @@ const ALLOWED_HEADER_ALIAS_CANONICALS = new Set([ '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. // These must never be configured as aliases: browsers attach them ambiently, // and rewriting them into Parse headers (especially application-id) before @@ -69,6 +77,7 @@ const HEADER_ALIAS_CSRF_BLOCKLIST = new Set([ 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 get(applicationId: string, mount: string) { const cacheInfo = AppCache.get(applicationId); @@ -871,11 +880,11 @@ export class Config { `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) { + if (RESERVED_HEADER_ALIAS_NAMES.has(normAlias)) { throw new Error( - `Header alias '${alias}' for canonical header '${canonicalHeader}' collides with canonical header '${canonicalNormToKey.get( - normAlias - )}'.` + `Header alias '${alias}' for canonical header '${canonicalHeader}' collides with canonical header '${ + canonicalNormToKey.get(normAlias) || alias + }'.` ); } if (seenInArray.has(normAlias)) { diff --git a/src/middlewares.js b/src/middlewares.js index 1e4e57d3cb..5493a0f1d7 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 @@ -73,12 +81,14 @@ const getHeaderList = headers => // 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)); + 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)]; }; @@ -97,6 +107,7 @@ export function getHeaderAliases(headerAliases, canonicalHeader) { 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 @@ -104,19 +115,33 @@ function applyHeaderAliases(req, headerAliases) { if (!Config.ALLOWED_HEADER_ALIAS_CANONICALS.has(canonicalKey)) { continue; } - if (req.headers[canonicalKey] !== undefined) { - continue; // canonical wins + 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 / Range headers (GraphQL CSRF defense). + if (Config.HEADER_ALIAS_CSRF_BLOCKLIST.has(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]; + } } - const matchedAlias = (aliases || []).find(alias => { - const aliasKey = String(alias).trim().toLowerCase(); - // Never rewrite CORS-safelisted / Range headers (GraphQL CSRF defense). - if (Config.HEADER_ALIAS_CSRF_BLOCKLIST.has(aliasKey)) { - return false; + // Claim every alias for this canonical so a later mapping cannot reuse it. + for (const aliasKey of normalizedAliases) { + if (aliasKey) { + claimedAliases.add(aliasKey); } - return req.headers[aliasKey] !== undefined; - }); - if (matchedAlias) { - req.headers[canonicalKey] = req.headers[String(matchedAlias).trim().toLowerCase()]; } } } @@ -425,7 +450,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; @@ -471,7 +498,9 @@ export function allowCrossDomain(appId) { ).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]; @@ -665,8 +694,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) { @@ -749,10 +777,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; @@ -777,7 +807,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, From 2cd07160ecc331e59769987721a8cf931948f7d8 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 11 Sep 2026 12:32:44 +0200 Subject: [PATCH 10/10] fix: Block browser-generated header aliases from GraphQL CSRF Reject Origin/Cookie/Referer/User-Agent and sec-/proxy- prefixes as aliases, and stop describing session tokens as non-secret. --- spec/HeaderAliasesValidation.spec.js | 20 +++++++++ spec/Middlewares.spec.js | 14 +++++++ spec/ParseGraphQLServer.spec.js | 62 ++++++++++++++++++++++++++++ src/Config.js | 31 +++++++++++--- src/GraphQL/ParseGraphQLServer.js | 13 +++--- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- src/middlewares.js | 5 ++- types/Options/index.d.ts | 7 ++-- 10 files changed, 137 insertions(+), 21 deletions(-) diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js index ae9ada18cd..22b54ad6d8 100644 --- a/spec/HeaderAliasesValidation.spec.js +++ b/spec/HeaderAliasesValidation.spec.js @@ -128,6 +128,26 @@ describe('Config.validateHeaderAliases', () => { } }); + 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({ diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index 91d540e3b9..6a2d6a3f2e 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -593,6 +593,20 @@ describe('middlewares', () => { }); }); + 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: { diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index aba2d0964c..dd4daea57e 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -135,6 +135,21 @@ describe('ParseGraphQLServer', () => { 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']); @@ -364,6 +379,53 @@ describe('ParseGraphQLServer', () => { 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', () => { diff --git a/src/Config.js b/src/Config.js index c75172d673..213e8a3c3d 100644 --- a/src/Config.js +++ b/src/Config.js @@ -63,22 +63,41 @@ const RESERVED_HEADER_ALIAS_NAMES = new Set([ 'x-parse-maintenance-key', ]); -// CORS-safelisted request-header names (case-insensitive) plus Range. -// These must never be configured as aliases: browsers attach them ambiently, -// and rewriting them into Parse headers (especially application-id) before -// Apollo CSRF validation would let simple cross-site multipart requests pass. +// 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) { @@ -843,9 +862,9 @@ export class Config { `Header alias '${alias}' for canonical header '${canonicalHeader}' contains invalid characters` ); } - if (HEADER_ALIAS_CSRF_BLOCKLIST.has(trimmedAlias.toLowerCase())) { + if (isCsrfBlockedAlias(trimmedAlias)) { throw new Error( - `Header alias '${alias}' for canonical header '${canonicalHeader}' is a CORS-safelisted request header and cannot be used as an alias` + `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` ); } }); diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index c21b45e37c..be96bd6426 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -98,15 +98,14 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({ }); -// Aliases matching CORS-safelisted names / Range must not appear on Apollo's -// CSRF requestHeaders list. Config.validateHeaderAliases and applyHeaderAliases -// also reject/skip these names so they cannot be rewritten into application-id -// before Apollo's CSRF check. +// 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.HEADER_ALIAS_CSRF_BLOCKLIST.has(alias.trim().toLowerCase()) - ); + const safeAliases = aliases.filter(alias => !Config.isCsrfBlockedAlias(alias)); return [...new Set(['X-Parse-Application-Id', ...safeAliases])]; }; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 176fb376af..71cf5c59ab 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -318,7 +318,7 @@ module.exports.ParseServerOptions = { }, headerAliases: { env: 'PARSE_SERVER_HEADER_ALIASES', - help: '(Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. If the canonical header is not present in a request, Parse Server uses the first matching alias.

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

When setting this option via an environment variable, provide a JSON object string.', + 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: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 3e725203d9..f6dd1e64f3 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -60,7 +60,7 @@ * @property {String} graphQLPath The mount path for the GraphQL endpoint

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

Defaults is `/graphql`. * @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file - * @property {Object} headerAliases (Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. If the canonical header is not present in a request, Parse Server uses the first matching alias.

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

When setting this option via an environment variable, provide a JSON object string. + * @property {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 77b76167ee..5f78128071 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -91,7 +91,7 @@ export interface ParseServerOptions { appName: ?string; /* Add headers to Access-Control-Allow-Headers */ allowHeaders: ?(string[]); - /* (Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are only supported for non-secret headers (application ID, session token, installation ID, and client/API keys). Credential-bearing headers such as X-Parse-Master-Key and X-Parse-Maintenance-Key cannot be aliased. If the canonical header is not present in a request, Parse Server uses the first matching alias.

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

When setting this option via an environment variable, provide a JSON object string. + /* (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. */ diff --git a/src/middlewares.js b/src/middlewares.js index 5493a0f1d7..05dbbde3e3 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -123,8 +123,9 @@ function applyHeaderAliases(req, headerAliases) { if (!aliasKey || claimedAliases.has(aliasKey)) { return false; } - // Never rewrite CORS-safelisted / Range headers (GraphQL CSRF defense). - if (Config.HEADER_ALIAS_CSRF_BLOCKLIST.has(aliasKey)) { + // 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. diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index c2169addd8..2f7fd9316f 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -53,10 +53,11 @@ export interface ParseServerOptions { appName?: string; allowHeaders?: (string[]); /** - * Optional aliases for non-secret Parse request headers only + * Optional aliases for Parse request headers * (application ID, session token, installation ID, client/API keys). - * Credential-bearing headers such as X-Parse-Master-Key and - * X-Parse-Maintenance-Key cannot be aliased and are rejected at validation. + * 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;