Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hide "did you mean" suggestions via internal plugin to avoid leaking schema information #7916

Merged
Merged
5 changes: 5 additions & 0 deletions .changeset/pretty-buckets-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@apollo/server': minor
---

Add `hideSchemaDetailsFromClientErrors` option to ApolloServer to allow hiding 'did you mean' suggestions from validation errors
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
25 changes: 25 additions & 0 deletions docs/source/api/apollo-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,31 @@ 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.

<!--- cSpell:disable -->
For example, with this option set to `true`, an error would read `Cannot query field "helloo" on type "Query".` whereas with this option set to `false` it would read `Cannot query field "helloo" on type "Query". Did you mean "hello"?`.
<!--- cSpell:enable -->

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,81 @@
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,
// cSpell:disable
query: `#graphql
query {
helloo
}
`,
// cSpell:enable
});

assert(response.body.kind === 'complete');
expect(JSON.parse(response.body.string).errors[0].message).toBe(
// cspell:disable-next-line
'Cannot query field "helloo" on type "Query". Did you mean "hello"?',
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
);
});

it('should hide suggestions when plugin is enabled', async () => {
const response = await makeServer({
withPlugin: true,
// cSpell:disable
query: `#graphql
query {
helloo
}
`,
// cSpell:enable
});

assert(response.body.kind === 'complete');
expect(JSON.parse(response.body.string).errors[0].message).toBe(
// cspell:disable-next-line
'Cannot query field "helloo" 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(.+?)\?$/,
'',
);
});
};
},
};
},
});
}