Skip to content

Commit 4686454

Browse files
Hide "did you mean" suggestions via internal plugin to avoid leaking schema information (#7916)
It was previously discussed (see: #3919) to wait for graphql/graphql-js#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
1 parent d8e6da5 commit 4686454

File tree

9 files changed

+183
-2
lines changed

9 files changed

+183
-2
lines changed

.changeset/pretty-buckets-develop.md

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@apollo/server': minor
3+
---
4+
5+
Add `hideSchemaDetailsFromClientErrors` option to ApolloServer to allow hiding 'did you mean' suggestions from validation errors.
6+
7+
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
8+
will be met with a validation error that includes a helpful "did you mean" as part of the error text.
9+
10+
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"?`.
11+
12+
We recommend enabling this option in production to avoid leaking information about your schema to malicious actors.
13+
14+
To enable, set this option to `true` in your `ApolloServer` options:
15+
16+
```javascript
17+
const server = new ApolloServer({
18+
typeDefs,
19+
resolvers,
20+
hideSchemaDetailsFromClientErrors: true
21+
});
22+
```

docs/source/api/apollo-server.mdx

+23
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,29 @@ The default value is `true`, **unless** the `NODE_ENV` environment variable is s
149149
</tr>
150150

151151
<tr>
152+
153+
<tr>
154+
<td>
155+
156+
###### `hideSchemaDetailsFromClientErrors`
157+
158+
`boolean`
159+
160+
</td>
161+
162+
<td>
163+
164+
If `true`, Apollo Server will strip out "did you mean" suggestions when an operation fails validation.
165+
166+
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"?`.
167+
168+
The default value is `false` but we recommend enabling this option in production to avoid leaking information about your schema.
169+
170+
</td>
171+
</tr>
172+
173+
<tr>
174+
152175
<td>
153176

154177
###### `fieldResolver`

packages/server/package.json

+8
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@
5555
"import": "./dist/esm/plugin/disabled/index.js",
5656
"require": "./dist/cjs/plugin/disabled/index.js"
5757
},
58+
"./plugin/disableSuggestions": {
59+
"types": {
60+
"require": "./dist/cjs/plugin/disableSuggestions/index.d.ts",
61+
"default": "./dist/esm/plugin/disableSuggestions/index.d.ts"
62+
},
63+
"import": "./dist/esm/plugin/disableSuggestions/index.js",
64+
"require": "./dist/cjs/plugin/disableSuggestions/index.js"
65+
},
5866
"./plugin/drainHttpServer": {
5967
"types": {
6068
"require": "./dist/cjs/plugin/drainHttpServer/index.d.ts",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@apollo/server/plugin/disableSuggestions",
3+
"type": "module",
4+
"main": "../../dist/cjs/plugin/disableSuggestions/index.js",
5+
"module": "../../dist/esm/plugin/disableSuggestions/index.js",
6+
"types": "../../dist/esm/plugin/disableSuggestions/index.d.ts",
7+
"sideEffects": false
8+
}

packages/server/src/ApolloServer.ts

+21-1
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export interface ApolloServerInternals<TContext extends BaseContext> {
175175

176176
rootValue?: ((parsedQuery: DocumentNode) => unknown) | unknown;
177177
validationRules: Array<ValidationRule>;
178+
hideSchemaDetailsFromClientErrors: boolean;
178179
fieldResolver?: GraphQLFieldResolver<any, TContext>;
179180
// TODO(AS5): remove OR warn + ignore with this option set, ignore option and
180181
// flip default behavior.
@@ -281,6 +282,8 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
281282
};
282283

283284
const introspectionEnabled = config.introspection ?? isDev;
285+
const hideSchemaDetailsFromClientErrors =
286+
config.hideSchemaDetailsFromClientErrors ?? false;
284287

285288
// We continue to allow 'bounded' for backwards-compatibility with the AS3.9
286289
// API.
@@ -298,6 +301,7 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
298301
...(config.validationRules ?? []),
299302
...(introspectionEnabled ? [] : [NoIntrospection]),
300303
],
304+
hideSchemaDetailsFromClientErrors,
301305
dangerouslyDisableValidation:
302306
config.dangerouslyDisableValidation ?? false,
303307
fieldResolver: config.fieldResolver,
@@ -834,7 +838,12 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
834838
}
835839

