fix: Data schema exposed via GraphQL API public introspection (GHSA-48q3-prgv-gm4w) (#9819)

This commit is contained in:
Manuel
2025-07-10 04:25:09 +02:00
committed by GitHub
parent 2c29756038
commit c58b2eb6eb
8 changed files with 193 additions and 22 deletions

View File

@@ -50,6 +50,7 @@ describe('ParseGraphQLServer', () => {
beforeEach(async () => { beforeEach(async () => {
parseServer = await global.reconfigureServer({ parseServer = await global.reconfigureServer({
maintenanceKey: 'test2',
maxUploadSize: '1kb', maxUploadSize: '1kb',
}); });
parseGraphQLServer = new ParseGraphQLServer(parseServer, { parseGraphQLServer = new ParseGraphQLServer(parseServer, {
@@ -88,8 +89,8 @@ describe('ParseGraphQLServer', () => {
it('should initialize parseGraphQLSchema with a log controller', async () => { it('should initialize parseGraphQLSchema with a log controller', async () => {
const loggerAdapter = { const loggerAdapter = {
log: () => {}, log: () => { },
error: () => {}, error: () => { },
}; };
const parseServer = await global.reconfigureServer({ const parseServer = await global.reconfigureServer({
loggerAdapter, loggerAdapter,
@@ -124,10 +125,10 @@ describe('ParseGraphQLServer', () => {
info: new Object(), info: new Object(),
config: new Object(), config: new Object(),
auth: new Object(), auth: new Object(),
get: () => {}, get: () => { },
}; };
const res = { const res = {
set: () => {}, set: () => { },
}; };
it_id('0696675e-060f-414f-bc77-9d57f31807f5')(it)('should return schema and context with req\'s info, config and auth', async () => { it_id('0696675e-060f-414f-bc77-9d57f31807f5')(it)('should return schema and context with req\'s info, config and auth', async () => {
@@ -431,7 +432,7 @@ describe('ParseGraphQLServer', () => {
objects.push(object1, object2, object3, object4); objects.push(object1, object2, object3, object4);
} }
async function createGQLFromParseServer(_parseServer) { async function createGQLFromParseServer(_parseServer, parseGraphQLServerOptions) {
if (parseLiveQueryServer) { if (parseLiveQueryServer) {
await parseLiveQueryServer.server.close(); await parseLiveQueryServer.server.close();
} }
@@ -448,6 +449,7 @@ describe('ParseGraphQLServer', () => {
graphQLPath: '/graphql', graphQLPath: '/graphql',
playgroundPath: '/playground', playgroundPath: '/playground',
subscriptionsPath: '/subscriptions', subscriptionsPath: '/subscriptions',
...parseGraphQLServerOptions,
}); });
parseGraphQLServer.applyGraphQL(expressApp); parseGraphQLServer.applyGraphQL(expressApp);
parseGraphQLServer.applyPlayground(expressApp); parseGraphQLServer.applyPlayground(expressApp);
@@ -488,8 +490,8 @@ describe('ParseGraphQLServer', () => {
}, },
}, },
}); });
spyOn(console, 'warn').and.callFake(() => {}); spyOn(console, 'warn').and.callFake(() => { });
spyOn(console, 'error').and.callFake(() => {}); spyOn(console, 'error').and.callFake(() => { });
}); });
afterEach(async () => { afterEach(async () => {
@@ -605,6 +607,96 @@ describe('ParseGraphQLServer', () => {
]); ]);
}; };
describe('Introspection', () => {
it('should have public introspection disabled by default without master key', async () => {
try {
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
})
fail('should have thrown an error');
} catch (e) {
expect(e.message).toEqual('Response not successful: Received status code 403');
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
}
});
it('should always work with master key', async () => {
const introspection =
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
},)
expect(introspection.data).toBeDefined();
expect(introspection.errors).not.toBeDefined();
});
it('should always work with maintenance key', async () => {
const introspection =
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
context: {
headers: {
'X-Parse-Maintenance-Key': 'test2',
},
}
},)
expect(introspection.data).toBeDefined();
expect(introspection.errors).not.toBeDefined();
});
it('should have public introspection enabled if enabled', async () => {
const parseServer = await reconfigureServer();
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
const introspection =
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
})
expect(introspection.data).toBeDefined();
});
});
describe('Default Types', () => { describe('Default Types', () => {
it('should have Object scalar type', async () => { it('should have Object scalar type', async () => {
const objectType = ( const objectType = (
@@ -749,6 +841,11 @@ describe('ParseGraphQLServer', () => {
} }
} }
`, `,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
}) })
).data['__schema'].types.map(type => type.name); ).data['__schema'].types.map(type => type.name);
@@ -780,6 +877,11 @@ describe('ParseGraphQLServer', () => {
} }
} }
`, `,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
}) })
).data['__schema'].types.map(type => type.name); ).data['__schema'].types.map(type => type.name);
@@ -864,7 +966,7 @@ describe('ParseGraphQLServer', () => {
}); });
it('should have clientMutationId in call function input', async () => { it('should have clientMutationId in call function input', async () => {
Parse.Cloud.define('hello', () => {}); Parse.Cloud.define('hello', () => { });
const callFunctionInputFields = ( const callFunctionInputFields = (
await apolloClient.query({ await apolloClient.query({
@@ -886,7 +988,7 @@ describe('ParseGraphQLServer', () => {
}); });
it('should have clientMutationId in call function payload', async () => { it('should have clientMutationId in call function payload', async () => {
Parse.Cloud.define('hello', () => {}); Parse.Cloud.define('hello', () => { });
const callFunctionPayloadFields = ( const callFunctionPayloadFields = (
await apolloClient.query({ await apolloClient.query({
@@ -1312,6 +1414,11 @@ describe('ParseGraphQLServer', () => {
} }
} }
`, `,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
}) })
).data['__schema'].types.map(type => type.name); ).data['__schema'].types.map(type => type.name);
@@ -7447,9 +7554,9 @@ describe('ParseGraphQLServer', () => {
it('should send reset password', async () => { it('should send reset password', async () => {
const clientMutationId = uuidv4(); const clientMutationId = uuidv4();
const emailAdapter = { const emailAdapter = {
sendVerificationEmail: () => {}, sendVerificationEmail: () => { },
sendPasswordResetEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {}, sendMail: () => { },
}; };
parseServer = await global.reconfigureServer({ parseServer = await global.reconfigureServer({
appName: 'test', appName: 'test',
@@ -7488,11 +7595,11 @@ describe('ParseGraphQLServer', () => {
const clientMutationId = uuidv4(); const clientMutationId = uuidv4();
let resetPasswordToken; let resetPasswordToken;
const emailAdapter = { const emailAdapter = {
sendVerificationEmail: () => {}, sendVerificationEmail: () => { },
sendPasswordResetEmail: ({ link }) => { sendPasswordResetEmail: ({ link }) => {
resetPasswordToken = link.split('token=')[1].split('&')[0]; resetPasswordToken = link.split('token=')[1].split('&')[0];
}, },
sendMail: () => {}, sendMail: () => { },
}; };
parseServer = await global.reconfigureServer({ parseServer = await global.reconfigureServer({
appName: 'test', appName: 'test',
@@ -7558,9 +7665,9 @@ describe('ParseGraphQLServer', () => {
it('should send verification email again', async () => { it('should send verification email again', async () => {
const clientMutationId = uuidv4(); const clientMutationId = uuidv4();
const emailAdapter = { const emailAdapter = {
sendVerificationEmail: () => {}, sendVerificationEmail: () => { },
sendPasswordResetEmail: () => Promise.resolve(), sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {}, sendMail: () => { },
}; };
parseServer = await global.reconfigureServer({ parseServer = await global.reconfigureServer({
appName: 'test', appName: 'test',

View File

@@ -33,6 +33,7 @@ describe('Security Check Groups', () => {
config.security.enableCheckLog = false; config.security.enableCheckLog = false;
config.allowClientClassCreation = false; config.allowClientClassCreation = false;
config.enableInsecureAuthAdapters = false; config.enableInsecureAuthAdapters = false;
config.graphQLPublicIntrospection = false;
await reconfigureServer(config); await reconfigureServer(config);
const group = new CheckGroupServerConfig(); const group = new CheckGroupServerConfig();
@@ -41,12 +42,14 @@ describe('Security Check Groups', () => {
expect(group.checks()[1].checkState()).toBe(CheckState.success); expect(group.checks()[1].checkState()).toBe(CheckState.success);
expect(group.checks()[2].checkState()).toBe(CheckState.success); expect(group.checks()[2].checkState()).toBe(CheckState.success);
expect(group.checks()[4].checkState()).toBe(CheckState.success); expect(group.checks()[4].checkState()).toBe(CheckState.success);
expect(group.checks()[5].checkState()).toBe(CheckState.success);
}); });
it('checks fail correctly', async () => { it('checks fail correctly', async () => {
config.masterKey = 'insecure'; config.masterKey = 'insecure';
config.security.enableCheckLog = true; config.security.enableCheckLog = true;
config.allowClientClassCreation = true; config.allowClientClassCreation = true;
config.graphQLPublicIntrospection = true;
await reconfigureServer(config); await reconfigureServer(config);
const group = new CheckGroupServerConfig(); const group = new CheckGroupServerConfig();
@@ -55,6 +58,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[1].checkState()).toBe(CheckState.fail); expect(group.checks()[1].checkState()).toBe(CheckState.fail);
expect(group.checks()[2].checkState()).toBe(CheckState.fail); expect(group.checks()[2].checkState()).toBe(CheckState.fail);
expect(group.checks()[4].checkState()).toBe(CheckState.fail); expect(group.checks()[4].checkState()).toBe(CheckState.fail);
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
}); });
}); });

View File

@@ -4,7 +4,7 @@ import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4'; import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
import express from 'express'; import express from 'express';
import { execute, subscribe } from 'graphql'; import { execute, subscribe, GraphQLError } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws'; import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter'; import requiredParameter from '../requiredParameter';
@@ -12,6 +12,45 @@ import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
const IntrospectionControlPlugin = (publicIntrospection) => ({
requestDidStart: (requestContext) => ({
didResolveOperation: async () => {
// If public introspection is enabled, we allow all introspection queries
if (publicIntrospection) {
return;
}
const isMasterOrMaintenance = requestContext.contextValue.auth?.isMaster || requestContext.contextValue.auth?.isMaintenance
if (isMasterOrMaintenance) {
return;
}
// Now we check if the query is an introspection query
// this check strategy should work in 99.99% cases
// we can have an issue if a user name a field or class __schemaSomething
// we want to avoid a full AST check
const isIntrospectionQuery =
requestContext.request.query?.includes('__schema')
if (isIntrospectionQuery) {
throw new GraphQLError('Introspection is not allowed', {
extensions: {
http: {
status: 403,
},
}
});
}
},
})
});
class ParseGraphQLServer { class ParseGraphQLServer {
parseGraphQLController: ParseGraphQLController; parseGraphQLController: ParseGraphQLController;
@@ -65,8 +104,8 @@ class ParseGraphQLServer {
// needed since we use graphql upload // needed since we use graphql upload
requestHeaders: ['X-Parse-Application-Id'], requestHeaders: ['X-Parse-Application-Id'],
}, },
introspection: true, introspection: this.config.graphQLPublicIntrospection,
plugins: [ApolloServerPluginCacheControlDisabled()], plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
schema, schema,
}); });
await apollo.start(); await apollo.start();
@@ -118,7 +157,7 @@ class ParseGraphQLServer {
app.get( app.get(
this.config.playgroundPath || this.config.playgroundPath ||
requiredParameter('You must provide a config.playgroundPath to applyPlayground!'), requiredParameter('You must provide a config.playgroundPath to applyPlayground!'),
(_req, res) => { (_req, res) => {
res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Type', 'text/html');
res.write( res.write(

View File

@@ -292,6 +292,12 @@ module.exports.ParseServerOptions = {
help: 'Mount path for the GraphQL endpoint, defaults to /graphql', help: 'Mount path for the GraphQL endpoint, defaults to /graphql',
default: '/graphql', default: '/graphql',
}, },
graphQLPublicIntrospection: {
env: 'PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION',
help: 'Enable public introspection for the GraphQL endpoint, defaults to false',
action: parsers.booleanParser,
default: false,
},
graphQLSchema: { graphQLSchema: {
env: 'PARSE_SERVER_GRAPH_QLSCHEMA', env: 'PARSE_SERVER_GRAPH_QLSCHEMA',
help: 'Full path to your GraphQL custom schema.graphql file', help: 'Full path to your GraphQL custom schema.graphql file',

View File

@@ -53,6 +53,7 @@
* @property {Adapter<FilesAdapter>} filesAdapter Adapter module for the files sub-system * @property {Adapter<FilesAdapter>} filesAdapter Adapter module for the files sub-system
* @property {FileUploadOptions} fileUpload Options for file uploads * @property {FileUploadOptions} fileUpload Options for file uploads
* @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql * @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /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 {String} graphQLSchema Full path to your GraphQL custom schema.graphql file
* @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0 * @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 {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.

View File

@@ -306,6 +306,10 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_GRAPHQL_PATH :ENV: PARSE_SERVER_GRAPHQL_PATH
:DEFAULT: /graphql */ :DEFAULT: /graphql */
graphQLPath: ?string; graphQLPath: ?string;
/* Enable public introspection for the GraphQL endpoint, defaults to false
:ENV: PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION
:DEFAULT: false */
graphQLPublicIntrospection: ?boolean;
/* Mounts the GraphQL Playground - never use this option in production /* Mounts the GraphQL Playground - never use this option in production
:ENV: PARSE_SERVER_MOUNT_PLAYGROUND :ENV: PARSE_SERVER_MOUNT_PLAYGROUND
:DEFAULT: false */ :DEFAULT: false */

View File

@@ -80,6 +80,16 @@ class CheckGroupServerConfig extends CheckGroup {
} }
}, },
}), }),
new Check({
title: 'GraphQL public introspection disabled',
warning: 'GraphQL public introspection is enabled, which allows anyone to access the GraphQL schema.',
solution: "Change Parse Server configuration to 'graphQLPublicIntrospection: false'. You will need to use master key or maintenance key to access the GraphQL schema.",
check: () => {
if (config.graphQLPublicIntrospection !== false) {
throw 1;
}
},
}),
]; ];
} }
} }

View File

@@ -539,9 +539,9 @@ export const addRateLimit = (route, config, cloud) => {
url: route.redisUrl, url: route.redisUrl,
}); });
client.on('error', err => { log.error('Middlewares addRateLimit Redis client error', { error: err }) }); client.on('error', err => { log.error('Middlewares addRateLimit Redis client error', { error: err }) });
client.on('connect', () => {}); client.on('connect', () => { });
client.on('reconnecting', () => {}); client.on('reconnecting', () => { });
client.on('ready', () => {}); client.on('ready', () => { });
redisStore.connectionPromise = async () => { redisStore.connectionPromise = async () => {
if (client.isOpen) { if (client.isOpen) {
return; return;