Repository navigation
fix(graphql-server): never mask errors; same error rules in every environment #1878
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import crypto from 'node:crypto'; | ||
|
|
||
| import { type ErrorContext, parse } from '@constructive-io/errors'; | ||
| import { Logger } from '@pgpmjs/logger'; | ||
| import { type GraphQLError, type GraphQLFormattedError } from 'graphql'; | ||
|
|
||
| const formatErrorLog = new Logger('graphile:formatError'); | ||
|
|
||
| /** | ||
| * An error the GraphQL layer raised about the *request*, before any resolver | ||
| * ran: an unknown input field, a value of the wrong type, a missing required | ||
| * variable. graphql-js reports variable coercion without an `extensions.code` | ||
| * (unlike parse/validation, which carry `GRAPHQL_PARSE_FAILED` / | ||
| * `GRAPHQL_VALIDATION_FAILED`), so code-based classification alone would read | ||
| * it as a server failure instead of the client's own malformed query. | ||
| * | ||
| * A request error is answered before a field is resolved, so it carries no | ||
| * response `path` — every execution error has one. Coercion wraps the inner | ||
| * complaint about the value, so `originalError` may be set, but only ever to | ||
| * another GraphQL-layer error: anything a resolver or the database threw arrives | ||
| * as a foreign error (a pg error, an `Error`). The wrap | ||
| * is recognized by name rather than by `instanceof`, because the error is raised | ||
| * by whichever copy of graphql-js grafast resolved, not by this package's. | ||
| */ | ||
| const isGraphQLLayerError = (value: unknown): boolean => | ||
| value == null || | ||
| ((value as Error).name === 'GraphQLError' && | ||
| isGraphQLLayerError((value as GraphQLError).originalError)); | ||
|
|
||
| const isRequestError = (error: GraphQLError): boolean => | ||
| error.path == null && isGraphQLLayerError((error as { originalError?: unknown }).originalError); | ||
|
|
||
| /** The code a request error carries when graphql-js supplied none. */ | ||
| const BAD_USER_INPUT = 'BAD_USER_INPUT'; | ||
|
|
||
| /** The code any other error carries when nothing supplied one. */ | ||
| const INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR'; | ||
|
|
||
| /** | ||
| * Normalize any GraphQL/database error into a canonical Constructive shape. | ||
| * | ||
| * Database errors surface through Grafast without a populated `extensions.code` | ||
| * (the semantic code lives in the message, and any SQLSTATE/DETAIL lives on the | ||
| * underlying pg error at `originalError`). We parse `originalError` first so we | ||
| * can recover the structured code, then fall back to the GraphQL error itself. | ||
| */ | ||
| export const normalizeError = ( | ||
| error: GraphQLError, | ||
| ): { code: string | null; context: ErrorContext; class: 'public' | 'internal' } => { | ||
| const original = (error as { originalError?: unknown }).originalError; | ||
| const fromOriginal = original ? parse(original) : null; | ||
| const parsed = fromOriginal?.code ? fromOriginal : parse(error); | ||
| return { code: parsed.code, context: parsed.context, class: parsed.class }; | ||
| }; | ||
|
|
||
| /** | ||
| * Format every GraphQL error the same way, in every environment. Nothing is | ||
| * masked: the client always receives the real message. | ||
| * | ||
| * 1. Lift the structured code onto `extensions.code`/`class`/`context` from the | ||
| * parsed error, so database errors reach clients with a machine-readable | ||
| * code instead of a bare message with empty `extensions`. | ||
| * 2. A request error graphql-js raised without a code is `BAD_USER_INPUT`. | ||
| * 3. Any other error without a code is `INTERNAL_SERVER_ERROR`. | ||
| * 4. Every internal error (unknown, or registered as internal) carries an | ||
| * `errorId` and is logged under it, so a report can be matched to the log. | ||
| */ | ||
| export const formatError = (error: GraphQLError): GraphQLFormattedError => { | ||
| const { code, context, class: errorClass } = normalizeError(error); | ||
|
|
||
| // `extensions` is read-only on GraphQLError, so build a formatted error | ||
| // rather than mutating it. | ||
| const extensions: Record<string, unknown> = { ...error.extensions }; | ||
| if (code) { | ||
| extensions.code = code; | ||
| extensions.class = errorClass; | ||
| if (Object.keys(context).length > 0) { | ||
| extensions.context = context; | ||
| } | ||
| } | ||
|
|
||
| let internal = Boolean(code) && errorClass === 'internal'; | ||
| if (!code && !error.extensions?.code) { | ||
| const requestError = isRequestError(error); | ||
| extensions.code = requestError ? BAD_USER_INPUT : INTERNAL_SERVER_ERROR; | ||
| internal = !requestError; | ||
| } | ||
|
|
||
| if (internal) { | ||
| const errorId = crypto.randomBytes(8).toString('hex'); | ||
| extensions.errorId = errorId; | ||
| formatErrorLog.error(`[graphql-error:${errorId}]`, error); | ||
| } | ||
|
|
||
| // grafserv strips originalError before serializing to the client. | ||
| return { | ||
| message: error.message, | ||
| ...(error.locations ? { locations: error.locations } : {}), | ||
| ...(error.path ? { path: error.path } : {}), | ||
| extensions | ||
| }; | ||
|
Comment on lines
+96
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 security · medium GraphQL internal errors now reach clients unmasked
📋 Prompt for AI AgentsIn graphql/server/src/middleware/format-error.ts (formatError, lines 89-101), when |
||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 security · medium
Express 500 handler returns raw exception messages
Deleting
sanitizeMessagemakes the 500 fallback (and the 503/504 branches) returnerr.messageverbatim in the JSON body (graphql/server/src/middleware/error-handler.ts:44-50). Driver errors likeECONNREFUSED ... 10.x.x.x:5432disclose internal service topology, and unexpected exceptions disclose stack-derived internals on a public endpoint. Previously onlyisApiError/CSRF messages passed through and unknown errors got a generic message in production.📋 Prompt for AI Agents
In graphql/server/src/middleware/error-handler.ts lines 44-50, replace
err.messagewith generic client-safe copy in the three non-ApiError branches ('Service temporarily unavailable' for 503, 'Request timed out' for 504, 'An unexpected error occurred' for 500); the existinglogErrorcall already records the raw message and stack server-side.