836840
private async addDefaultPlugins() {
837-
const { plugins, apolloConfig, nodeEnv } = this.internals;
841+
const {
842+
plugins,
843+
apolloConfig,
844+
nodeEnv,
845+
hideSchemaDetailsFromClientErrors,
846+
} = this.internals;
838847
const isDev = nodeEnv !== 'production';
839848

840849
const alreadyHavePluginWithInternalId = (id: InternalPluginId) =>
@@ -993,6 +1002,17 @@ export class ApolloServer<in out TContext extends BaseContext = BaseContext> {
9931002
plugin.__internal_installed_implicitly__ = true;
9941003
plugins.push(plugin);
9951004
}
1005+
1006+
{
1007+
const alreadyHavePlugin =
1008+
alreadyHavePluginWithInternalId('DisableSuggestions');
1009+
if (hideSchemaDetailsFromClientErrors && !alreadyHavePlugin) {
1010+
const { ApolloServerPluginDisableSuggestions } = await import(
1011+
'./plugin/disableSuggestions/index.js'
1012+
);
1013+
plugins.push(ApolloServerPluginDisableSuggestions());
1014+
}
1015+
}
9961016
}
9971017

9981018
public addPlugin(plugin: ApolloServerPlugin<TContext>) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { ApolloServer, HeaderMap } from '../../..';
2+
import { describe, it, expect } from '@jest/globals';
3+
import assert from 'assert';
4+
5+
describe('ApolloServerPluginDisableSuggestions', () => {
6+
async function makeServer({
7+
withPlugin,
8+
query,
9+
}: {
10+
withPlugin: boolean;
11+
query: string;
12+
}) {
13+
const server = new ApolloServer({
14+
typeDefs: 'type Query {hello: String}',
15+
resolvers: {
16+
Query: {
17+
hello() {
18+
return 'asdf';
19+
},
20+
},
21+
},
22+
hideSchemaDetailsFromClientErrors: withPlugin,
23+
});
24+
25+
await server.start();
26+
27+
try {
28+
return await server.executeHTTPGraphQLRequest({
29+
httpGraphQLRequest: {
30+
method: 'POST',
31+
headers: new HeaderMap([['apollo-require-preflight', 't']]),
32+
search: '',
33+
body: {
34+
query,
35+
},
36+
},
37+
context: async () => ({}),
38+
});
39+
} finally {
40+
await server.stop();
41+
}
42+
}
43+
44+
it('should not hide suggestions when plugin is not enabled', async () => {
45+
const response = await makeServer({
46+
withPlugin: false,
47+
query: `#graphql
48+
query {
49+
help
50+
}
51+
`,
52+
});
53+
54+
assert(response.body.kind === 'complete');
55+
expect(JSON.parse(response.body.string).errors[0].message).toBe(
56+
'Cannot query field "help" on type "Query". Did you mean "hello"?',
57+
);
58+
});
59+
60+
it('should hide suggestions when plugin is enabled', async () => {
61+
const response = await makeServer({
62+
withPlugin: true,
63+
query: `#graphql
64+
query {
65+
help
66+
}
67+
`,
68+
});
69+
70+
assert(response.body.kind === 'complete');
71+
expect(JSON.parse(response.body.string).errors[0].message).toBe(
72+
'Cannot query field "help" on type "Query".',
73+
);
74+
});
75+
});

packages/server/src/externalTypes/constructor.ts

+1
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ interface ApolloServerOptionsBase<TContext extends BaseContext> {
9393
value: FormattedExecutionResult,
9494
) => string | Promise<string>;
9595
introspection?: boolean;
96+
hideSchemaDetailsFromClientErrors?: boolean;
9697
plugins?: ApolloServerPlugin<TContext>[];
9798
persistedQueries?: PersistedQueryOptions | false;
9899
stopOnTerminationSignals?: boolean;

packages/server/src/internalPlugin.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export type InternalPluginId =
3030
| 'LandingPageDisabled'
3131
| 'SchemaReporting'
3232
| 'InlineTrace'
33-
| 'UsageReporting';
33+
| 'UsageReporting'
34+
| 'DisableSuggestions';
3435

3536
export function pluginIsInternal<TContext extends BaseContext>(
3637
plugin: ApolloServerPlugin<TContext>,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ApolloServerPlugin } from '../../externalTypes/index.js';
2+
import { internalPlugin } from '../../internalPlugin.js';
3+
4+
export function ApolloServerPluginDisableSuggestions(): ApolloServerPlugin {
5+
return internalPlugin({
6+
__internal_plugin_id__: 'DisableSuggestions',
7+
__is_disabled_plugin__: false,
8+
async requestDidStart() {
9+
return {
10+
async validationDidStart() {
11+
return async (validationErrors) => {
12+
validationErrors?.forEach((error) => {
13+
error.message = error.message.replace(
14+
/ ?Did you mean(.+?)\?$/,
15+
'',
16+
);
17+
});
18+
};
19+
},
20+
};
21+
},
22+
});
23+
}

0 commit comments

Comments
 (0)