History

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

* Keep a query-carried credential out of GraphQL introspection's failure 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. * Fail introspection on an unparseable endpoint instead of dropping its query params * Assert the typed error without manual tag checks * Reject endpoint userinfo and pin the introspection request URL Userinfo in the endpoint (user:pass@host) stays in request.url after the query is split off, so it still rendered into HttpClientError messages. Reject it with the same invalid-endpoint failure, which echoes no part of the endpoint. Classify invalid-endpoint in the health check as an integration config problem. It previously fell through to the generic branch and told the operator to check a credential that was never sent. The urlParams split re-serializes the query through URLSearchParams, so it is not byte-transparent: %20 becomes +, ~ is percent-encoded, and a valueless flag gains an =. Raw bytes only survive inside request.url, the one field every error message renders, so log safety wins. Document the normalization in the changeset and pin the exact dialed URLs by test. ---------

Sergio Fernández committed 2 months ago

Commit e5526f31 parent 4879d477 files · +467 −11Browse files

Unified · Split

7 files
  1. .changeset/graphql-introspection-credential-log.md +24 −0
  2. packages/plugins/graphql/src/sdk/errors.ts +1 −0
  3. packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts +139 −0
  4. packages/plugins/graphql/src/sdk/introspect-request-url.test.ts +182 −0
  5. packages/plugins/graphql/src/sdk/introspect.ts +57 −11
  6. packages/plugins/graphql/src/sdk/plugin.test.ts +50 −0
  7. packages/plugins/graphql/src/sdk/plugin.ts +14 −0
