Skip to content

Commit

Permalink
Hide "did you mean" suggestions via internal plugin to avoid leaking …
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
andrewmcgivery committed Aug 8, 2024
1 parent d8e6da5 commit 4686454
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 2 deletions.
22 changes: 22 additions & 0 deletions .changeset/pretty-buckets-develop.md
Original file line number Diff line number Diff line change
@@ -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
});
```
23 changes: 23 additions & 0 deletions docs/source/api/apollo-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/server/plugin/disableSuggestions/package.json
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 21 additions & 1 deletion packages/server/src/ApolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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".',
);
});
});
1 change: 1 addition & 0 deletions packages/server/src/externalTypes/constructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/internalPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export type InternalPluginId =
| 'LandingPageDisabled'
| 'SchemaReporting'
| 'InlineTrace'
| 'UsageReporting';
| 'UsageReporting'
| 'DisableSuggestions';

export function pluginIsInternal<TContext extends BaseContext>(
plugin: ApolloServerPlugin<TContext>,
Expand Down
23 changes: 23 additions & 0 deletions packages/server/src/plugin/disableSuggestions/index.ts
Original file line number Diff line number Diff line change
@@ -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(.+?)\?$/,
'',
);
});
};
},
};
},
});
}

0 comments on commit 4686454

Please sign in to comment.