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

feat: remove handler for validation and parse errors #1510

10 changes: 10 additions & 0 deletions .changeset/rude-cats-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@envelop/core': major
---

Remove `handleValidationErrors` and `handleParseErrors` options from `useMaskedErrors`.

> ONLY masking validation errors OR ONLY disabling introspection errors does not make sense, as both can be abused for reverse-engineering the GraphQL schema (see https://github.com/nikitastupin/clairvoyance for reverse-engineering the schema based on validation error suggestions).
> https://github.com/n1ru4l/envelop/issues/1482#issue-1340015060

Rename `formatError` function option to `maskErrorFn`
64 changes: 64 additions & 0 deletions packages/core/docs/use-masked-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#### `useMaskedErrors`

Prevent unexpected error messages from leaking to the GraphQL clients.

```ts
import { envelop, useSchema, useMaskedErrors } from '@envelop/core'
import { makeExecutableSchema, GraphQLError } from 'graphql'

const schema = makeExecutableSchema({
typeDefs: /* GraphQL */ `
type Query {
something: String!
somethingElse: String!
somethingSpecial: String!
}
`,
resolvers: {
Query: {
something: () => {
throw new GraphQLError('Error that is propagated to the clients.')
},
somethingElse: () => {
throw new Error("Unsafe error that will be masked as 'Unexpected Error.'.")
},
somethingSpecial: () => {
throw new GraphQLError('The error will have an extensions field.', {
code: 'ERR_CODE',
randomNumber: 123
})
}
}
}
})

const getEnveloped = envelop({
plugins: [useSchema(schema), useMaskedErrors()]
})
```

You may customize the default error message `Unexpected error.` with your own `errorMessage`:

```ts
const getEnveloped = envelop({
plugins: [useSchema(schema), useMaskedErrors({ errorMessage: 'Something went wrong.' })]
})
```

Or provide a custom formatter when masking the output:

```ts
import { isGraphQLError, MaskErrorFn } from '@envelop/core'

export const customFormatError: MaskErrorFn = err => {
if (isGraphQLError(err)) {
return new GraphQLError('Sorry, something went wrong.')
}

return err
}

const getEnveloped = envelop({
plugins: [useSchema(schema), useMaskedErrors({ maskErrorFn: customFormatError })]
})
```
113 changes: 113 additions & 0 deletions packages/core/src/plugins/use-masked-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Plugin, ExecutionResult } from '@envelop/types';
import { handleStreamOrSingleExecutionResult } from '../utils.js';

export const DEFAULT_ERROR_MESSAGE = 'Unexpected error.';

export type MaskError = (error: unknown, message: string) => Error;

export type SerializableGraphQLErrorLike = Error & {
name: 'GraphQLError';
toJSON(): { message: string };
saihaj marked this conversation as resolved.
Show resolved Hide resolved
extensions?: Record<string, unknown>;
};

export function isGraphQLError(error: unknown): error is Error & { originalError?: Error } {
return error instanceof Error && error.name === 'GraphQLError';
}

function createSerializableGraphQLError(
message: string,
originalError: unknown,
isDev: boolean
): SerializableGraphQLErrorLike {
const error = new Error(message) as SerializableGraphQLErrorLike;
error.name = 'GraphQLError';
if (isDev) {
const extensions =
originalError instanceof Error
? { message: originalError.message, stack: originalError.stack }
: { message: String(originalError) };

Object.defineProperty(error, 'extensions', {
get() {
return extensions;
},
});
}

Object.defineProperty(error, 'toJSON', {
value() {
return {
message: error.message,
extensions: error.extensions,
};
},
});

return error as SerializableGraphQLErrorLike;
}

export const createDefaultMaskError =
(isDev: boolean): MaskError =>
(error, message) => {
if (isGraphQLError(error)) {
if (error?.originalError) {
if (isGraphQLError(error.originalError)) {
return error;
}
return createSerializableGraphQLError(message, error, isDev);
}
return error;
}
return createSerializableGraphQLError(message, error, isDev);
};

const isDev = globalThis.process?.env?.NODE_ENV === 'development';

export const defaultMaskError: MaskError = createDefaultMaskError(isDev);

export type UseMaskedErrorsOpts = {
/** The function used for identify and mask errors. */
maskError?: MaskError;
/** The error message that shall be used for masked errors. */
errorMessage?: string;
};

const makeHandleResult =
(maskError: MaskError, message: string) =>
({ result, setResult }: { result: ExecutionResult; setResult: (result: ExecutionResult) => void }) => {
if (result.errors != null) {
setResult({ ...result, errors: result.errors.map(error => maskError(error, message)) });
}
};

export const useMaskedErrors = (opts?: UseMaskedErrorsOpts): Plugin => {
const maskError = opts?.maskError ?? defaultMaskError;
const message = opts?.errorMessage || DEFAULT_ERROR_MESSAGE;
saihaj marked this conversation as resolved.
Show resolved Hide resolved
const handleResult = makeHandleResult(maskError, message);

return {
onPluginInit(context) {
context.registerContextErrorHandler(({ error, setError }) => {
setError(maskError(error, message));
});
},
onExecute() {
return {
onExecuteDone(payload) {
return handleStreamOrSingleExecutionResult(payload, handleResult);
},
};
},
onSubscribe() {
return {
onSubscribeResult(payload) {
return handleStreamOrSingleExecutionResult(payload, handleResult);
},
onSubscribeError({ error, setError }) {
setError(maskError(error, message));
},
};
},
};
};