Skip to content

Keep a query-carried credential out of GraphQL introspection's failure log - #1575

Open
GeiserX wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
GeiserX:fix/graphql-introspection-credential-log
Open

Keep a query-carried credential out of GraphQL introspection's failure log#1575
GeiserX wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
GeiserX:fix/graphql-introspection-credential-log

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 13, 2026

Copy link
Copy Markdown

TL;DR

A GraphQL connection whose key rides in the query string gets that key written to the process log whenever introspection fails. query is a supported credential carrier, so an endpoint can legitimately be reached with ?token=<secret>.

One-line cause: the request is built from a URL string, and HttpClientRequest.setUrl keeps a string verbatim as request.url. Every HttpClientError renders ${method} ${request.url} into its message, and introspection logs the raw cause.

Fix: build the request from a URL object. setUrl then moves the query into request.urlParams and clears it from request.url, so the secret is gone from the message — and from anything else that renders the request URL. Nothing changes on the wire.


The path, precisely

  1. introspect folds the query parameters into the endpoint and passes the result to HttpClientRequest.post.
  2. Passed a string, setUrl stores it as request.url unchanged. (Passed a URL, it splits searchParams into urlParams and clears search — that difference is the whole fix.)
  3. HttpClientError's methodAndUrl getter is `${this.request.method} ${this.request.url}`, and message is built from it.
  4. Effect.tapCause(cause => Effect.logError("graphql introspection request failed", cause)) renders that through Cause.pretty, which rebuilds the first line from the live message getter.

Triggered by an ordinary transport failure — dead host, connection refused, TLS error — and by the sibling DecodeError path when a reachable endpoint returns non-JSON. Both reached on every credentialed introspection, via the health check and the connect-time introspect.

This is a path that bypasses the plugin's own precedent: the sibling invoke path already strips the query for telemetry with endpointForTelemetry, so the query string is treated as credential-bearing elsewhere in the same plugin.

Why fix it at the request rather than by scrubbing the log

Scrubbing the log would fix one call site. Building the request correctly keeps the secret out of request.url entirely, so every future renderer of that URL — a new log line, a span attribute, an error surfaced to a caller — is covered without anyone having to remember.

The endpoint's own query string is handled the same way, not just the separately-supplied parameters, since a configured endpoint can carry a credential too.

Tests

introspect-credential-logging.test.ts installs a capturing logger and asserts on what was actually written, rather than on a model of it. Three cases: a query-parameter credential, the same credential in the endpoint's own query, and the wire check.

Both directions are pinned. The tests assert the secret is absent from the log and that it is still sent to the upstream — a "fix" that stopped sending the parameter would satisfy the first on its own and silently break authentication. There is also a positive control asserting the failure was logged at all, so an empty capture cannot pass.

Mutation-checked, each mutation verified to have landed, with an unmutated control before and after:

mutation result
hand post a string again (i.e. revert this PR) killed (2 tests)
stop applying the query parameters killed (the wire assertion)
stop logging the failure entirely killed (the positive control)

Package: 100 passed / 12 files. tsgo --noEmit, oxlint --deny-warnings and oxfmt --check clean on the changed files.

…e log

query is a supported credential carrier, so an endpoint can be reached with
?token=<secret>. Introspection built its request from a URL string, and
setUrl keeps a string verbatim as request.url; every HttpClientError renders
method + request.url into its message getter, and the failure cause is logged
raw. So a transport failure or a non-JSON response wrote the secret to the log.

Build the request from a URL object instead. setUrl then moves the query into
request.urlParams and clears it from request.url, so the secret is absent from
the message and from anything else rendering the URL. The client recombines the
two when it executes, so nothing changes on the wire.

Handles the endpoint's own query string too, since a configured endpoint can
carry a credential.
@GeiserX

GeiserX commented Aug 13, 2026

Copy link
Copy Markdown
Author

Context for this one: #1585 explains why this PR and twelve others exist — they came out of a single pass over credential handling, asking for each credential where it ends up, how long it stays, and who can read it once it's there.

This PR stands alone and doesn't depend on any of the others.

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