diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 07bcd4efdf..634bcf3d23 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -1016,6 +1016,115 @@ describe('ParseGraphQLServer', () => { expect(introspection.data).toBeDefined(); expect(introspection.data.__type).toBeDefined(); }); + + it('should strip "Did you mean" field suggestions from validation errors without master or maintenance key', async () => { + try { + await apolloClient.query({ + query: gql` + query Typo { + healt + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const message = e.networkError.result.errors[0].message; + expect(message).toContain('Cannot query field "healt"'); + expect(message).not.toMatch(/Did you mean/); + expect(message).not.toContain('health'); + } + }); + + it('should strip "Did you mean" argument suggestions from validation errors without master or maintenance key', async () => { + try { + await apolloClient.query({ + query: gql` + query UnknownArg { + users(wher: {}) { + edges { + node { + id + } + } + } + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const message = e.networkError.result.errors[0].message; + expect(message).toContain('Unknown argument "wher"'); + expect(message).not.toMatch(/Did you mean/); + expect(message).not.toContain('"where"'); + } + }); + + it('should keep "Did you mean" suggestions with master key', async () => { + try { + await apolloClient.query({ + query: gql` + query Typo { + healt + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const message = e.networkError.result.errors[0].message; + expect(message).toContain('Cannot query field "healt"'); + expect(message).toMatch(/Did you mean/); + expect(message).toContain('health'); + } + }); + + it('should keep "Did you mean" suggestions with maintenance key', async () => { + try { + await apolloClient.query({ + query: gql` + query Typo { + healt + } + `, + context: { + headers: { + 'X-Parse-Maintenance-Key': 'test2', + }, + }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const message = e.networkError.result.errors[0].message; + expect(message).toContain('Cannot query field "healt"'); + expect(message).toMatch(/Did you mean/); + expect(message).toContain('health'); + } + }); + + it('should keep "Did you mean" suggestions when public introspection is enabled', async () => { + const parseServer = await reconfigureServer(); + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + + try { + await apolloClient.query({ + query: gql` + query Typo { + healt + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const message = e.networkError.result.errors[0].message; + expect(message).toContain('Cannot query field "healt"'); + expect(message).toMatch(/Did you mean/); + expect(message).toContain('health'); + } + }); }); diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 0b2c17d232..9d18f87d94 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -90,6 +90,33 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({ }); +// graphql-js validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule, +// KnownTypeNamesRule, ...) embed "Did you mean ...?" hints sourced from the live +// schema in their error messages. Those messages are returned to the caller +// before didResolveOperation runs, so they sidestep IntrospectionControlPlugin +// and disclose schema identifiers the introspection guard is meant to hide. +// Strip the hint suffix for callers that are not allowed to introspect. +const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({ + requestDidStart: async (requestContext) => ({ + validationDidStart: async () => { + if (publicIntrospection) { + return; + } + const isMasterOrMaintenance = + requestContext.contextValue.auth?.isMaster || + requestContext.contextValue.auth?.isMaintenance; + if (isMasterOrMaintenance) { + return; + } + return async (validationErrors) => { + validationErrors?.forEach(error => { + error.message = error.message.replace(/ ?Did you mean(.+?)\?$/, ''); + }); + }; + }, + }), +}); + class ParseGraphQLServer { parseGraphQLController: ParseGraphQLController; @@ -153,7 +180,7 @@ class ParseGraphQLServer { // 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 introspection: true, - plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)], + plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection), SchemaSuggestionsControlPlugin(this.config.graphQLPublicIntrospection), createComplexityValidationPlugin(() => this.parseServer.config.requestComplexity)], schema, }); await apollo.start();