diff --git a/spec/HeaderAliasesValidation.spec.js b/spec/HeaderAliasesValidation.spec.js new file mode 100644 index 0000000000..6c5091a9f6 --- /dev/null +++ b/spec/HeaderAliasesValidation.spec.js @@ -0,0 +1,211 @@ +'use strict'; + +const Config = require('../lib/Config'); + +describe('Config.validateHeaderAliases', () => { + it('should accept null and undefined', () => { + expect(() => Config.validateHeaderAliases(null)).not.toThrow(); + expect(() => Config.validateHeaderAliases(undefined)).not.toThrow(); + }); + + it('should accept a valid headerAliases object', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['X-App-Id'], + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }) + ).not.toThrow(); + }); + + it('should accept an empty aliases array for an allowlisted canonical header', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': [], + }) + ).not.toThrow(); + }); + + it('should accept aliases with trimable surrounding whitespace', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': [' X-App-Id '], + }) + ).not.toThrow(); + }); + + it('should accept differently cased allowlisted canonical headers', () => { + expect(() => + Config.validateHeaderAliases({ + 'x-parse-application-id': ['X-App-Id'], + }) + ).not.toThrow(); + }); + + it('should reject a non-object headerAliases value', () => { + expect(() => Config.validateHeaderAliases([])).toThrow('Header aliases must be an object'); + expect(() => Config.validateHeaderAliases('bad')).toThrow('Header aliases must be an object'); + expect(() => Config.validateHeaderAliases(1)).toThrow('Header aliases must be an object'); + }); + + it('should reject empty or whitespace-only canonical keys', () => { + expect(() => Config.validateHeaderAliases({ '': ['X-App-Id'] })).toThrow( + 'Header aliases must contain non-empty string keys' + ); + expect(() => Config.validateHeaderAliases({ ' ': ['X-App-Id'] })).toThrow( + 'Header aliases must contain non-empty string keys' + ); + }); + + it('should reject non-array aliases', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': 'X-App-Id', + }) + ).toThrow("Header aliases for 'X-Parse-Application-Id' must be an array"); + }); + + it('should reject non-string aliases', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': [1], + }) + ).toThrow("Header aliases for 'X-Parse-Application-Id' must only contain strings"); + }); + + it('should reject empty string aliases', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': [' '], + }) + ).toThrow("Header aliases for 'X-Parse-Application-Id' must not contain empty strings"); + }); + + it('should reject an alias that contains CRLF characters', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['X-Foo\r\nSet-Cookie: evil'], + }) + ).toThrowError(/contains invalid characters/); + }); + + it('should reject an alias that contains a colon', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['X-Foo: bar'], + }) + ).toThrowError(/contains invalid characters/); + }); + + it('should reject an alias that contains an internal space', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['X Foo'], + }) + ).toThrowError(/contains invalid characters/); + }); + + it('should reject an alias that contains characters outside A-Za-z0-9-', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['X-App/Id'], + }) + ).toThrowError(/contains invalid characters/); + }); + + it('should reject CORS-safelisted request-header names and Range as aliases', () => { + for (const alias of ['Accept', 'accept-language', 'Content-Language', 'Content-Type', 'Range']) { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': [alias], + }) + ).toThrowError(/CORS-safelisted request header/); + } + }); + + it('should reject a canonical header that contains invalid characters', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id:': ['X-App-Id'], + }) + ).toThrowError(/contains invalid characters/); + }); + + it('should reject canonical headers that are not allowlisted Parse headers', () => { + expect(() => + Config.validateHeaderAliases({ + Authorization: ['X-Auth-Alias'], + }) + ).toThrowError(/is not an allowed Parse header/); + expect(() => + Config.validateHeaderAliases({ + Host: ['X-Host-Alias'], + }) + ).toThrowError(/is not an allowed Parse header/); + expect(() => + Config.validateHeaderAliases({ + Cookie: ['X-Cookie-Alias'], + }) + ).toThrowError(/is not an allowed Parse header/); + expect(() => + Config.validateHeaderAliases({ + 'X-Custom': ['X-Custom-Alias'], + }) + ).toThrowError(/is not an allowed Parse header/); + }); + + it('should reject credential-bearing master and maintenance key aliases', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Master-Key': ['X-Master-Key-Alias'], + }) + ).toThrowError(/is not an allowed Parse header/); + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Maintenance-Key': ['X-Maintenance-Key-Alias'], + }) + ).toThrowError(/is not an allowed Parse header/); + }); + + it('should reject an alias that normalizes to the same value as its canonical header', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['x-parse-application-id'], + }) + ).toThrowError(/must not normalize to the same value as the canonical header name/); + }); + + it('should reject duplicate normalized aliases within the same aliases array', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['foo', 'FOO'], + }) + ).toThrowError(/Duplicate normalized header alias/); + }); + + it('should reject two canonical keys that normalize to the same value', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': [], + 'x-parse-application-id': [], + }) + ).toThrowError(/collides with.*after trim and lowercasing/); + }); + + it('should reject the same normalized alias used for two different canonical headers', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Application-Id': ['shared-alias'], + 'X-Parse-Session-Token': ['Shared-Alias'], + }) + ).toThrowError(/collides with alias/); + }); + + it('should reject an alias that normalizes to another canonical header key', () => { + expect(() => + Config.validateHeaderAliases({ + 'X-Parse-Session-Token': [], + 'X-Parse-Application-Id': ['x-parse-session-token'], + }) + ).toThrowError(/collides with canonical header/); + }); +}); diff --git a/spec/Middlewares.spec.js b/spec/Middlewares.spec.js index ff6fa003b5..688520d50a 100644 --- a/spec/Middlewares.spec.js +++ b/spec/Middlewares.spec.js @@ -338,6 +338,25 @@ describe('middlewares', () => { expect(headers['Access-Control-Allow-Headers']).toContain(middlewares.DEFAULT_ALLOWED_HEADERS); }); + it('should append configured header aliases to Access-Control-Allow-Headers', () => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + }); + const headers = {}; + const res = { + header: (key, value) => { + headers[key] = value; + }, + }; + const allowCrossDomain = middlewares.allowCrossDomain(fakeReq.body._ApplicationId); + allowCrossDomain(fakeReq, res, () => {}); + expect(headers['Access-Control-Allow-Headers']).toContain('X-App-Id'); + expect(headers['Access-Control-Allow-Headers']).toContain('X-Session-Token-Alias'); + }); + it('should set default Access-Control-Allow-Origin if allowOrigin is empty', () => { AppCachePut(fakeReq.body._ApplicationId, { allowOrigin: undefined, @@ -408,6 +427,207 @@ describe('middlewares', () => { }); }); + it('should resolve app id from configured header alias', done => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-app-id'] = fakeReq.body._ApplicationId; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-application-id']).toEqual(fakeReq.body._ApplicationId); + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.appId).toEqual(fakeReq.body._ApplicationId); + done(); + }); + }); + }); + + it('should resolve session token from configured header alias', done => { + const sessionToken = 'session-token-via-alias'; + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-session-token-alias'] = sessionToken; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual(sessionToken); + done(); + }); + }); + }); + + it('should prefer canonical session token over alias when both headers are present', done => { + const canonicalToken = 'session-token-canonical'; + const aliasToken = 'session-token-alias-value'; + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-parse-session-token'] = canonicalToken; + fakeReq.headers['x-session-token-alias'] = aliasToken; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual(canonicalToken); + done(); + }); + }); + }); + + it('should prefer canonical application id over alias when both headers are present', done => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-parse-application-id'] = fakeReq.body._ApplicationId; + fakeReq.headers['x-app-id'] = 'other-app-id'; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-application-id']).toEqual(fakeReq.body._ApplicationId); + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.appId).toEqual(fakeReq.body._ApplicationId); + done(); + }); + }); + }); + + it('should use the first matching alias according to config order', done => { + const firstAliasToken = 'session-token-alias-a'; + const secondAliasToken = 'session-token-alias-b'; + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Session-Token': ['X-Alias-A', 'X-Alias-B'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + // Insert B before A in req.headers so Object.entries order would prefer B + fakeReq.headers['x-alias-b'] = secondAliasToken; + fakeReq.headers['x-alias-a'] = firstAliasToken; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual(firstAliasToken); + done(); + }); + }); + }); + + it('should use the second alias when the first configured alias is absent', done => { + const secondAliasToken = 'session-token-alias-b-only'; + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Session-Token': ['X-Alias-A', 'X-Alias-B'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-alias-b'] = secondAliasToken; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + middlewares.handleParseHeaders(fakeReq, fakeRes, () => { + expect(fakeReq.info.sessionToken).toEqual(secondAliasToken); + done(); + }); + }); + }); + + it('should not rewrite a canonical header when only unrelated headers are present', done => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-unrelated'] = 'value'; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-session-token']).toBeUndefined(); + done(); + }); + }); + + it('should not overwrite an empty-string canonical header with an alias value', done => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-parse-session-token'] = ''; + fakeReq.headers['x-session-token-alias'] = 'session-token-alias-value'; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-session-token']).toEqual(''); + done(); + }); + }); + + it('should not rewrite CORS-safelisted Accept into application-id', done => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Application-Id': ['Accept'], + }, + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['accept'] = 'test'; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-application-id']).toBeUndefined(); + done(); + }); + }); + + it('should 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 => { + AppCachePut(fakeReq.body._ApplicationId, { + headerAliases: { + 'X-Parse-Master-Key': ['X-Master-Key-Alias'], + }, + masterKey: 'masterKey', + masterKeyIps: ['0.0.0.0/0'], + }); + fakeReq.headers['x-master-key-alias'] = 'masterKey'; + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + expect(fakeReq.headers['x-parse-master-key']).toBeUndefined(); + done(); + }); + }); + + it('should call next without throwing when app is not in AppCache', () => { + const next = jasmine.createSpy('next'); + middlewares.handleHeaderAliases('NotInCacheAppId')(fakeReq, fakeRes, next); + expect(next).toHaveBeenCalled(); + }); + + it('should call next without throwing when headerAliases is missing or null', done => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['0.0.0.0/0'], + }); + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, () => { + AppCachePut(fakeReq.body._ApplicationId, { + masterKey: 'masterKey', + masterKeyIps: ['0.0.0.0/0'], + headerAliases: null, + }); + middlewares.handleHeaderAliases(fakeReq.body._ApplicationId)(fakeReq, fakeRes, done); + }); + }); + it('should give invalid response when upload file without x-parse-application-id in header', () => { AppCachePut(fakeReq.body._ApplicationId, { masterKey: 'masterKey', diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index df5da2070d..aba2d0964c 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -32,7 +32,7 @@ const { GraphQLList, } = require('graphql'); const { ParseServer } = require('../'); -const { ParseGraphQLServer } = require('../lib/GraphQL/ParseGraphQLServer'); +const { ParseGraphQLServer, getCSRFRequestHeaders } = require('../lib/GraphQL/ParseGraphQLServer'); const { ReadPreference, Collection } = require('mongodb'); const { randomUUID: uuidv4 } = require('crypto'); @@ -105,6 +105,42 @@ describe('ParseGraphQLServer', () => { }); }); + describe('getCSRFRequestHeaders', () => { + it('should include safe application-id header aliases with canonical header', () => { + const headers = getCSRFRequestHeaders({ + 'X-Parse-Application-Id': ['X-App-Id', 'X-Client-App'], + }); + expect(headers).toEqual(['X-Parse-Application-Id', 'X-App-Id', 'X-Client-App']); + }); + + it('should resolve aliases when canonical header key casing differs', () => { + const headers = getCSRFRequestHeaders({ + 'x-parse-application-id': ['X-App-Id'], + }); + expect(headers).toEqual(['X-Parse-Application-Id', 'X-App-Id']); + }); + + it('should exclude CORS-safelisted request-header names and Range from CSRF whitelist', () => { + const headers = getCSRFRequestHeaders({ + 'X-Parse-Application-Id': [ + 'Accept', + 'accept-language', + 'Content-Language', + 'Content-Type', + 'Range', + 'rAnGe', + 'X-Safe-Custom', + ], + }); + expect(headers).toEqual(['X-Parse-Application-Id', 'X-Safe-Custom']); + }); + + it('should tolerate null or undefined headerAliases without throwing', () => { + expect(getCSRFRequestHeaders(null)).toEqual(['X-Parse-Application-Id']); + expect(getCSRFRequestHeaders(undefined)).toEqual(['X-Parse-Application-Id']); + }); + }); + describe('_getServer', () => { it('should only return new server on schema changes', async () => { parseGraphQLServer.server = undefined; @@ -135,6 +171,7 @@ describe('ParseGraphQLServer', () => { expect(server).toBe(firstServer); }); }); + }); describe('_getGraphQLOptions', () => { @@ -201,6 +238,132 @@ describe('ParseGraphQLServer', () => { ).not.toThrow(); expect(useCount).toBeGreaterThan(0); }); + + it('registers header alias normalization before parse header handling', async () => { + const parseServerWithAliases = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + }); + const graphQLServerWithAliases = new ParseGraphQLServer(parseServerWithAliases, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + }); + const middlewares = require('../lib/middlewares'); + const useCalls = []; + const app = { + use: (...args) => { + useCalls.push(args); + }, + }; + graphQLServerWithAliases.applyGraphQL(app); + const parseHeadersIndex = useCalls.findIndex( + ([path, middleware]) => path === '/graphql' && middleware === middlewares.handleParseHeaders + ); + expect(parseHeadersIndex).toBeGreaterThan(0); + const [path, aliasMiddleware] = useCalls[parseHeadersIndex - 1]; + expect(path).toBe('/graphql'); + const req = { + originalUrl: '/graphql', + url: '/graphql', + protocol: 'http', + headers: { + host: 'localhost', + 'x-app-id': parseServerWithAliases.config.appId, + }, + get: key => req.headers[key.toLowerCase()], + }; + await new Promise(resolve => aliasMiddleware(req, {}, resolve)); + expect(req.headers['x-parse-application-id']).toBe(parseServerWithAliases.config.appId); + }); + + it('prefers canonical application-id over alias in GraphQL alias middleware', async () => { + const parseServerWithAliases = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + }); + const graphQLServerWithAliases = new ParseGraphQLServer(parseServerWithAliases, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + }); + const middlewares = require('../lib/middlewares'); + const useCalls = []; + const app = { + use: (...args) => { + useCalls.push(args); + }, + }; + graphQLServerWithAliases.applyGraphQL(app); + const parseHeadersIndex = useCalls.findIndex( + ([path, middleware]) => path === '/graphql' && middleware === middlewares.handleParseHeaders + ); + const [, aliasMiddleware] = useCalls[parseHeadersIndex - 1]; + const req = { + originalUrl: '/graphql', + url: '/graphql', + protocol: 'http', + headers: { + host: 'localhost', + 'x-parse-application-id': parseServerWithAliases.config.appId, + 'x-app-id': 'other-app-id', + }, + get: key => req.headers[key.toLowerCase()], + }; + await new Promise(resolve => aliasMiddleware(req, {}, resolve)); + expect(req.headers['x-parse-application-id']).toBe(parseServerWithAliases.config.appId); + }); + + it('does not authorize GraphQL via Accept application-id alias before CSRF checks', async () => { + const AppCache = require('../lib/cache').default; + const parseServerWithAliases = await global.reconfigureServer({ + maintenanceKey: 'test2', + maxUploadSize: '1kb', + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + }); + // Inject a blocked alias without re-validation to prove rewrite defense. + const cached = AppCache.get(parseServerWithAliases.config.appId); + AppCache.put(parseServerWithAliases.config.appId, { + ...cached, + headerAliases: { + 'X-Parse-Application-Id': ['Accept'], + }, + }); + const graphQLServerWithAliases = new ParseGraphQLServer(parseServerWithAliases, { + graphQLPath: '/graphql', + playgroundPath: '/playground', + }); + const middlewares = require('../lib/middlewares'); + const useCalls = []; + const app = { + use: (...args) => { + useCalls.push(args); + }, + }; + graphQLServerWithAliases.applyGraphQL(app); + const parseHeadersIndex = useCalls.findIndex( + ([path, middleware]) => path === '/graphql' && middleware === middlewares.handleParseHeaders + ); + const [, aliasMiddleware] = useCalls[parseHeadersIndex - 1]; + const req = { + originalUrl: '/graphql', + url: '/graphql', + protocol: 'http', + headers: { + host: 'localhost', + accept: parseServerWithAliases.config.appId, + }, + get: key => req.headers[key.toLowerCase()], + }; + await new Promise(resolve => aliasMiddleware(req, {}, resolve)); + expect(req.headers['x-parse-application-id']).toBeUndefined(); + }); }); describe('applyPlayground', () => { @@ -9037,6 +9200,37 @@ describe('ParseGraphQLServer', () => { }); describe('Session Token', () => { + it('should retrieve me with session token header alias', async () => { + parseServer = await global.reconfigureServer({ + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + }); + await createGQLFromParseServer(parseServer); + const username = `alias-gql-${uuidv4()}`; + const user = new Parse.User(); + user.setUsername(username); + user.setPassword('password'); + await user.signUp(); + const result = await apolloClient.query({ + query: gql` + query GetCurrentUser { + viewer { + user { + username + } + } + } + `, + context: { + headers: { + 'X-Session-Token-Alias': user.getSessionToken(), + }, + }, + }); + expect(result.data.viewer.user.username).toBe(username); + }); + it('should fail due to invalid session token', async () => { try { await apolloClient.query({ diff --git a/spec/rest.spec.js b/spec/rest.spec.js index 9416d9230e..7012fa5445 100644 --- a/spec/rest.spec.js +++ b/spec/rest.spec.js @@ -1738,6 +1738,121 @@ describe('read-only masterKey', () => { }); }); +describe('rest header aliases', () => { + const signUpViaRest = async username => { + const response = await request({ + url: `${Parse.serverURL}/users`, + method: 'POST', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { username, password: 'password' }, + }); + return response.data; + }; + + it('supports REST requests with application-id header alias only', async () => { + await reconfigureServer({ + headerAliases: { + 'X-Parse-Application-Id': ['X-App-Id'], + }, + }); + try { + const response = await request({ + url: `${Parse.serverURL}/schemas`, + method: 'GET', + headers: { + 'X-App-Id': Parse.applicationId, + 'X-Parse-Master-Key': Parse.masterKey, + }, + }); + expect(response.data.results).toBeDefined(); + expect(Array.isArray(response.data.results)).toBe(true); + } finally { + await reconfigureServer(); + } + }); + + it('supports /users/me with session-token header alias', async () => { + await reconfigureServer({ + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + }); + try { + const username = `alias-rest-${Date.now()}`; + const user = await Parse.User.signUp(username, 'password'); + const response = await request({ + url: `${Parse.serverURL}/users/me`, + method: 'GET', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Session-Token-Alias': user.getSessionToken(), + }, + }); + expect(response.data.objectId).toBe(user.id); + expect(response.data.username).toBe(username); + } finally { + await reconfigureServer(); + } + }); + + it('prefers canonical session token over alias on /users/me', async () => { + await reconfigureServer({ + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias'], + }, + }); + try { + // Create users via REST so the SDK current-user singleton is not shared/overwritten. + const canonicalUser = await signUpViaRest(`alias-rest-canonical-${Date.now()}`); + const aliasUser = await signUpViaRest(`alias-rest-alias-${Date.now()}`); + const response = await request({ + url: `${Parse.serverURL}/users/me`, + method: 'GET', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': canonicalUser.sessionToken, + 'X-Session-Token-Alias': aliasUser.sessionToken, + }, + }); + expect(response.data.objectId).toBe(canonicalUser.objectId); + } finally { + await reconfigureServer(); + } + }); + + it('uses the first matching session-token alias over REST according to config order', async () => { + await reconfigureServer({ + headerAliases: { + 'X-Parse-Session-Token': ['X-Session-Token-Alias-A', 'X-Session-Token-Alias-B'], + }, + }); + try { + // Create users via REST so the SDK current-user singleton is not shared/overwritten. + const firstAliasUser = await signUpViaRest(`alias-rest-a-${Date.now()}`); + const secondAliasUser = await signUpViaRest(`alias-rest-b-${Date.now()}`); + const response = await request({ + url: `${Parse.serverURL}/users/me`, + method: 'GET', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'X-Session-Token-Alias-B': secondAliasUser.sessionToken, + 'X-Session-Token-Alias-A': firstAliasUser.sessionToken, + }, + }); + expect(response.data.objectId).toBe(firstAliasUser.objectId); + } finally { + await reconfigureServer(); + } + }); +}); + describe('rest context', () => { it('should support dependency injection on rest api', async () => { const requestContextMiddleware = (req, res, next) => { diff --git a/src/Config.js b/src/Config.js index 95543c6c6b..9c1a3da5fd 100644 --- a/src/Config.js +++ b/src/Config.js @@ -43,7 +43,33 @@ 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', +]); + +// 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) { @@ -130,6 +156,7 @@ export class Config { readOnlyMasterKey, readOnlyMasterKeyIps, allowHeaders, + headerAliases, idempotencyOptions, fileUpload, fileDownload, @@ -183,6 +210,7 @@ export class Config { this.validateDefaultLimit(defaultLimit); this.validateMaxLimit(maxLimit); this.validateAllowHeaders(allowHeaders); + this.validateHeaderAliases(headerAliases); this.validateIdempotencyOptions(idempotencyOptions); this.validatePagesOptions(pages); this.validateSecurityOptions(security); @@ -769,6 +797,106 @@ export class Config { } } + static validateHeaderAliases(headerAliases) { + if (![null, undefined].includes(headerAliases)) { + if (Object.prototype.toString.call(headerAliases) !== '[object Object]') { + throw 'Header aliases must be an object'; + } + const SAFE_HEADER_NAME = /^[A-Za-z0-9-]+$/; + const entries = Object.entries(headerAliases); + for (const [canonicalHeader, aliases] of entries) { + if (typeof canonicalHeader !== 'string' || !canonicalHeader.trim().length) { + throw 'Header aliases must contain non-empty string keys'; + } + const trimmedCanonical = canonicalHeader.trim(); + if (!SAFE_HEADER_NAME.test(trimmedCanonical)) { + throw new Error( + `Header aliases canonical '${canonicalHeader}' contains invalid characters` + ); + } + if (!Config.ALLOWED_HEADER_ALIAS_CANONICALS.has(trimmedCanonical.toLowerCase())) { + throw new Error( + `Header aliases canonical '${canonicalHeader}' is not an allowed Parse header` + ); + } + if (!Array.isArray(aliases)) { + throw `Header aliases for '${canonicalHeader}' must be an array`; + } + aliases.forEach(alias => { + if (typeof alias !== 'string') { + throw `Header aliases for '${canonicalHeader}' must only contain strings`; + } else if (!alias.trim().length) { + throw `Header aliases for '${canonicalHeader}' must not contain empty strings`; + } + const trimmedAlias = alias.trim(); + if (!SAFE_HEADER_NAME.test(trimmedAlias)) { + throw new Error( + `Header alias '${alias}' for canonical header '${canonicalHeader}' contains invalid characters` + ); + } + if (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` + ); + } + }); + } + + 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 }); + } + } + } + } + static validateLogLevels(logLevels) { for (const key of Object.keys(LogLevels)) { if (logLevels[key]) { diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 572a6fa71d..c21b45e37c 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -4,9 +4,17 @@ import { expressMiddleware } from '@as-integrations/express5'; import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; import express from 'express'; import { GraphQLError, parse } from 'graphql'; -import { allowCrossDomain, handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; +import { + allowCrossDomain, + getHeaderAliases, + handleHeaderAliases, + handleParseErrors, + handleParseHeaders, + handleParseSession, +} from '../middlewares'; import requiredParameter from '../requiredParameter'; import defaultLogger from '../logger'; +import Config from '../Config'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; import { createComplexityValidationPlugin } from './helpers/queryComplexity'; @@ -90,6 +98,18 @@ 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. +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()) + ); + return [...new Set(['X-Parse-Application-Id', ...safeAliases])]; +}; + // graphql-js embeds "Did you mean ...?" hints sourced from the live schema in // its error messages. They are produced in two distinct phases: // - validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule, @@ -285,11 +305,13 @@ class ParseGraphQLServer { const createServer = async () => { try { const { schema, context } = await this._getGraphQLOptions(); + const csrfRequestHeaders = getCSRFRequestHeaders(this.parseServer.config.headerAliases); const apollo = new ApolloServer({ csrfPrevention: { // See https://www.apollographql.com/docs/router/configuration/csrf/ - // needed since we use graphql upload - requestHeaders: ['X-Parse-Application-Id'], + // needed since we use graphql upload. handleHeaderAliases runs on this path + // before Apollo; getCSRFRequestHeaders lists canonical + safe aliases only. + requestHeaders: csrfRequestHeaders, }, // We need always true introspection because apollo server have changing behavior based on the NODE_ENV variable // we delegate the introspection control to the IntrospectionControlPlugin @@ -344,6 +366,7 @@ class ParseGraphQLServer { requiredParameter('You must provide an Express.js app instance!'); } app.use(this.config.graphQLPath, allowCrossDomain(this.parseServer.config.appId)); + app.use(this.config.graphQLPath, handleHeaderAliases(this.parseServer.config.appId)); app.use(this.config.graphQLPath, handleParseHeaders); app.use(this.config.graphQLPath, handleParseSession); this.applyRequestContextMiddleware(app, this.parseServer.config); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index f5230e7f1b..176fb376af 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -316,6 +316,11 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_GRAPH_QLSCHEMA', help: 'Full path to your GraphQL custom schema.graphql file', }, + headerAliases: { + env: 'PARSE_SERVER_HEADER_ALIASES', + help: '(Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are 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: { env: 'PARSE_SERVER_HOST', help: 'The host to serve ParseServer on, defaults to 0.0.0.0', diff --git a/src/Options/docs.js b/src/Options/docs.js index 91d58cbe9b..3e725203d9 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -60,6 +60,7 @@ * @property {String} graphQLPath The mount path for the GraphQL endpoint

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

Defaults is `/graphql`. * @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false * @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file + * @property {Object} headerAliases (Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are 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 d8f56defca..77b76167ee 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -91,6 +91,9 @@ export interface ParseServerOptions { appName: ?string; /* Add headers to Access-Control-Allow-Headers */ allowHeaders: ?(string[]); + /* (Optional) Define aliases for Parse request headers. For each allowed canonical Parse header, set an array of accepted alias headers. Aliases are 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. */ allowOrigin: ?StringOrStringArray; /* Adapter module for the analytics */ diff --git a/src/ParseServer.ts b/src/ParseServer.ts index 65d537ae68..128093863b 100644 --- a/src/ParseServer.ts +++ b/src/ParseServer.ts @@ -311,6 +311,7 @@ class ParseServer { //api.use("/apps", express.static(__dirname + "/public")); api.use(middlewares.allowCrossDomain(appId)); api.use(middlewares.allowDoubleForwardSlash); + api.use(middlewares.handleHeaderAliases(appId)); api.use(middlewares.handleParseAuth(appId)); // File handling needs to be before the default JSON body parser because file // uploads send binary data that should not be parsed as JSON. diff --git a/src/defaults.js b/src/defaults.js index b7d05f1550..b042cae53e 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -25,6 +25,7 @@ const DefinitionDefaults = Object.keys(ParseServerOptions).reduce((memo, key) => }, {}); const computedDefaults = { + headerAliases: {}, jsonLogs: process.env.JSON_LOGS || false, logsFolder, verbose, diff --git a/src/middlewares.js b/src/middlewares.js index 24ecc234b5..1e4e57d3cb 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -64,6 +64,71 @@ export const checkIp = (ip, ipRangeList, store) => { return result; }; +// Build a clean list of headers +const getHeaderList = headers => + headers + .split(',') + .map(header => header.trim()) + .filter(Boolean); + +// Merge all headers into a single list +const mergeHeaders = (...headerSources) => { + const reduced = headerSources.reduce((acc, source) => { + const headers = Array.isArray(source) ? source : getHeaderList(source || ''); + const trimmedHeaders = headers.map(header => header.trim()); + acc.push(...trimmedHeaders); + return acc; + }, []).filter(header => Boolean(header)); + return [...new Set(reduced)]; +}; + +export function getHeaderAliases(headerAliases, canonicalHeader) { + const target = String(canonicalHeader).trim().toLowerCase(); + const matchedKey = Object.keys(headerAliases || {}).find( + key => String(key).trim().toLowerCase() === target + ); + const aliases = matchedKey === undefined ? undefined : headerAliases[matchedKey]; + if (!Array.isArray(aliases)) { + return []; + } + // Clean up the aliases and remove any empty strings + return aliases.map(alias => alias.trim()).filter(Boolean); +} + +function applyHeaderAliases(req, headerAliases) { + req.headers = req.headers || {}; + 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 + } + 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()]; + } + } +} + +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. @@ -399,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..c2169addd8 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -52,6 +52,13 @@ 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; filesAdapter?: Adapter;