From 25ec8226ce98bce3910b40f70e7a382970b3f5be Mon Sep 17 00:00:00 2001 From: ulsreall Date: Tue, 2 Jun 2026 16:56:20 +0000 Subject: [PATCH 1/2] fix: GraphQL "Did you mean" validation suggestions disclose schema to unauthenticated callers (GHSA-8cph-rgr4-g5vj) GraphQL 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. Fix: Add SchemaSuggestionsControlPlugin that hooks into the validation phase and strips "Did you mean" suffixes from error messages for callers that are not master-key or maintenance-key authenticated, and public introspection is not enabled. --- spec/ParseGraphQLServer.spec.js | 109 ++++++++++++++++++++++++++++++ src/GraphQL/ParseGraphQLServer.js | 29 +++++++- 2 files changed, 137 insertions(+), 1 deletion(-) 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(); From 18d58d8d5cd06c1a03a84bef9b8927e222153da9 Mon Sep 17 00:00:00 2001 From: ulsreall Date: Wed, 3 Jun 2026 09:52:04 +0000 Subject: [PATCH 2/2] test: add KnownTypeNamesRule regression test for schema suggestion stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a test case verifying that 'Did you mean' suggestions for unknown type names (e.g. UsreWhereInput → UserWhereInput) are stripped for unauthenticated callers, covering the KnownTypeNamesRule validation path that was documented but not pinned by a regression test. Refs: CodeRabbit review feedback on PR #10491 --- spec/ParseGraphQLServer.spec.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 634bcf3d23..d1f02bd12e 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -1059,6 +1059,27 @@ describe('ParseGraphQLServer', () => { } }); + it('should strip "Did you mean" type suggestions from validation errors without master or maintenance key', async () => { + try { + await apolloClient.query({ + query: gql` + query UnknownType($where: UsreWhereInput) { + health + } + `, + variables: { + where: {}, + }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const message = e.networkError.result.errors[0].message; + expect(message).toContain('Unknown type'); + expect(message).not.toMatch(/Did you mean/); + expect(message).not.toContain('UserWhereInput'); + } + }); + it('should keep "Did you mean" suggestions with master key', async () => { try { await apolloClient.query({