.changeset/graphql-introspection-credential-log.md+24−0
@@ -0,0 +1,24 @@
1+---
2+"executor": patch
3+---
4+
5+**GraphQL introspection no longer logs a credential carried in the endpoint URL**
6+
7+`query` is a supported credential carrier, so a GraphQL endpoint can be reached with `?token=<secret>`. Introspection built its request from a URL **string**, and `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`. Every `HttpClientError` renders `${method} ${request.url}` into its `message` getter, and introspection logs the raw failure cause — so on any transport failure or non-JSON response, the connection's secret was written to the process log.
8+
9+The request is now built from a URL **object**, which moves the query into `request.urlParams` and clears it from `request.url`. The secret is therefore absent from the error message, and from anything else that renders the request URL. The credential still reaches the upstream: the client recombines url and urlParams when it executes the request. The endpoint's own query string is handled the same way, not just the separately-supplied query parameters, since a configured endpoint can carry a credential too.
10+
11+**The query string is now normalized on the wire.** Recombination appends each pair through `URLSearchParams`, so the query is re-serialized in form-urlencoded form instead of passed through byte-for-byte:
12+
13+- a space written as `%20` is sent as `+`
14+- `~` and `!'()` are percent-encoded
15+- a valueless `?flag` is sent as `flag=`
16+
17+Key order, repeated keys, and already-encoded reserved characters are unchanged, and every parameter still decodes to the same value. This is not avoidable while the fix holds: raw query bytes only survive inside `request.url`, which is the one field every error message renders, so byte-transparency and keeping the credential out of the log cannot both hold. An upstream that signs its raw query string is the case to watch. The exact resulting URLs are pinned by test.
18+
19+Two endpoints are now rejected up front with an `invalid-endpoint` failure rather than dialed:
20+
21+- an endpoint that is not a valid URL, which cannot be split this way and would otherwise be sent without the query parameters it was asked to include
22+- an endpoint carrying userinfo (`https://user:pass@host/…`), which `URL` keeps in the origin, so it would stay in `request.url` and leak into error messages exactly the way a query-carried secret used to
23+
24+Neither rejection echoes any part of the endpoint. A health check on such an integration now reports the invalid configuration and points the operator at the endpoint URL, instead of blaming the credential that was never sent.
packages/plugins/graphql/src/sdk/errors.ts+1−0
@@ -11,6 +11,7 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIn · 10 unmodified lines
1111 Schema.Literals([
1212 "network",
1313 "http",
14+ "invalid-endpoint",
1415 "invalid-json",
1516 "invalid-shape",
1617 "missing-schema",
packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts+139−0
@@ -0,0 +1,139 @@
1+// ---------------------------------------------------------------------------
2+// Introspection must not log a credential carried in the query string.
3+//
4+// `query` is a supported credential carrier, so a GraphQL endpoint can be
5+// reached with `?token=<secret>`. Introspection logs the raw failure cause on
6+// any transport error, and every `HttpClientError` renders `${method}
7+// ${request.url}` into its message — so if the request is built from a URL
8+// STRING, the secret is inside that message and goes straight to the log.
9+//
10+// Building the request from a URL OBJECT moves the query into
11+// `request.urlParams`, out of `request.url` and therefore out of the message,
12+// while the client still recombines the two when it executes.
13+//
14+// Both directions are asserted. A test that only checked "the secret is absent"
15+// would pass just as happily against a logger that captured nothing at all, or
16+// a change that stopped sending the parameter entirely.
17+// ---------------------------------------------------------------------------
18+
19+import { describe, expect, it } from "@effect/vitest";
20+import { Cause, Effect, Layer, Logger } from "effect";
21+import { FetchHttpClient } from "effect/unstable/http";
22+
23+import { GraphqlIntrospectionError } from "./errors";
24+import { introspect } from "./introspect";
25+
26+const SECRET = "tok_live_introspection_MUST_NOT_LOG";
27+const ENDPOINT = "https://graph.example.test/graphql";
28+
29+/** Collects everything a logger would have written, message and cause alike —
30+ * `Cause.pretty` is the renderer that rebuilds the first line from the error's
31+ * live `message` getter, which is the exact path the leak took. */
32+const capturingLogger = (sink: Array<string>) =>
33+ Logger.make<unknown, void>((options) => {
34+ sink.push(String(options.message));
35+ sink.push(Cause.pretty(options.cause));
36+ });
37+
38+/** A fetch that records the URL it was handed and then fails at the transport
39+ * layer, which is what drives introspection down its logging path. */
40+const failingFetch = (seen: Array<string>): typeof globalThis.fetch =>
41+ (async (input: RequestInfo | URL) => {
42+ seen.push(input instanceof Request ? input.url : String(input));
43+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the platform fetch rejecting on a dead host
44+ throw new Error("getaddrinfo ENOTFOUND graph.example.test");
45+ }) as typeof globalThis.fetch;
46+
47+const clientLayer = (seen: Array<string>) =>
48+ FetchHttpClient.layer.pipe(
49+ Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(failingFetch(seen))),
50+ );
51+
52+describe("GraphQL introspection credential logging", () => {
53+ it.effect("does not write a query-carried credential to the log", () =>
54+ Effect.gen(function* () {
55+ const logged: Array<string> = [];
56+ const seen: Array<string> = [];
57+
58+ yield* introspect(ENDPOINT, undefined, { token: SECRET }).pipe(
59+ Effect.flip,
60+ Effect.provide(clientLayer(seen)),
61+ Effect.provide(Logger.layer([capturingLogger(logged)])),
62+ );
63+
64+ const output = logged.join("\n");
65+
66+ // Positive control FIRST: prove the logger actually captured the failure.
67+ // Without this, an empty capture would satisfy every assertion below.
68+ expect(output).toContain("graphql introspection request failed");
69+ expect(output).toContain("graph.example.test");
70+
71+ // The credential is absent from everything that was logged.
72+ expect(output).not.toContain(SECRET);
73+ expect(output).not.toContain("token=");
74+ }),
75+ );
76+
77+ it.effect("still sends the query-carried credential on the wire", () =>
78+ Effect.gen(function* () {
79+ const logged: Array<string> = [];
80+ const seen: Array<string> = [];
81+
82+ yield* introspect(ENDPOINT, undefined, { token: SECRET }).pipe(
83+ Effect.flip,
84+ Effect.provide(clientLayer(seen)),
85+ Effect.provide(Logger.layer([capturingLogger(logged)])),
86+ );
87+
88+ // Keeping it out of the log is only correct if it still reaches the
89+ // upstream — otherwise this "fix" silently breaks authentication.
90+ expect(seen).toHaveLength(1);
91+ expect(seen[0]).toContain(`token=${SECRET}`);
92+ }),
93+ );
94+
95+ it.effect("keeps a credential carried in the endpoint's own query out of the log", () =>
96+ Effect.gen(function* () {
97+ // A configured endpoint can carry the secret itself, with no separate
98+ // queryParams argument at all.
99+ const logged: Array<string> = [];
100+ const seen: Array<string> = [];
101+
102+ yield* introspect(`${ENDPOINT}?token=${SECRET}`).pipe(
103+ Effect.flip,
104+ Effect.provide(clientLayer(seen)),
105+ Effect.provide(Logger.layer([capturingLogger(logged)])),
106+ );
107+
108+ const output = logged.join("\n");
109+ expect(output).toContain("graphql introspection request failed");
110+ expect(output).not.toContain(SECRET);
111+ expect(seen[0]).toContain(`token=${SECRET}`);
112+ }),
113+ );
114+
115+ it.effect("fails a malformed endpoint instead of dialing it without the query params", () =>
116+ Effect.gen(function* () {
117+ // Only a parseable endpoint can carry the query in `urlParams`. An
118+ // unparseable one must fail rather than quietly dial without the
119+ // credential it was told to send.
120+ const logged: Array<string> = [];
121+ const seen: Array<string> = [];
122+
123+ const error = yield* introspect("not a url", undefined, { token: SECRET }).pipe(
124+ Effect.flip,
125+ Effect.provide(clientLayer(seen)),
126+ Effect.provide(Logger.layer([capturingLogger(logged)])),
127+ );
128+
129+ expect(error).toBeInstanceOf(GraphqlIntrospectionError);
130+ expect(error.reason).toBe("invalid-endpoint");
131+
132+ // Nothing was sent: no request at all, rather than one missing the token.
133+ expect(seen).toHaveLength(0);
134+ // And the rejection itself does not echo the credential anywhere.
135+ expect(Cause.pretty(Cause.fail(error))).not.toContain(SECRET);
136+ expect(logged.join("\n")).not.toContain(SECRET);
137+ }),
138+ );
139+});
packages/plugins/graphql/src/sdk/introspect-request-url.test.ts+182−0
@@ -0,0 +1,182 @@
1+// ---------------------------------------------------------------------------
2+// The exact URL introspection dials.
3+//
4+// Keeping a query-carried credential out of the log means splitting the query
5+// off `request.url` and into `request.urlParams` (see
6+// `introspect-credential-logging.test.ts`). The client recombines the two by
7+// appending each pair through `URLSearchParams`, so the query is re-serialized
8+// in form-urlencoded form rather than passed through byte-for-byte.
9+//
10+// That is a real, if narrow, wire change, and it is not recoverable: raw query
11+// bytes only survive inside `request.url`, which is the single field every
12+// `HttpClientError` message renders. So the normalization is pinned here by
13+// exact string equality rather than described in prose — a `toContain` check
14+// would let any of these encodings drift silently, and an upstream that signs
15+// its query string would notice.
16+// ---------------------------------------------------------------------------
17+
18+import { describe, expect, it } from "@effect/vitest";
19+import { Cause, Effect, Layer, Logger } from "effect";
20+import { FetchHttpClient } from "effect/unstable/http";
21+
22+import { GraphqlIntrospectionError } from "./errors";
23+import { introspect } from "./introspect";
24+
25+const ENDPOINT = "https://graph.example.test/graphql";
26+
27+/** Records the URL handed to the platform, then fails the transport so the
28+ * effect finishes without a live host. */
29+const recordingFetch = (seen: Array<string>): typeof globalThis.fetch =>
30+ (async (input: RequestInfo | URL) => {
31+ seen.push(input instanceof Request ? input.url : String(input));
32+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the platform fetch rejecting on a dead host
33+ throw new Error("getaddrinfo ENOTFOUND graph.example.test");
34+ }) as typeof globalThis.fetch;
35+
36+const clientLayer = (seen: Array<string>) =>
37+ FetchHttpClient.layer.pipe(
38+ Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(recordingFetch(seen))),
39+ );
40+
41+const silentLogger = Logger.layer([Logger.make<unknown, void>(() => {})]);
42+
43+/** Runs introspection against a fetch that always fails, and returns the one
44+ * URL it was asked to dial. */
45+const dialedUrl = (endpoint: string, queryParams?: Record<string, string>) =>
46+ Effect.gen(function* () {
47+ const seen: Array<string> = [];
48+ yield* introspect(endpoint, undefined, queryParams).pipe(
49+ Effect.flip,
50+ Effect.provide(clientLayer(seen)),
51+ Effect.provide(silentLogger),
52+ );
53+ expect(seen).toHaveLength(1);
54+ return seen[0];
55+ });
56+
57+describe("GraphQL introspection request URL", () => {
58+ it.effect("merges query params into an endpoint that already has some", () =>
59+ Effect.gen(function* () {
60+ // Endpoint params keep their position and order; merged params are
61+ // appended after them.
62+ const url = yield* dialedUrl(`${ENDPOINT}?apiVersion=2&region=eu`, {
63+ token: "s3cr3t",
64+ trace: "on",
65+ });
66+ expect(url).toBe(`${ENDPOINT}?apiVersion=2&region=eu&token=s3cr3t&trace=on`);
67+ }),
68+ );
69+
70+ it.effect("adds a query to an endpoint that has none", () =>
71+ Effect.gen(function* () {
72+ const url = yield* dialedUrl(ENDPOINT, { token: "s3cr3t" });
73+ expect(url).toBe(`${ENDPOINT}?token=s3cr3t`);
74+ }),
75+ );
76+
77+ it.effect("overwrites a duplicate param in place rather than appending it", () =>
78+ Effect.gen(function* () {
79+ // The supplied param wins, and it keeps the endpoint param's position —
80+ // so the stale value is gone, not merely shadowed.
81+ const url = yield* dialedUrl(`${ENDPOINT}?token=stale&keep=1`, { token: "fresh" });
82+ expect(url).toBe(`${ENDPOINT}?token=fresh&keep=1`);
83+ }),
84+ );
85+
86+ it.effect("collapses repeats of an overwritten key to the supplied value", () =>
87+ Effect.gen(function* () {
88+ const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2`, { a: "3" });
89+ expect(url).toBe(`${ENDPOINT}?a=3`);
90+ }),
91+ );
92+
93+ it.effect("keeps repeated keys the caller did not overwrite", () =>
94+ Effect.gen(function* () {
95+ const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2&b=3`);
96+ expect(url).toBe(`${ENDPOINT}?a=1&a=2&b=3`);
97+ }),
98+ );
99+
100+ it.effect("re-encodes a %20 space as +", () =>
101+ Effect.gen(function* () {
102+ // Documented normalization, not an accident: `URLSearchParams` serializes
103+ // a space in form-urlencoded form. The decoded value is unchanged.
104+ const url = yield* dialedUrl(`${ENDPOINT}?token=a%20b`);
105+ expect(url).toBe(`${ENDPOINT}?token=a+b`);
106+ }),
107+ );
108+
109+ it.effect("leaves a + space as +", () =>
110+ Effect.gen(function* () {
111+ const url = yield* dialedUrl(`${ENDPOINT}?token=a+b`);
112+ expect(url).toBe(`${ENDPOINT}?token=a+b`);
113+ }),
114+ );
115+
116+ it.effect("percent-encodes a literal tilde", () =>
117+ Effect.gen(function* () {
118+ // `~` is legal in a raw query and is left alone by `URL`, but the
119+ // form-urlencoded serializer escapes it.
120+ const url = yield* dialedUrl(`${ENDPOINT}?sig=~tilde~`);
121+ expect(url).toBe(`${ENDPOINT}?sig=%7Etilde%7E`);
122+ }),
123+ );
124+
125+ it.effect("gives a valueless flag a trailing =", () =>
126+ Effect.gen(function* () {
127+ const url = yield* dialedUrl(`${ENDPOINT}?debug`);
128+ expect(url).toBe(`${ENDPOINT}?debug=`);
129+ }),
130+ );
131+
132+ it.effect("leaves an already-encoded reserved character encoded", () =>
133+ Effect.gen(function* () {
134+ const url = yield* dialedUrl(`${ENDPOINT}?a=%2B`);
135+ expect(url).toBe(`${ENDPOINT}?a=%2B`);
136+ }),
137+ );
138+
139+ it.effect("rejects an endpoint carrying userinfo, without echoing it", () =>
140+ Effect.gen(function* () {
141+ // `user:pass@host` is a credential placement that `URL` keeps in the
142+ // origin, so it would ride along in `request.url` and render into every
143+ // `HttpClientError` message — the exact leak the query split closes.
144+ const seen: Array<string> = [];
145+ const logged: Array<string> = [];
146+
147+ const error = yield* introspect("https://svc:hunter2@graph.example.test/graphql").pipe(
148+ Effect.flip,
149+ Effect.provide(clientLayer(seen)),
150+ Effect.provide(
151+ Logger.layer([
152+ Logger.make<unknown, void>((options) => {
153+ logged.push(String(options.message));
154+ logged.push(Cause.pretty(options.cause));
155+ }),
156+ ]),
157+ ),
158+ );
159+
160+ expect(error).toBeInstanceOf(GraphqlIntrospectionError);
161+ expect(error.reason).toBe("invalid-endpoint");
162+
163+ // Nothing was dialed, and neither the error nor the log names the secret.
164+ expect(seen).toHaveLength(0);
165+ const rendered = `${Cause.pretty(Cause.fail(error))}\n${logged.join("\n")}`;
166+ expect(rendered).not.toContain("hunter2");
167+ expect(rendered).not.toContain("svc:");
168+ }),
169+ );
170+
171+ it.effect("rejects userinfo even when only a username is present", () =>
172+ Effect.gen(function* () {
173+ const seen: Array<string> = [];
174+ const error = yield* introspect("https://svc@graph.example.test/graphql", undefined, {
175+ token: "s3cr3t",
176+ }).pipe(Effect.flip, Effect.provide(clientLayer(seen)), Effect.provide(silentLogger));
177+
178+ expect(error.reason).toBe("invalid-endpoint");
179+ expect(seen).toHaveLength(0);
180+ }),
181+ );
182+});
packages/plugins/graphql/src/sdk/introspect.ts+57−11
@@ -235,18 +235,64 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 234 unmodified lines
235235 queryParams?: Record<string, string>,
236236 ) {
237237 const client = yield* HttpClient.HttpClient;
238− const requestEndpoint =
239− queryParams && Object.keys(queryParams).length > 0
240− ? (() => {
241− const url = new URL(endpoint);
242− for (const [name, value] of Object.entries(queryParams)) {
243− url.searchParams.set(name, value);
244− }
245− return url.toString();
246− })()
247− : endpoint;
238+ // Hand `post` a URL OBJECT rather than a string, deliberately.
239+ //
240+ // `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`, and
241+ // every `HttpClientError` renders `${method} ${request.url}` into its
242+ // `message` getter. The `query` carrier is a supported credential placement,
243+ // so an endpoint reached with `?token=…` put that secret inside the error
244+ // message — and the `Effect.logError(…, cause)` below writes the message
245+ // straight to the log on any transport failure or non-JSON response.
246+ //
247+ // Given a URL object, `setUrl` moves the query into `request.urlParams` and
248+ // clears it from `request.url`, so the same failure logs the bare endpoint.
249+ // The query still reaches the upstream: the client recombines url + urlParams
250+ // when it executes the request. Handling the endpoint's OWN query the same
251+ // way (not just the `queryParams` argument) matters — a configured endpoint
252+ // can carry a credential in its query string too.
253+ //
254+ // The split is NOT byte-transparent, and deliberately so. Recombination
255+ // appends each pair through `URLSearchParams`, whose form-urlencoded
256+ // serializer writes a space as `+` rather than `%20`, percent-encodes `~` and
257+ // `!'()`, and gives a valueless `?flag` a trailing `=`. Key order, duplicate
258+ // keys and already-encoded reserved characters survive unchanged. No request
259+ // shape avoids this: the raw query bytes only survive inside `request.url`,
260+ // which is the one field every error message renders, so byte-transparency
261+ // and keeping the credential out of the log cannot both hold. Log safety
262+ // wins. `introspect-request-url.test.ts` pins the exact resulting URLs.
263+ //
264+ // An endpoint that does not parse has no such split available: it would go
265+ // verbatim into `request.url` — query, credential and all — and `queryParams`
266+ // could not be applied to it at all. Reject it instead of dialing a request
267+ // that silently omits the credential the caller asked us to send. The
268+ // endpoint is deliberately left out of the message, since it may be carrying
269+ // the secret.
270+ if (!URL.canParse(endpoint)) {
271+ return yield* new GraphqlIntrospectionError({
272+ message: "GraphQL endpoint is not a valid URL",
273+ reason: "invalid-endpoint",
274+ });
275+ }
276+
277+ const requestUrl = new URL(endpoint);
278+
279+ // Userinfo (`https://user:pass@host/…`) is a credential placement with no
280+ // split of its own: `URL` keeps it in the origin, so it stays in
281+ // `request.url` and renders into every `HttpClientError` message exactly the
282+ // way a query-carried secret used to. Reject it rather than log it, with the
283+ // same constant message that echoes no part of the endpoint.
284+ if (requestUrl.username !== "" || requestUrl.password !== "") {
285+ return yield* new GraphqlIntrospectionError({
286+ message: "GraphQL endpoint must not embed credentials in the URL",
287+ reason: "invalid-endpoint",
288+ });
289+ }
290+
291+ for (const [name, value] of Object.entries(queryParams ?? {})) {
292+ requestUrl.searchParams.set(name, value);
293+ }
248294
249− let request = HttpClientRequest.post(requestEndpoint).pipe(
295+ let request = HttpClientRequest.post(requestUrl).pipe(
250296 HttpClientRequest.setHeader("Content-Type", "application/json"),
251297 HttpClientRequest.setHeader("Accept", "application/json"),
252298 HttpClientRequest.setHeader("User-Agent", "executor-graphql"),
packages/plugins/graphql/src/sdk/plugin.test.ts+50−0
@@ -886,6 +886,56 @@ describe("graphqlPlugin real protocol server", () => { · 885 unmodified lines
886886 }),
887887 );
888888
889+ it.effect("reports an unparseable endpoint as a config problem, not a credential one", () =>
890+ Effect.gen(function* () {
891+ const executor = yield* makeExecutor();
892+ yield* executor.graphql.addIntegration({
893+ endpoint: "not a url",
894+ slug: "health_bad_endpoint",
895+ });
896+
897+ const result = yield* executor.connections.validate({
898+ owner: "org",
899+ integration: IntegrationSlug.make("health_bad_endpoint"),
900+ template: AuthTemplateSlug.make("none"),
901+ value: "unused",
902+ });
903+
904+ expect(result).toMatchObject({
905+ status: "unknown",
906+ detail:
907+ "The GraphQL endpoint URL is invalid. Edit the integration configuration, then try again.",
908+ });
909+ // No request was ever sent, so nothing upstream judged the credential.
910+ // Telling the operator to check it would send them down a dead end.
911+ expect(result.detail).not.toContain("credential");
912+ }),
913+ );
914+
915+ it.effect("reports an endpoint with embedded userinfo as a config problem", () =>
916+ Effect.gen(function* () {
917+ const executor = yield* makeExecutor();
918+ yield* executor.graphql.addIntegration({
919+ endpoint: "https://svc:hunter2@graph.example.test/graphql",
920+ slug: "health_userinfo_endpoint",
921+ });
922+
923+ const result = yield* executor.connections.validate({
924+ owner: "org",
925+ integration: IntegrationSlug.make("health_userinfo_endpoint"),
926+ template: AuthTemplateSlug.make("none"),
927+ value: "unused",
928+ });
929+
930+ expect(result).toMatchObject({
931+ status: "unknown",
932+ detail:
933+ "The GraphQL endpoint URL is invalid. Edit the integration configuration, then try again.",
934+ });
935+ expect(result.detail).not.toContain("hunter2");
936+ }),
937+ );
938+
889939 it.effect("persists the introspection failure when tool sync is incomplete", () =>
890940 Effect.gen(function* () {
891941 const server = yield* serveTestHttpApp(() =>
packages/plugins/graphql/src/sdk/plugin.ts+14−0
@@ -99,6 +99,8 @@ const GRAPHQL_INVALID_SCHEMA_DETAIL = · 98 unmodified lines
9999 const GRAPHQL_AUTH_DETAIL = "Check the credential and selected authentication method.";
100100 const GRAPHQL_NETWORK_DETAIL =
101101 "The GraphQL endpoint could not be reached. Check the URL and upstream availability, then try again.";
102+const GRAPHQL_INVALID_ENDPOINT_DETAIL =
103+ "The GraphQL endpoint URL is invalid. Edit the integration configuration, then try again.";
102104
103105 const truncateHealthDetail = (text: string, max = 240): string => {
104106 const normalized = text.replaceAll(/\s+/g, " ").trim();
@@ -145,6 +147,18 @@ const healthFromIntrospectionError = ( · 40 unmodified lines
145147 const upstream = error.upstreamMessage;
146148 const httpStatus = error.status;
147149
150+ // Classified first, and never as a credential problem: the endpoint was
151+ // rejected before any request went out, so nothing upstream judged the
152+ // credential. Sending the operator to re-enter a working secret would be a
153+ // dead end — the URL in the integration config is what needs the edit.
154+ if (error.reason === "invalid-endpoint") {
155+ return {
156+ status: "unknown",
157+ checkedAt,
158+ detail: GRAPHQL_INVALID_ENDPOINT_DETAIL,
159+ };
160+ }
161+
148162 if (httpStatus === 401 || httpStatus === 403 || isAuthMessage(upstream)) {
149163 const statusDetail =
150164 httpStatus === 401 || httpStatus === 403