diff --git a/.changeset/pretty-buckets-develop.md b/.changeset/pretty-buckets-develop.md new file mode 100644 index 00000000000..396f388602d --- /dev/null +++ b/.changeset/pretty-buckets-develop.md @@ -0,0 +1,22 @@ +--- +'@apollo/server': minor +--- + +Add `hideSchemaDetailsFromClientErrors` option to ApolloServer to allow hiding 'did you mean' suggestions from validation errors. + +Even with introspection disabled, it is possible to "fuzzy test" a graph manually or with automated tools to try to determine the shape of your schema. This is accomplished by taking advantage of the default behavior where a misspelt field in an operation +will be met with a validation error that includes a helpful "did you mean" as part of the error text. + +For example, with this option set to `true`, an error would read `Cannot query field "help" on type "Query".` whereas with this option set to `false` it would read `Cannot query field "help" on type "Query". Did you mean "hello"?`. + +We recommend enabling this option in production to avoid leaking information about your schema to malicious actors. + +To enable, set this option to `true` in your `ApolloServer` options: + +```javascript +const server = new ApolloServer({ + typeDefs, + resolvers, + hideSchemaDetailsFromClientErrors: true +}); +``` diff --git a/docs/source/api/apollo-server.mdx b/docs/source/api/apollo-server.mdx index a006f983531..89ae1522e22 100644 --- a/docs/source/api/apollo-server.mdx +++ b/docs/source/api/apollo-server.mdx @@ -149,6 +149,29 @@ The default value is `true`, **unless** the `NODE_ENV` environment variable is s + + + + +###### `hideSchemaDetailsFromClientErrors` + +`boolean` + + + + + +If `true`, Apollo Server will strip out "did you mean" suggestions when an operation fails validation. + +For example, with this option set to `true`, an error would read `Cannot query field "help" on type "Query".` whereas with this option set to `false` it would read `Cannot query field "help" on type "Query". Did you mean "hello"?`. + +The default value is `false` but we recommend enabling this option in production to avoid leaking information about your schema. + + + + + + ###### `fieldResolver` diff --git a/packages/server/package.json b/packages/server/package.json index dce558554da..77335536190 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -55,6 +55,14 @@ "import": "./dist/esm/plugin/disabled/index.js", "require": "./dist/cjs/plugin/disabled/index.js" }, + "./plugin/disableSuggestions": { + "types": { + "require": "./dist/cjs/plugin/disableSuggestions/index.d.ts", + "default": "./dist/esm/plugin/disableSuggestions/index.d.ts" + }, + "import": "./dist/esm/plugin/disableSuggestions/index.js", + "require": "./dist/cjs/plugin/disableSuggestions/index.js" + }, "./plugin/drainHttpServer": { "types": { "require": "./dist/cjs/plugin/drainHttpServer/index.d.ts", diff --git a/packages/server/plugin/disableSuggestions/package.json b/packages/server/plugin/disableSuggestions/package.json new file mode 100644 index 00000000000..e65b8675e27 --- /dev/null +++ b/packages/server/plugin/disableSuggestions/package.json @@ -0,0 +1,8 @@ +{ + "name": "@apollo/server/plugin/disableSuggestions", + "type": "module", + "main": "../../dist/cjs/plugin/disableSuggestions/index.js", + "module": "../../dist/esm/plugin/disableSuggestions/index.js", + "types": "../../dist/esm/plugin/disableSuggestions/index.d.ts", + "sideEffects": false +} diff --git a/packages/server/src/ApolloServer.ts b/packages/server/src/ApolloServer.ts index 145919e3190..4a9c8d97c9f 100644 --- a/packages/server/src/ApolloServer.ts +++ b/packages/server/src/ApolloServer.ts @@ -175,6 +175,7 @@ export interface ApolloServerInternals { rootValue?: ((parsedQuery: DocumentNode) => unknown) | unknown; validationRules: Array; + hideSchemaDetailsFromClientErrors: boolean; fieldResolver?: GraphQLFieldResolver; // TODO(AS5): remove OR warn + ignore with this option set, ignore option and // flip default behavior. @@ -281,6 +282,8 @@ export class ApolloServer { }; const introspectionEnabled = config.introspection ?? isDev; + const hideSchemaDetailsFromClientErrors = + config.hideSchemaDetailsFromClientErrors ?? false; // We continue to allow 'bounded' for backwards-compatibility with the AS3.9 // API. @@ -298,6 +301,7 @@ export class ApolloServer { ...(config.validationRules ?? []), ...(introspectionEnabled ? [] : [NoIntrospection]), ], + hideSchemaDetailsFromClientErrors, dangerouslyDisableValidation: config.dangerouslyDisableValidation ?? false, fieldResolver: config.fieldResolver, @@ -834,7 +838,12 @@ export class ApolloServer { } private async addDefaultPlugins() { - const { plugins, apolloConfig, nodeEnv } = this.internals; + const { + plugins, + apolloConfig, + nodeEnv, + hideSchemaDetailsFromClientErrors, + } = this.internals; const isDev = nodeEnv !== 'production'; const alreadyHavePluginWithInternalId = (id: InternalPluginId) => @@ -993,6 +1002,17 @@ export class ApolloServer { plugin.__internal_installed_implicitly__ = true; plugins.push(plugin); } + + { + const alreadyHavePlugin = + alreadyHavePluginWithInternalId('DisableSuggestions'); + if (hideSchemaDetailsFromClientErrors && !alreadyHavePlugin) { + const { ApolloServerPluginDisableSuggestions } = await import( + './plugin/disableSuggestions/index.js' + ); + plugins.push(ApolloServerPluginDisableSuggestions()); + } + } } public addPlugin(plugin: ApolloServerPlugin) { diff --git a/packages/server/src/__tests__/plugin/disableSuggestions/disableSuggestions.test.ts b/packages/server/src/__tests__/plugin/disableSuggestions/disableSuggestions.test.ts new file mode 100644 index 00000000000..95eec2c6a16 --- /dev/null +++ b/packages/server/src/__tests__/plugin/disableSuggestions/disableSuggestions.test.ts @@ -0,0 +1,75 @@ +import { ApolloServer, HeaderMap } from '../../..'; +import { describe, it, expect } from '@jest/globals'; +import assert from 'assert'; + +describe('ApolloServerPluginDisableSuggestions', () => { + async function makeServer({ + withPlugin, + query, + }: { + withPlugin: boolean; + query: string; + }) { + const server = new ApolloServer({ + typeDefs: 'type Query {hello: String}', + resolvers: { + Query: { + hello() { + return 'asdf'; + }, + }, + }, + hideSchemaDetailsFromClientErrors: withPlugin, + }); + + await server.start(); + + try { + return await server.executeHTTPGraphQLRequest({ + httpGraphQLRequest: { + method: 'POST', + headers: new HeaderMap([['apollo-require-preflight', 't']]), + search: '', + body: { + query, + }, + }, + context: async () => ({}), + }); + } finally { + await server.stop(); + } + } + + it('should not hide suggestions when plugin is not enabled', async () => { + const response = await makeServer({ + withPlugin: false, + query: `#graphql + query { + help + } + `, + }); + + assert(response.body.kind === 'complete'); + expect(JSON.parse(response.body.string).errors[0].message).toBe( + 'Cannot query field "help" on type "Query". Did you mean "hello"?', + ); + }); + + it('should hide suggestions when plugin is enabled', async () => { + const response = await makeServer({ + withPlugin: true, + query: `#graphql + query { + help + } + `, + }); + + assert(response.body.kind === 'complete'); + expect(JSON.parse(response.body.string).errors[0].message).toBe( + 'Cannot query field "help" on type "Query".', + ); + }); +}); diff --git a/packages/server/src/externalTypes/constructor.ts b/packages/server/src/externalTypes/constructor.ts index 839a2daaea9..6d6f354f655 100644 --- a/packages/server/src/externalTypes/constructor.ts +++ b/packages/server/src/externalTypes/constructor.ts @@ -93,6 +93,7 @@ interface ApolloServerOptionsBase { value: FormattedExecutionResult, ) => string | Promise; introspection?: boolean; + hideSchemaDetailsFromClientErrors?: boolean; plugins?: ApolloServerPlugin[]; persistedQueries?: PersistedQueryOptions | false; stopOnTerminationSignals?: boolean; diff --git a/packages/server/src/internalPlugin.ts b/packages/server/src/internalPlugin.ts index 206f6a32671..c357ad92677 100644 --- a/packages/server/src/internalPlugin.ts +++ b/packages/server/src/internalPlugin.ts @@ -30,7 +30,8 @@ export type InternalPluginId = | 'LandingPageDisabled' | 'SchemaReporting' | 'InlineTrace' - | 'UsageReporting'; + | 'UsageReporting' + | 'DisableSuggestions'; export function pluginIsInternal( plugin: ApolloServerPlugin, diff --git a/packages/server/src/plugin/disableSuggestions/index.ts b/packages/server/src/plugin/disableSuggestions/index.ts new file mode 100644 index 00000000000..6a76e8436f4 --- /dev/null +++ b/packages/server/src/plugin/disableSuggestions/index.ts @@ -0,0 +1,23 @@ +import type { ApolloServerPlugin } from '../../externalTypes/index.js'; +import { internalPlugin } from '../../internalPlugin.js'; + +export function ApolloServerPluginDisableSuggestions(): ApolloServerPlugin { + return internalPlugin({ + __internal_plugin_id__: 'DisableSuggestions', + __is_disabled_plugin__: false, + async requestDidStart() { + return { + async validationDidStart() { + return async (validationErrors) => { + validationErrors?.forEach((error) => { + error.message = error.message.replace( + / ?Did you mean(.+?)\?$/, + '', + ); + }); + }; + }, + }; + }, + }); +}