Skip to content

Commit e5526f3

Browse files
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. --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 4879d47 commit e5526f3

7 files changed

Lines changed: 467 additions & 11 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIn
1111
Schema.Literals([
1212
"network",
1313
"http",
14+
"invalid-endpoint",
1415
"invalid-json",
1516
"invalid-shape",
1617
"missing-schema",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)