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
+
###### `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(.+?)\?$/,
+ '',
+ );
+ });
+ };
+ },
+ };
+ },
+ });
+}
|