diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index f324924d37..cac3f448ce 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -50,6 +50,7 @@ describe('ParseGraphQLServer', () => { beforeEach(async () => { parseServer = await global.reconfigureServer({ + maintenanceKey: 'test2', maxUploadSize: '1kb', }); parseGraphQLServer = new ParseGraphQLServer(parseServer, { @@ -88,8 +89,8 @@ describe('ParseGraphQLServer', () => { it('should initialize parseGraphQLSchema with a log controller', async () => { const loggerAdapter = { - log: () => {}, - error: () => {}, + log: () => { }, + error: () => { }, }; const parseServer = await global.reconfigureServer({ loggerAdapter, @@ -124,10 +125,10 @@ describe('ParseGraphQLServer', () => { info: new Object(), config: new Object(), auth: new Object(), - get: () => {}, + get: () => { }, }; 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 () => { @@ -431,7 +432,7 @@ describe('ParseGraphQLServer', () => { objects.push(object1, object2, object3, object4); } - async function createGQLFromParseServer(_parseServer) { + async function createGQLFromParseServer(_parseServer, parseGraphQLServerOptions) { if (parseLiveQueryServer) { await parseLiveQueryServer.server.close(); } @@ -448,6 +449,7 @@ describe('ParseGraphQLServer', () => { graphQLPath: '/graphql', playgroundPath: '/playground', subscriptionsPath: '/subscriptions', + ...parseGraphQLServerOptions, }); parseGraphQLServer.applyGraphQL(expressApp); parseGraphQLServer.applyPlayground(expressApp); @@ -488,8 +490,8 @@ describe('ParseGraphQLServer', () => { }, }, }); - spyOn(console, 'warn').and.callFake(() => {}); - spyOn(console, 'error').and.callFake(() => {}); + spyOn(console, 'warn').and.callFake(() => { }); + spyOn(console, 'error').and.callFake(() => { }); }); 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', () => { it('should have Object scalar type', async () => { const objectType = ( @@ -749,6 +841,11 @@ describe('ParseGraphQLServer', () => { } } `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + } }) ).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); @@ -864,7 +966,7 @@ describe('ParseGraphQLServer', () => { }); it('should have clientMutationId in call function input', async () => { - Parse.Cloud.define('hello', () => {}); + Parse.Cloud.define('hello', () => { }); const callFunctionInputFields = ( await apolloClient.query({ @@ -886,7 +988,7 @@ describe('ParseGraphQLServer', () => { }); it('should have clientMutationId in call function payload', async () => { - Parse.Cloud.define('hello', () => {}); + Parse.Cloud.define('hello', () => { }); const callFunctionPayloadFields = ( await apolloClient.query({ @@ -1312,6 +1414,11 @@ describe('ParseGraphQLServer', () => { } } `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + } }) ).data['__schema'].types.map(type => type.name); @@ -7447,9 +7554,9 @@ describe('ParseGraphQLServer', () => { it('should send reset password', async () => { const clientMutationId = uuidv4(); const emailAdapter = { - sendVerificationEmail: () => {}, + sendVerificationEmail: () => { }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {}, + sendMail: () => { }, }; parseServer = await global.reconfigureServer({ appName: 'test', @@ -7488,11 +7595,11 @@ describe('ParseGraphQLServer', () => { const clientMutationId = uuidv4(); let resetPasswordToken; const emailAdapter = { - sendVerificationEmail: () => {}, + sendVerificationEmail: () => { }, sendPasswordResetEmail: ({ link }) => { resetPasswordToken = link.split('token=')[1].split('&')[0]; }, - sendMail: () => {}, + sendMail: () => { }, }; parseServer = await global.reconfigureServer({ appName: 'test', @@ -7558,9 +7665,9 @@ describe('ParseGraphQLServer', () => { it('should send verification email again', async () => { const clientMutationId = uuidv4(); const emailAdapter = { - sendVerificationEmail: () => {}, + sendVerificationEmail: () => { }, sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {}, + sendMail: () => { }, }; parseServer = await global.reconfigureServer({ appName: 'test', diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index 21409a78c1..3e5f312dd7 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -33,6 +33,7 @@ describe('Security Check Groups', () => { config.security.enableCheckLog = false; config.allowClientClassCreation = false; config.enableInsecureAuthAdapters = false; + config.graphQLPublicIntrospection = false; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -41,12 +42,14 @@ describe('Security Check Groups', () => { expect(group.checks()[1].checkState()).toBe(CheckState.success); expect(group.checks()[2].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 () => { config.masterKey = 'insecure'; config.security.enableCheckLog = true; config.allowClientClassCreation = true; + config.graphQLPublicIntrospection = true; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -55,6 +58,7 @@ describe('Security Check Groups', () => { expect(group.checks()[1].checkState()).toBe(CheckState.fail); expect(group.checks()[2].checkState()).toBe(CheckState.fail); expect(group.checks()[4].checkState()).toBe(CheckState.fail); + expect(group.checks()[5].checkState()).toBe(CheckState.fail); }); }); diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 00218e0cdb..268d219596 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -4,7 +4,7 @@ import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@apollo/server/express4'; import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; import express from 'express'; -import { execute, subscribe } from 'graphql'; +import { execute, subscribe, GraphQLError } from 'graphql'; import { SubscriptionServer } from 'subscriptions-transport-ws'; import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; import requiredParameter from '../requiredParameter'; @@ -12,6 +12,45 @@ import defaultLogger from '../logger'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; 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 { parseGraphQLController: ParseGraphQLController; @@ -65,8 +104,8 @@ class ParseGraphQLServer { // needed since we use graphql upload requestHeaders: ['X-Parse-Application-Id'], }, - introspection: true, - plugins: [ApolloServerPluginCacheControlDisabled()], + introspection: this.config.graphQLPublicIntrospection, + plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)], schema, }); await apollo.start(); @@ -118,7 +157,7 @@ class ParseGraphQLServer { app.get( this.config.playgroundPath || - requiredParameter('You must provide a config.playgroundPath to applyPlayground!'), + requiredParameter('You must provide a config.playgroundPath to applyPlayground!'), (_req, res) => { res.setHeader('Content-Type', 'text/html'); res.write( diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index ca2f7cc2ee..a23a0de3e5 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -292,6 +292,12 @@ module.exports.ParseServerOptions = { help: 'Mount path for the GraphQL endpoint, defaults to /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: { env: 'PARSE_SERVER_GRAPH_QLSCHEMA', help: 'Full path to your GraphQL custom schema.graphql file', diff --git a/src/Options/docs.js b/src/Options/docs.js index 2c081eaa6b..bfba129bb2 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -53,6 +53,7 @@ * @property {Adapter} filesAdapter Adapter module for the files sub-system * @property {FileUploadOptions} fileUpload Options for file uploads * @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} 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. diff --git a/src/Options/index.js b/src/Options/index.js index b3c04462ca..b1827d808a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -306,6 +306,10 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_GRAPHQL_PATH :DEFAULT: /graphql */ 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 :ENV: PARSE_SERVER_MOUNT_PLAYGROUND :DEFAULT: false */ diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index 3f88c18898..05a52a0275 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -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; + } + }, + }), ]; } } diff --git a/src/middlewares.js b/src/middlewares.js index bf8029844a..6479987ba4 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -539,9 +539,9 @@ export const addRateLimit = (route, config, cloud) => { 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('connect', () => { }); + client.on('reconnecting', () => { }); + client.on('ready', () => { }); redisStore.connectionPromise = async () => { if (client.isOpen) { return;