Repository: apollographql/apollo-server (ts)

Issue:
Disable suggestions in errors message
## Context 
We have some mutation defined:
```graphql
  extend type Mutation {
    updateUserDetail(input: UpdateUserDetailsInput!): UpdateUserDetailsPayload
  }
```
The user sends the following query:
```graphql
mutation {
  updateUserDetil {
    user {
        id
    }
  }
}
```
The server will response with an error maintaining in the message: `"message": "Cannot query field \"updateUserDetil\" on type \"Mutation\". Did you mean \"updateUserDetail\"?",`

## The problem
In case of a private API, we generally want to avoid leaking information about our API. Disabling the introspection is a good step, but the recommendations are leaking some information that can be used by attackers. This [talk](https://youtu.be/NPDp7GHmMa0?t=1906) discuss this issue (from the perspective of a pentester).

## Propositions
1. Remove suggestions for any environment that is not development
2. Add a setting to force override the previous change


Pull request:
Hide "did you mean" suggestions via internal plugin to avoid leaking schema information
It was previously discussed (see: https://github.com/apollographql/apollo-server/issues/3919) to wait for https://github.com/graphql/graphql-js/issues/2247 to close, however, that issue has not moved in years and in the mean time libraries and frameworks seem to have opted for implementing their own solutions (E.g. https://github.com/Escape-Technologies/graphql-armor/blob/main/packages/plugins/block-field-suggestions/src/index.ts).

This should be a very low impact change that achieves the goal that would also be easy enough to rip out if this gets properly implemented in graphql-js later.


Adds `hideSchemaDetailsFromClientErrors` option to ApolloServer to allow hiding of these suggestions.

Before: `Cannot query field "helloo" on type "Query". Did you mean "hello"?`
After: `Cannot query field "helloo" on type "Query".`

Fixes #3919

Patch:
diff --git a/.changeset/pretty-buckets-develop.md b/.changeset/pretty-buckets-develop.md
new file mode 100644
index 000000000..396f38860
--- /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 a006f9835..89ae1522e 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
 </tr>
 
 <tr>
+
+<tr>
+<td>
+
+###### `hideSchemaDetailsFromClientErrors`
+
+`boolean`
+
+</td>
+
+<td>
+
+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.
+
+</td>
+</tr>
+
+<tr>
+
 <td>
 
 ###### `fieldResolver`
diff --git a/packages/server/package.json b/packages/server/package.json
index dce558554..773355361 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 000000000..e65b8675e
--- /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 145919e31..4a9c8d97c 100644
--- a/packages/server/src/ApolloServer.ts
+++ b/packages/server/src/ApolloServer.ts
@@ -175,6 +175,7 @@ export interface ApolloServerInternals<TContext extends BaseContext> {
 
   rootValue?: ((parsedQuery: DocumentNode) => unknown) | unknown;
   validationRules: Array<ValidationRule>;
+  hideSchemaDetailsFromClientErrors: boolean;
   fieldResolver?: GraphQLFieldResolver<any, TContext>;
   // TODO(AS5): remove OR warn + ignore with this option set, ignore option and
   // flip default behavior.
@@ -281,6 +282,8 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
         };
 
     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<in out TContext extends BaseContext = BaseContext> {
         ...(config.validationRules ?? []),
         ...(introspectionEnabled ? [] : [NoIntrospection]),
       ],
+      hideSchemaDetailsFromClientErrors,
       dangerouslyDisableValidation:
         config.dangerouslyDisableValidation ?? false,
       fieldResolver: config.fieldResolver,
@@ -834,7 +838,12 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
   }
 
   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<in out TContext extends BaseContext = BaseContext> {
       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<TContext>) {
diff --git a/packages/server/src/externalTypes/constructor.ts b/packages/server/src/externalTypes/constructor.ts
index 839a2daae..6d6f354f6 100644
--- a/packages/server/src/externalTypes/constructor.ts
+++ b/packages/server/src/externalTypes/constructor.ts
@@ -93,6 +93,7 @@ interface ApolloServerOptionsBase<TContext extends BaseContext> {
     value: FormattedExecutionResult,
   ) => string | Promise<string>;
   introspection?: boolean;
+  hideSchemaDetailsFromClientErrors?: boolean;
   plugins?: ApolloServerPlugin<TContext>[];
   persistedQueries?: PersistedQueryOptions | false;
   stopOnTerminationSignals?: boolean;
diff --git a/packages/server/src/internalPlugin.ts b/packages/server/src/internalPlugin.ts
index 206f6a326..c357ad926 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<TContext extends BaseContext>(
   plugin: ApolloServerPlugin<TContext>,
diff --git a/packages/server/src/plugin/disableSuggestions/index.ts b/packages/server/src/plugin/disableSuggestions/index.ts
new file mode 100644
index 000000000..6a76e8436
--- /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(.+?)\?$/,
+                '',
+              );
+            });
+          };
+        },
+      };
+    },
+  });
+}


Test patch:
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 000000000..95eec2c6a
--- /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".',
+    );
+  });
+});