Skip to content

Added masking of errors caused by unexpected exceptions - #632

Draft
xperiandri wants to merge 3 commits into
devfrom
error-masking
Draft

xperiandri wants to merge 3 commits into
devfrom
error-masking

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Problem

The raw message of an exception thrown by a field resolver reached HTTP and graphql-transport-ws clients. HTTP responses also carried the raw message of the exception behind a request error, which the WebSocket transport already replaced with a generic one. Such messages can disclose connection strings, SQL or internal paths.

Changes

  • Masking (breaking: on by default). An error whose exception is neither a GraphQLException nor an IGQLError, nor an aggregate of only such exceptions, reaches the client as Unexpected error:
    • it keeps its path, its locations and its kind extension;
    • every other extension is dropped.
  • Where it applies:
    • HTTP direct, deferred and request error responses;
    • every next and error message of graphql-transport-ws, with the incremental and completed entries of @defer and @stream.
  • Turning it off: the new GraphQLOptions.MaskUnexpectedErrors turns masking off for development.
  • Logging once per response. The exceptions masked in one response or message are logged once at the error level, with their number, their first paths and the first exception. Each one is also logged at the debug level. One entry per error would let a client multiply log entries with aliases or failing list items.
  • IGQLError messages. An error of an IGQLError exception gets the message the exception declares for clients. The default ParseError reports the exception's own message, which could leak.
  • WebSocket:
    • a query that does not parse is answered with the parser's message, as over HTTP, instead of Unexpected error during subscription;
    • a GraphQL error thrown while an operation starts keeps its message;
    • the per-operation warnings that repeated every exception with its stack trace are now debug entries.

Tests

The tests are xUnit, in AspNetCore/, over HTTP, the WebSocket connection and the subscription worker:

  • masked and kept messages, paths, locations and extensions;
  • incremental and completed entries, and request errors;
  • masking turned off;
  • logging once per message;
  • IGQLError messages, syntax errors and errors at operation start.

The behaviour fixes were checked to fail with the previous code.

An independent review ran the real handler and WebSocket connection with failing resolvers, streams and subscription sources. No masked message leaked, and deliberate errors, validation and coercion errors kept their messages.

Verification:

  • Unit tests: the whole project passes, 787 tests with 5 skipped.
  • FAKE: the full pipeline passes, including the integration tests.

Notes

  • No marker on masked errors. graphql-yoga adds extensions.code: INTERNAL_SERVER_ERROR; adding one would let clients tell masked errors apart.
  • HTTP exceptions before execution. An exception from a planning middleware or RootFactory still escapes HandleAsync as a 500, as before, while WebSocket masks it.

🤖 Generated with Claude Code

xperiandri and others added 2 commits October 3, 2026 13:08
An exception thrown by a field resolver reached HTTP and graphql-transport-ws
clients with its raw message, and HTTP responses also carried the raw message
of the exception behind a request error, which the WebSocket transport already
replaced with a generic one.

- Added the internal ErrorMasking module: an error whose exception is neither a
  GraphQLException nor an IGQLError (nor an aggregate of only such exceptions)
  is reported as "Unexpected error", keeps its path, locations and kind
  extension, and its exception is logged at the error level instead
- Added GraphQLOptions.MaskUnexpectedErrors, true by default, to turn masking
  off for development (breaking: a new field of the options record)
- The HTTP handler masks the errors of direct, deferred and request error
  responses; the WebSocket sender, the only writer of the socket, masks every
  next and error message, including incremental payloads, which replaces the
  request-error-only sanitizeRequestError
- A failed subscription source, or an exception while an operation starts,
  keeps the "Unexpected error during subscription" message unless masking is
  off, and a GraphQL error thrown while an operation starts keeps its message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The exceptions masked in one response or message are logged once at the
  error level, with their number, their first paths and the first
  exception, and each one at the debug level: an entry with a stack trace
  per masked error let a client multiply the error entries of the log
  through aliases or a list of failing items.
- An error of an IGQLError exception that the default ParseError reports
  by the message of the exception gets the message the exception declares
  for clients, as a failed subscription source already did; the internal
  message of such an exception reached clients.
- A graphql-transport-ws operation whose query does not parse is answered
  with the message of the parser, as over HTTP, instead of being reported
  and logged as an unexpected error.
- The graphql-transport-ws handler logs the errors of an operation at the
  debug level: its warnings repeated every masked exception with its stack
  trace before the sender logged it.
- A payload without errors to mask is no longer copied.
- Tests for logging once, the IGQLError message over HTTP and WebSocket,
  syntax errors over WebSocket, a GraphQL error thrown while an operation
  starts, the errors of a completed deferred fragment, and a failed source
  with masking disabled; corrected the docs and the release notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 19s ⏱️
  895 tests   890 ✅  5 💤 0 ❌
2 685 runs  2 670 ✅ 15 💤 0 ❌

Results for commit bcbec91.

♻️ This comment has been updated with latest results.

The tests open the namespaces instead of qualifying names and assert the
absence of an extension with `Assert.DoesNotContain`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant