Strip OAuth authorization codes and CSRF state from exported span URL attributes, and sanitize user-supplied MCP endpoints before stamping them.
Unified · Split
| @@ -0,0 +1,8 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Stop exporting credential-bearing URLs in span attributes. OAuth callback | |
| 6 | +authorization codes and CSRF state are stripped from span URL attributes before | |
| 7 | +export, and user-supplied MCP endpoints are sanitized of query-string and | |
| 8 | +userinfo credentials before being stamped. | |
| @@ -0,0 +1,114 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// OAuth callback × telemetry — the authorization code must never be exported. | |
| 3 | +// | |
| 4 | +// `/api/oauth/callback` is an app-owned path, so Effect's | |
| 5 | +// `HttpMiddleware.tracer` opens its `http.server` span and stamps `url.full` | |
| 6 | +// and `url.query` unconditionally (`effect/unstable/http/HttpMiddleware.ts`). | |
| 7 | +// It redacts URL userinfo and configured header names — nothing else — so the | |
| 8 | +// provider's `?code=…&state=…` reached the trace backend on every connect. | |
| 9 | +// | |
| 10 | +// This drives a real callback request through the real core API web handler, | |
| 11 | +// with the real Effect→OTel tracer bridge exporting into an in-memory | |
| 12 | +// exporter behind the production span-processor chain, and asserts that no | |
| 13 | +// exported span attribute contains the code or the state. | |
| 14 | +// --------------------------------------------------------------------------- | |
| 15 | + | |
| 16 | +import { describe, expect, it } from "@effect/vitest"; | |
| 17 | +import * as Resource from "@effect/opentelemetry/Resource"; | |
| 18 | +import * as OtelTracer from "@effect/opentelemetry/Tracer"; | |
| 19 | +import { | |
| 20 | + InMemorySpanExporter, | |
| 21 | + SimpleSpanProcessor, | |
| 22 | + type ReadableSpan, | |
| 23 | +} from "@opentelemetry/sdk-trace-base"; | |
| 24 | +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; | |
| 25 | +import { Context, Effect, Layer } from "effect"; | |
| 26 | +import { HttpRouter, HttpServer } from "effect/unstable/http"; | |
| 27 | +import { HttpApiBuilder } from "effect/unstable/httpapi"; | |
| 28 | + | |
| 29 | +import { ExecutorApi, observabilityMiddleware } from "@executor-js/api"; | |
| 30 | +import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; | |
| 31 | +import { createExecutor } from "@executor-js/sdk"; | |
| 32 | +import { makeTestConfig } from "@executor-js/sdk/testing"; | |
| 33 | + | |
| 34 | +import { UrlRedactingSpanProcessor } from "./redact-span-urls"; | |
| 35 | + | |
| 36 | +// Synthetic placeholders — never a real authorization code or CSRF state. | |
| 37 | +const CODE = "synthetic-authorization-code-9f2c"; | |
| 38 | +const STATE = "synthetic-csrf-state-4b7e"; | |
| 39 | + | |
| 40 | +const makeTracing = () => { | |
| 41 | + const exporter = new InMemorySpanExporter(); | |
| 42 | + const provider = new BasicTracerProvider({ | |
| 43 | + // The same wrapper order production installs in `telemetry.ts`: the | |
| 44 | + // redactor sits outermost, so nothing downstream ever sees the secret. | |
| 45 | + spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], | |
| 46 | + }); | |
| 47 | + const tracerLayer = OtelTracer.layer.pipe( | |
| 48 | + Layer.provide(Layer.succeed(OtelTracer.OtelTracerProvider)(provider)), | |
| 49 | + Layer.provide(Resource.layer({ serviceName: "executor-cloud-test" })), | |
| 50 | + ); | |
| 51 | + return { exporter, provider, tracerLayer }; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +describe("oauth callback telemetry", () => { | |
| 55 | + it.effect("exports no span attribute containing the authorization code or state", () => | |
| 56 | + Effect.gen(function* () { | |
| 57 | + const { exporter, provider, tracerLayer } = makeTracing(); | |
| 58 | + const executor = yield* createExecutor(makeTestConfig({})); | |
| 59 | + | |
| 60 | + const web = yield* Effect.acquireRelease( | |
| 61 | + Effect.sync(() => | |
| 62 | + HttpRouter.toWebHandler( | |
| 63 | + HttpApiBuilder.layer(ExecutorApi).pipe( | |
| 64 | + Layer.provide(CoreHandlers), | |
| 65 | + Layer.provide(observabilityMiddleware(ExecutorApi)), | |
| 66 | + Layer.provide(Layer.succeed(ExecutorService)(executor)), | |
| 67 | + Layer.provide( | |
| 68 | + Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), | |
| 69 | + ), | |
| 70 | + Layer.provideMerge(HttpServer.layerServices), | |
| 71 | + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), | |
| 72 | + // The Effect→OTel bridge: HttpMiddleware.tracer's span is created | |
| 73 | + // by this tracer, so it lands in the exporter below. | |
| 74 | + Layer.provideMerge(tracerLayer), | |
| 75 | + ), | |
| 76 | + { disableLogger: true }, | |
| 77 | + ), | |
| 78 | + ), | |
| 79 | + (handle) => Effect.promise(() => handle.dispose()), | |
| 80 | + ); | |
| 81 | + | |
| 82 | + const context = Context.make(ExecutorService, executor).pipe( | |
| 83 | + Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), | |
| 84 | + ); | |
| 85 | + | |
| 86 | + // The provider round-trip: a real callback carries the grant and the | |
| 87 | + // CSRF state in the query string. | |
| 88 | + yield* Effect.promise(() => | |
| 89 | + web.handler( | |
| 90 | + new Request( | |
| 91 | + `http://app.test/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`, | |
| 92 | + ), | |
| 93 | + context, | |
| 94 | + ), | |
| 95 | + ); | |
| 96 | + | |
| 97 | + yield* Effect.promise(() => provider.forceFlush()); | |
| 98 | + const spans: readonly ReadableSpan[] = exporter.getFinishedSpans(); | |
| 99 | + | |
| 100 | + // The middleware must actually have opened a server span — otherwise | |
| 101 | + // this test would pass vacuously. | |
| 102 | + const serverSpan = spans.find((span) => span.name.startsWith("http.server")); | |
| 103 | + expect(serverSpan).toBeDefined(); | |
| 104 | + | |
| 105 | + const serialized = JSON.stringify(spans.map((span) => span.attributes)); | |
| 106 | + expect(serialized).not.toContain(CODE); | |
| 107 | + expect(serialized).not.toContain(STATE); | |
| 108 | + | |
| 109 | + // Route-level visibility survives the scrub. | |
| 110 | + expect(serverSpan?.attributes["url.path"]).toBe("/oauth/callback"); | |
| 111 | + expect(String(serverSpan?.attributes["url.full"] ?? "")).toContain("/oauth/callback"); | |
| 112 | + }), | |
| 113 | + ); | |
| 114 | +}); | |
| @@ -0,0 +1,126 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + BasicTracerProvider, | |
| 4 | + InMemorySpanExporter, | |
| 5 | + SimpleSpanProcessor, | |
| 6 | + type ReadableSpan, | |
| 7 | +} from "@opentelemetry/sdk-trace-base"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + redactSpanUrlAttributes, | |
| 11 | + STRIPPED_QUERY_ATTRIBUTE, | |
| 12 | + UrlRedactingSpanProcessor, | |
| 13 | +} from "./redact-span-urls"; | |
| 14 | + | |
| 15 | +// Synthetic placeholders only — never a real authorization code or state. | |
| 16 | +const CODE = "synthetic-authorization-code"; | |
| 17 | +const STATE = "synthetic-csrf-state"; | |
| 18 | + | |
| 19 | +const callbackUrl = `https://app.test/api/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`; | |
| 20 | + | |
| 21 | +/** Ends one real SDK span carrying `attributes` through the redacting | |
| 22 | + * processor, and returns what the exporter actually received. Using the real | |
| 23 | + * provider (rather than a hand-built span) exercises the `onEnding` → `onEnd` | |
| 24 | + * hook sequence exactly as production does. */ | |
| 25 | +const exportSpanWith = (attributes: Record<string, string>): ReadableSpan | undefined => { | |
| 26 | + const exporter = new InMemorySpanExporter(); | |
| 27 | + const provider = new BasicTracerProvider({ | |
| 28 | + spanProcessors: [new UrlRedactingSpanProcessor(new SimpleSpanProcessor(exporter))], | |
| 29 | + }); | |
| 30 | + const span = provider.getTracer("test").startSpan("http.server GET"); | |
| 31 | + span.setAttributes(attributes); | |
| 32 | + span.end(); | |
| 33 | + return exporter.getFinishedSpans()[0]; | |
| 34 | +}; | |
| 35 | + | |
| 36 | +describe("redactSpanUrlAttributes", () => { | |
| 37 | + it("strips the authorization code and state from url.full and url.query", () => { | |
| 38 | + const attributes: Record<string, unknown> = { | |
| 39 | + "url.full": callbackUrl, | |
| 40 | + "url.query": `code=${CODE}&state=${STATE}&domain=example.test`, | |
| 41 | + "url.path": "/api/oauth/callback", | |
| 42 | + "http.request.method": "GET", | |
| 43 | + }; | |
| 44 | + | |
| 45 | + const stripped = redactSpanUrlAttributes(attributes); | |
| 46 | + | |
| 47 | + expect(stripped).toEqual(["code", "state"]); | |
| 48 | + expect(JSON.stringify(attributes)).not.toContain(CODE); | |
| 49 | + expect(JSON.stringify(attributes)).not.toContain(STATE); | |
| 50 | + // Route-level visibility is preserved. | |
| 51 | + expect(attributes["url.path"]).toBe("/api/oauth/callback"); | |
| 52 | + expect(attributes["url.full"]).toBe("https://app.test/api/oauth/callback?domain=example.test"); | |
| 53 | + expect(attributes["url.query"]).toBe("domain=example.test"); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it("strips a code nested inside the login redirect's returnTo parameter", () => { | |
| 57 | + // `/login` is not an app-owned path, so its span comes from the worker | |
| 58 | + // boundary — the callback query rides along inside `returnTo` | |
| 59 | + // (auth/return-to.ts + the sign-in redirect in start.ts). | |
| 60 | + const returnTo = encodeURIComponent(`/api/oauth/callback?code=${CODE}&state=${STATE}`); | |
| 61 | + const attributes: Record<string, unknown> = { | |
| 62 | + "url.full": `https://app.test/login?returnTo=${returnTo}`, | |
| 63 | + "url.query": `returnTo=${returnTo}`, | |
| 64 | + }; | |
| 65 | + | |
| 66 | + const stripped = redactSpanUrlAttributes(attributes); | |
| 67 | + | |
| 68 | + expect(stripped).toEqual(["returnTo.code", "returnTo.state"]); | |
| 69 | + expect(JSON.stringify(attributes)).not.toContain(CODE); | |
| 70 | + expect(JSON.stringify(attributes)).not.toContain(STATE); | |
| 71 | + expect(String(attributes["url.full"])).toContain("%2Fapi%2Foauth%2Fcallback"); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it("leaves a span with no sensitive parameters untouched", () => { | |
| 75 | + const attributes: Record<string, unknown> = { | |
| 76 | + "url.full": "https://app.test/api/integrations?owner=org", | |
| 77 | + "url.query": "owner=org", | |
| 78 | + "url.path": "/api/integrations", | |
| 79 | + }; | |
| 80 | + | |
| 81 | + expect(redactSpanUrlAttributes(attributes)).toEqual([]); | |
| 82 | + expect(attributes["url.full"]).toBe("https://app.test/api/integrations?owner=org"); | |
| 83 | + expect(attributes["url.query"]).toBe("owner=org"); | |
| 84 | + }); | |
| 85 | + | |
| 86 | + it("strips the other sensitive OAuth parameters", () => { | |
| 87 | + const attributes: Record<string, unknown> = { | |
| 88 | + "url.query": | |
| 89 | + "id_token=synthetic-id-token&session_state=synthetic-session&error_description=synthetic-detail&error=access_denied", | |
| 90 | + }; | |
| 91 | + | |
| 92 | + expect(redactSpanUrlAttributes(attributes)).toEqual([ | |
| 93 | + "error_description", | |
| 94 | + "id_token", | |
| 95 | + "session_state", | |
| 96 | + ]); | |
| 97 | + // `error` is an enumerable code, not a secret — it stays. | |
| 98 | + expect(attributes["url.query"]).toBe("error=access_denied"); | |
| 99 | + }); | |
| 100 | +}); | |
| 101 | + | |
| 102 | +describe("UrlRedactingSpanProcessor", () => { | |
| 103 | + it("scrubs the span before the exporter sees it", () => { | |
| 104 | + const exported = exportSpanWith({ | |
| 105 | + "url.full": callbackUrl, | |
| 106 | + "url.query": `code=${CODE}&state=${STATE}`, | |
| 107 | + "url.path": "/api/oauth/callback", | |
| 108 | + }); | |
| 109 | + | |
| 110 | + expect(exported).toBeDefined(); | |
| 111 | + expect(JSON.stringify(exported?.attributes)).not.toContain(CODE); | |
| 112 | + expect(JSON.stringify(exported?.attributes)).not.toContain(STATE); | |
| 113 | + expect(exported?.attributes["url.path"]).toBe("/api/oauth/callback"); | |
| 114 | + expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBe("code,state"); | |
| 115 | + }); | |
| 116 | + | |
| 117 | + it("leaves a span with no sensitive parameters unchanged", () => { | |
| 118 | + const exported = exportSpanWith({ | |
| 119 | + "url.full": "https://app.test/api/integrations?owner=org", | |
| 120 | + "url.query": "owner=org", | |
| 121 | + }); | |
| 122 | + | |
| 123 | + expect(exported?.attributes["url.full"]).toBe("https://app.test/api/integrations?owner=org"); | |
| 124 | + expect(exported?.attributes[STRIPPED_QUERY_ATTRIBUTE]).toBeUndefined(); | |
| 125 | + }); | |
| 126 | +}); | |
| @@ -0,0 +1,189 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// URL-attribute redaction for exported spans. | |
| 3 | +// | |
| 4 | +// Effect's `HttpMiddleware.tracer` stamps `url.full` and `url.query` | |
| 5 | +// unconditionally on every `http.server` span (see | |
| 6 | +// `effect/unstable/http/HttpMiddleware.ts` — it redacts URL userinfo and | |
| 7 | +// configured header names, and nothing else). `/api/oauth/callback` is an | |
| 8 | +// app-owned path, so its span carried the provider's `?code=…&state=…` | |
| 9 | +// verbatim to the trace backend on every OAuth connect. | |
| 10 | +// | |
| 11 | +// The scrub runs at the span-processor seam rather than at the route, because | |
| 12 | +// this is the only chokepoint every span in the isolate must pass through on | |
| 13 | +// its way to the exporter — worker spans, Effect spans, Durable Object spans, | |
| 14 | +// and any route added later. A per-route middleware or a `TracerDisabledWhen` | |
| 15 | +// override would only cover the routes someone remembered to wire it into, and | |
| 16 | +// overriding the middleware's attribute handling would mean forking Effect | |
| 17 | +// internals. | |
| 18 | +// | |
| 19 | +// `url.path` is deliberately preserved: route-level visibility is what makes | |
| 20 | +// these traces worth exporting at all. Only the sensitive query parameters are | |
| 21 | +// removed, and their presence is recorded as a stripped-key list so a trace | |
| 22 | +// still shows that the request carried a `code`, without its value. | |
| 23 | +// --------------------------------------------------------------------------- | |
| 24 | + | |
| 25 | +import type { Context } from "@opentelemetry/api"; | |
| 26 | +import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base"; | |
| 27 | + | |
| 28 | +/** Query parameters that are credentials, single-use grants, or CSRF secrets. | |
| 29 | + * Matched case-insensitively against the parameter name. */ | |
| 30 | +const SENSITIVE_QUERY_KEYS: ReadonlySet<string> = new Set([ | |
| 31 | + "access_token", | |
| 32 | + "client_secret", | |
| 33 | + "code", | |
| 34 | + "code_verifier", | |
| 35 | + "error_description", | |
| 36 | + "id_token", | |
| 37 | + "refresh_token", | |
| 38 | + "session_state", | |
| 39 | + "state", | |
| 40 | + "token", | |
| 41 | +]); | |
| 42 | + | |
| 43 | +/** Span attributes whose value is a whole URL. */ | |
| 44 | +const URL_ATTRIBUTES = ["url.full", "http.url"] as const; | |
| 45 | +const QUERY_ATTRIBUTE = "url.query"; | |
| 46 | +/** Names of the parameters removed from this span's URL attributes. Non-secret | |
| 47 | + * by construction — it is the key list, never the values. */ | |
| 48 | +export const STRIPPED_QUERY_ATTRIBUTE = "url.query.stripped_keys"; | |
| 49 | + | |
| 50 | +const isSensitive = (key: string): boolean => SENSITIVE_QUERY_KEYS.has(key.toLowerCase()); | |
| 51 | + | |
| 52 | +/** How deep to follow a query parameter whose value is itself a URL or path | |
| 53 | + * with a query string. Depth 2 covers the real nesting in this app — | |
| 54 | + * `/login?returnTo=%2Fapi%2Foauth%2Fcallback%3Fcode%3D…` (see | |
| 55 | + * `auth/return-to.ts` and the sign-in redirect in `start.ts`) — with headroom, | |
| 56 | + * and bounds the work per span. */ | |
| 57 | +const MAX_NESTED_DEPTH = 2; | |
| 58 | + | |
| 59 | +/** The query string with every sensitive parameter dropped, plus the names | |
| 60 | + * dropped. Parsed with `URLSearchParams` so encoding round-trips correctly. | |
| 61 | + * | |
| 62 | + * A parameter whose own value carries a nested query string is redacted | |
| 63 | + * recursively rather than dropped: the login redirect round-trips the whole | |
| 64 | + * OAuth callback path through `returnTo`, so the credential rides inside | |
| 65 | + * another parameter's value and a top-level key check alone would miss it. */ | |
| 66 | +const redactQuery = ( | |
| 67 | + query: string, | |
| 68 | + depth = 0, | |
| 69 | +): { readonly query: string; readonly stripped: readonly string[] } => { | |
| 70 | + const params = new URLSearchParams(query); | |
| 71 | + const stripped = new Set<string>(); | |
| 72 | + for (const key of Array.from(new Set(params.keys()))) { | |
| 73 | + if (isSensitive(key)) { | |
| 74 | + stripped.add(key); | |
| 75 | + params.delete(key); | |
| 76 | + continue; | |
| 77 | + } | |
| 78 | + if (depth >= MAX_NESTED_DEPTH) continue; | |
| 79 | + const values = params.getAll(key); | |
| 80 | + const rewritten = values.map((value) => { | |
| 81 | + const separator = value.indexOf("?"); | |
| 82 | + if (separator === -1) return value; | |
| 83 | + const nested = redactQuery(value.slice(separator + 1), depth + 1); | |
| 84 | + for (const name of nested.stripped) stripped.add(`${key}.${name}`); | |
| 85 | + return nested.stripped.length === 0 | |
| 86 | + ? value | |
| 87 | + : `${value.slice(0, separator)}${nested.query === "" ? "" : `?${nested.query}`}`; | |
| 88 | + }); | |
| 89 | + if (rewritten.every((value, index) => value === values[index])) continue; | |
| 90 | + params.delete(key); | |
| 91 | + for (const value of rewritten) params.append(key, value); | |
| 92 | + } | |
| 93 | + return stripped.size === 0 | |
| 94 | + ? { query, stripped: [] } | |
| 95 | + : { query: params.toString(), stripped: Array.from(stripped).sort() }; | |
| 96 | +}; | |
| 97 | + | |
| 98 | +/** The URL with every sensitive query parameter dropped. Path, host, and | |
| 99 | + * scheme are untouched. Unparseable values are dropped entirely rather than | |
| 100 | + * passed through — if it cannot be parsed it cannot be proven safe. */ | |
| 101 | +const redactUrl = ( | |
| 102 | + value: string, | |
| 103 | +): { readonly url: string; readonly stripped: readonly string[] } => { | |
| 104 | + if (!URL.canParse(value)) { | |
| 105 | + const { query, stripped } = redactQuery(value); | |
| 106 | + return { url: stripped.length === 0 ? value : query, stripped }; | |
| 107 | + } | |
| 108 | + const url = new URL(value); | |
| 109 | + if (url.search === "") return { url: value, stripped: [] }; | |
| 110 | + const { query, stripped } = redactQuery(url.search.slice(1)); | |
| 111 | + if (stripped.length === 0) return { url: value, stripped: [] }; | |
| 112 | + url.search = query; | |
| 113 | + return { url: url.toString(), stripped }; | |
| 114 | +}; | |
| 115 | + | |
| 116 | +/** Rewrites the URL-bearing attributes of an in-flight span in place, dropping | |
| 117 | + * sensitive query parameters. Returns the parameter names removed. */ | |
| 118 | +export const redactSpanUrlAttributes = (attributes: Record<string, unknown>): readonly string[] => { | |
| 119 | + const stripped = new Set<string>(); | |
| 120 | + for (const name of URL_ATTRIBUTES) { | |
| 121 | + const value = attributes[name]; | |
| 122 | + if (typeof value !== "string") continue; | |
| 123 | + const result = redactUrl(value); | |
| 124 | + for (const key of result.stripped) stripped.add(key); | |
| 125 | + if (result.stripped.length > 0) attributes[name] = result.url; | |
| 126 | + } | |
| 127 | + const query = attributes[QUERY_ATTRIBUTE]; | |
| 128 | + if (typeof query === "string") { | |
| 129 | + const result = redactQuery(query); | |
| 130 | + for (const key of result.stripped) stripped.add(key); | |
| 131 | + if (result.stripped.length > 0) attributes[QUERY_ATTRIBUTE] = result.query; | |
| 132 | + } | |
| 133 | + return Array.from(stripped).sort(); | |
| 134 | +}; | |
| 135 | + | |
| 136 | +/** Wraps a span processor so every span is scrubbed of credential-bearing URL | |
| 137 | + * query parameters before the inner processor (and therefore the exporter) | |
| 138 | + * sees it. | |
| 139 | + * | |
| 140 | + * The rewrite happens in `onEnding`, the last hook the OTel SDK calls while | |
| 141 | + * the span is still mutable (`Span.end()` runs `onEnding` before setting | |
| 142 | + * `_ended`, so `setAttribute` still applies); `onEnd` receives a frozen | |
| 143 | + * `ReadableSpan`. `onEnding` is optional in the SpanProcessor interface, so | |
| 144 | + * `onEnd` re-checks the attribute bag and mutates it directly as a backstop | |
| 145 | + * for any SDK path that skips the earlier hook. */ | |
| 146 | +export class UrlRedactingSpanProcessor implements SpanProcessor { | |
| 147 | + constructor(private readonly inner: SpanProcessor) {} | |
| 148 | + | |
| 149 | + forceFlush(): Promise<void> { | |
| 150 | + return this.inner.forceFlush(); | |
| 151 | + } | |
| 152 | + | |
| 153 | + onStart(span: Span, parentContext: Context): void { | |
| 154 | + this.inner.onStart(span, parentContext); | |
| 155 | + } | |
| 156 | + | |
| 157 | + onEnding(span: Span): void { | |
| 158 | + this.redact(span.attributes, (key, value) => span.setAttribute(key, value)); | |
| 159 | + this.inner.onEnding?.(span); | |
| 160 | + } | |
| 161 | + | |
| 162 | + onEnd(span: ReadableSpan): void { | |
| 163 | + const attributes = span.attributes as Record<string, unknown>; | |
| 164 | + this.redact(attributes, (key, value) => { | |
| 165 | + attributes[key] = value; | |
| 166 | + }); | |
| 167 | + this.inner.onEnd(span); | |
| 168 | + } | |
| 169 | + | |
| 170 | + shutdown(): Promise<void> { | |
| 171 | + return this.inner.shutdown(); | |
| 172 | + } | |
| 173 | + | |
| 174 | + private redact( | |
| 175 | + attributes: Record<string, unknown>, | |
| 176 | + write: (key: string, value: string) => void, | |
| 177 | + ): void { | |
| 178 | + // Work on a copy so the redaction decision is made from the current values | |
| 179 | + // and applied through the caller's writer (span API vs direct mutation). | |
| 180 | + const draft: Record<string, unknown> = { ...attributes }; | |
| 181 | + const stripped = redactSpanUrlAttributes(draft); | |
| 182 | + if (stripped.length === 0) return; | |
| 183 | + for (const name of [...URL_ATTRIBUTES, QUERY_ATTRIBUTE]) { | |
| 184 | + const value = draft[name]; | |
| 185 | + if (typeof value === "string" && value !== attributes[name]) write(name, value); | |
| 186 | + } | |
| 187 | + write(STRIPPED_QUERY_ATTRIBUTE, stripped.join(",")); | |
| 188 | + } | |
| 189 | +} | |
| @@ -51,6 +51,7 @@ import { · 50 unmodified lines | ||
| 51 | 51 | OTEL_MAX_SPAN_QUEUE_SIZE, |
| 52 | 52 | recordForceFlush, |
| 53 | 53 | } from "./memory-metrics"; |
| 54 | +import { UrlRedactingSpanProcessor } from "./redact-span-urls"; | |
| 54 | 55 | |
| 55 | 56 | const SERVICE_NAME = "executor-cloud"; |
| 56 | 57 | const SERVICE_VERSION = "1.0.0"; |
| @@ -93,7 +94,12 @@ const ensureGlobalTracerProvider = (): boolean => { · 36 unmodified lines | ||
| 93 | 94 | }), |
| 94 | 95 | OTEL_MAX_SPAN_QUEUE_SIZE, |
| 95 | 96 | ); |
| 96 | − return [countingProcessor]; | |
| 97 | + // Outermost wrapper: every span the isolate produces passes through here | |
| 98 | + // before it is queued for export, so credential-bearing query parameters | |
| 99 | + // (OAuth `code`/`state` on `/api/oauth/callback`, which Effect's | |
| 100 | + // HttpMiddleware.tracer stamps into `url.full`/`url.query` | |
| 101 | + // unconditionally) are stripped no matter which route emitted the span. | |
| 102 | + return [new UrlRedactingSpanProcessor(countingProcessor)]; | |
| 97 | 103 | })(), |
| 98 | 104 | }); |
| 99 | 105 | // Skip `provider.register()` — its StackContextManager / W3C propagator |
| @@ -412,3 +412,7 @@ export { · 411 unmodified lines | ||
| 412 | 412 | insufficientScopeFromEmbeddedJson, |
| 413 | 413 | type InsufficientScopeDetection, |
| 414 | 414 | } from "./insufficient-scope"; |
| 415 | + | |
| 416 | +// Endpoint sanitization for span attributes — plugins stamping a user-supplied | |
| 417 | +// endpoint must strip its credential-bearing parts first. | |
| 418 | +export { endpointForTelemetry, endpointTelemetryAttributes } from "./telemetry-endpoint"; | |
| @@ -0,0 +1,68 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { endpointForTelemetry, endpointTelemetryAttributes } from "./telemetry-endpoint"; | |
| 4 | + | |
| 5 | +// Synthetic placeholders only. | |
| 6 | +const QUERY_TOKEN = "synthetic-query-token"; | |
| 7 | +const USERINFO_PASSWORD = "synthetic-userinfo-password"; | |
| 8 | + | |
| 9 | +describe("endpointForTelemetry", () => { | |
| 10 | + it("strips a credential carried in the query string", () => { | |
| 11 | + // The shape the MCP preset list ships and the add-flow passes through raw. | |
| 12 | + expect(endpointForTelemetry(`https://mcp.example.test/mcp?token=${QUERY_TOKEN}`)).toBe( | |
| 13 | + "https://mcp.example.test/mcp", | |
| 14 | + ); | |
| 15 | + }); | |
| 16 | + | |
| 17 | + it("strips a credential carried in URL userinfo", () => { | |
| 18 | + expect(endpointForTelemetry(`https://svc-user:${USERINFO_PASSWORD}@mcp.example.test/mcp`)).toBe( | |
| 19 | + "https://mcp.example.test/mcp", | |
| 20 | + ); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it("strips query, fragment, and userinfo together", () => { | |
| 24 | + const scrubbed = endpointForTelemetry( | |
| 25 | + `https://svc-user:${USERINFO_PASSWORD}@mcp.example.test/mcp?token=${QUERY_TOKEN}#frag`, | |
| 26 | + ); | |
| 27 | + expect(scrubbed).toBe("https://mcp.example.test/mcp"); | |
| 28 | + expect(scrubbed).not.toContain(QUERY_TOKEN); | |
| 29 | + expect(scrubbed).not.toContain(USERINFO_PASSWORD); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it("leaves a credential-free endpoint intact", () => { | |
| 33 | + expect(endpointForTelemetry("https://mcp.example.test/mcp")).toBe( | |
| 34 | + "https://mcp.example.test/mcp", | |
| 35 | + ); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("returns unparseable input as-is", () => { | |
| 39 | + expect(endpointForTelemetry("not a url")).toBe("not a url"); | |
| 40 | + }); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +describe("endpointTelemetryAttributes", () => { | |
| 44 | + it("keeps the endpoint debuggable without exposing the credential", () => { | |
| 45 | + const attributes = endpointTelemetryAttributes( | |
| 46 | + "mcp.endpoint", | |
| 47 | + `https://svc-user:${USERINFO_PASSWORD}@mcp.example.test/mcp?token=${QUERY_TOKEN}`, | |
| 48 | + ); | |
| 49 | + | |
| 50 | + expect(attributes).toEqual({ | |
| 51 | + "mcp.endpoint": "https://mcp.example.test/mcp", | |
| 52 | + "mcp.endpoint.origin": "https://mcp.example.test", | |
| 53 | + "mcp.endpoint.has_query": true, | |
| 54 | + "mcp.endpoint.has_userinfo": true, | |
| 55 | + }); | |
| 56 | + expect(JSON.stringify(attributes)).not.toContain(QUERY_TOKEN); | |
| 57 | + expect(JSON.stringify(attributes)).not.toContain(USERINFO_PASSWORD); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it("reports absence of both credential shapes for a plain endpoint", () => { | |
| 61 | + expect(endpointTelemetryAttributes("mcp.endpoint", "https://mcp.example.test/mcp")).toEqual({ | |
| 62 | + "mcp.endpoint": "https://mcp.example.test/mcp", | |
| 63 | + "mcp.endpoint.origin": "https://mcp.example.test", | |
| 64 | + "mcp.endpoint.has_query": false, | |
| 65 | + "mcp.endpoint.has_userinfo": false, | |
| 66 | + }); | |
| 67 | + }); | |
| 68 | +}); | |
| @@ -0,0 +1,54 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Endpoint sanitization for span attributes. | |
| 3 | +// | |
| 4 | +// User-supplied endpoints are a credential carrier: `?token=…` in the query | |
| 5 | +// string and `user:pass@host` userinfo are both first-class supported input | |
| 6 | +// shapes (the MCP preset list ships a query-token URL, and the add-flow passes | |
| 7 | +// the raw paste straight through). Stamping such a URL verbatim onto a span | |
| 8 | +// ships the credential to the trace backend. | |
| 9 | +// | |
| 10 | +// Span attributes may carry hostnames, paths, and booleans; they may never | |
| 11 | +// carry the secret-bearing parts of a URL. `endpointForTelemetry` keeps the | |
| 12 | +// scheme/host/path — the parts that make a trace debuggable — and drops the | |
| 13 | +// query, fragment, and userinfo. `endpointTelemetryAttributes` adds the | |
| 14 | +// non-sensitive companions (origin, and whether a query string was present) so | |
| 15 | +// "the user pasted a URL with credentials in it" stays diagnosable without the | |
| 16 | +// credential itself. | |
| 17 | +// --------------------------------------------------------------------------- | |
| 18 | + | |
| 19 | +/** The endpoint with every credential-bearing component removed: query string, | |
| 20 | + * fragment, and `user:pass@` userinfo. Unparseable input is returned as-is — | |
| 21 | + * it is not a URL, so there is nothing to strip, and callers still want the | |
| 22 | + * literal for debugging a malformed paste. */ | |
| 23 | +export const endpointForTelemetry = (endpoint: string): string => { | |
| 24 | + if (!URL.canParse(endpoint)) return endpoint; | |
| 25 | + const url = new URL(endpoint); | |
| 26 | + url.search = ""; | |
| 27 | + url.hash = ""; | |
| 28 | + url.username = ""; | |
| 29 | + url.password = ""; | |
| 30 | + return url.toString(); | |
| 31 | +}; | |
| 32 | + | |
| 33 | +/** Span attributes describing an endpoint without exposing its credentials. | |
| 34 | + * `<prefix>` is the sanitized URL, `<prefix>.origin` the scheme+host, and the | |
| 35 | + * two booleans record that a query string / userinfo was stripped. */ | |
| 36 | +export const endpointTelemetryAttributes = ( | |
| 37 | + prefix: string, | |
| 38 | + endpoint: string, | |
| 39 | +): Record<string, string | boolean> => { | |
| 40 | + if (!URL.canParse(endpoint)) { | |
| 41 | + return { | |
| 42 | + [prefix]: endpoint, | |
| 43 | + [`${prefix}.has_query`]: false, | |
| 44 | + [`${prefix}.has_userinfo`]: false, | |
| 45 | + }; | |
| 46 | + } | |
| 47 | + const url = new URL(endpoint); | |
| 48 | + return { | |
| 49 | + [prefix]: endpointForTelemetry(endpoint), | |
| 50 | + [`${prefix}.origin`]: url.origin, | |
| 51 | + [`${prefix}.has_query`]: url.search !== "", | |
| 52 | + [`${prefix}.has_userinfo`]: url.username !== "" || url.password !== "", | |
| 53 | + }; | |
| 54 | +}; | |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 3 | 3 | |
| 4 | +import { endpointForTelemetry } from "@executor-js/sdk/core"; | |
| 5 | + | |
| 4 | 6 | import { GraphqlInvocationError } from "./errors"; |
| 5 | 7 | import { type OperationBinding, InvocationResult } from "./types"; |
| 6 | 8 | |
| @@ -13,14 +15,6 @@ const endpointWithQueryParams = (endpoint: string, queryParams: Record<string, s · 6 unmodified lines | ||
| 13 | 15 | return url.toString(); |
| 14 | 16 | }; |
| 15 | 17 | |
| 16 | −export const endpointForTelemetry = (endpoint: string): string => { | |
| 17 | − if (!URL.canParse(endpoint)) return endpoint; | |
| 18 | − const url = new URL(endpoint); | |
| 19 | − url.search = ""; | |
| 20 | − url.hash = ""; | |
| 21 | − return url.toString(); | |
| 22 | −}; | |
| 23 | − | |
| 24 | 18 | /** The operation string to send for a call. A caller-supplied `select` overrides |
| 25 | 19 | * the default scalar-leaf selection: it is spliced into the field's selection |
| 26 | 20 | * set (`field { <select> }`) so nested/list data can be requested per call. Falls |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | ProviderKey, |
| 17 | 17 | ToolAddress, |
| 18 | 18 | createExecutor, |
| 19 | + endpointForTelemetry, | |
| 19 | 20 | } from "@executor-js/sdk"; |
| 20 | 21 | import { |
| 21 | 22 | makeTestConfig, |
| @@ -24,7 +25,6 @@ import { · 2 unmodified lines | ||
| 24 | 25 | } from "@executor-js/sdk/testing"; |
| 25 | 26 | |
| 26 | 27 | import { graphqlPlugin } from "./plugin"; |
| 27 | −import { endpointForTelemetry } from "./invoke"; | |
| 28 | 28 | import { introspect } from "./introspect"; |
| 29 | 29 | import type { IntrospectionResult } from "./introspect"; |
| 30 | 30 | import { |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { Effect, Layer, Option, Predicate, Schema } from "effect"; | |
| 2 | +import { Effect, Layer, Option, Predicate, Schema, Tracer } from "effect"; | |
| 3 | 3 | import { |
| 4 | 4 | HttpClient, |
| 5 | 5 | HttpClientRequest, |
| @@ -1034,3 +1034,85 @@ describe("mcpPlugin detect URL-token fallback", () => { · 1028 unmodified lines | ||
| 1034 | 1034 | }), |
| 1035 | 1035 | ); |
| 1036 | 1036 | }); |
| 1037 | + | |
| 1038 | +describe("mcpPlugin endpoint telemetry", () => { | |
| 1039 | + // A credential in the endpoint's query string is a first-class supported | |
| 1040 | + // input shape here (the shipped preset list carries one, and the add-flow | |
| 1041 | + // passes the raw paste through), so the endpoint must be sanitized before it | |
| 1042 | + // is stamped onto a span. Synthetic placeholders only. | |
| 1043 | + const QUERY_TOKEN = "synthetic-endpoint-token"; | |
| 1044 | + const USERINFO_PASSWORD = "synthetic-endpoint-password"; | |
| 1045 | + | |
| 1046 | + /** Records every span the program opens, so the stamped attributes can be | |
| 1047 | + * read back. Port 1 connection-refuses immediately, so detection resolves | |
| 1048 | + * without any network dependency. */ | |
| 1049 | + const recordingTracer = (spans: Array<Tracer.NativeSpan>) => | |
| 1050 | + Tracer.make({ | |
| 1051 | + span: (options) => { | |
| 1052 | + const span = new Tracer.NativeSpan(options); | |
| 1053 | + spans.push(span); | |
| 1054 | + return span; | |
| 1055 | + }, | |
| 1056 | + context: (primitive, fiber) => primitive["~effect/Effect/evaluate"](fiber), | |
| 1057 | + }); | |
| 1058 | + | |
| 1059 | + it.effect("stamps a sanitized endpoint on the detect span", () => | |
| 1060 | + Effect.gen(function* () { | |
| 1061 | + const spans: Array<Tracer.NativeSpan> = []; | |
| 1062 | + const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | |
| 1063 | + | |
| 1064 | + yield* executor.integrations | |
| 1065 | + .detect(`http://svc-user:${USERINFO_PASSWORD}@127.0.0.1:1/api/mcp?token=${QUERY_TOKEN}`) | |
| 1066 | + .pipe(Effect.provideService(Tracer.Tracer, recordingTracer(spans))); | |
| 1067 | + | |
| 1068 | + const detect = spans.find((span) => span.name === "mcp.plugin.detect"); | |
| 1069 | + expect(detect).toBeDefined(); | |
| 1070 | + expect(detect?.attributes.get("mcp.endpoint")).toBe("http://127.0.0.1:1/api/mcp"); | |
| 1071 | + // The non-sensitive companions keep the trace debuggable. | |
| 1072 | + expect(detect?.attributes.get("mcp.endpoint.origin")).toBe("http://127.0.0.1:1"); | |
| 1073 | + expect(detect?.attributes.get("mcp.endpoint.has_query")).toBe(true); | |
| 1074 | + expect(detect?.attributes.get("mcp.endpoint.has_userinfo")).toBe(true); | |
| 1075 | + | |
| 1076 | + // Scoped to the plugin's own spans. Effect's HttpClient separately | |
| 1077 | + // stamps `url.full`/`url.query` on its outgoing client spans | |
| 1078 | + // (`effect/unstable/http/HttpClient.ts:685,690`); those are scrubbed | |
| 1079 | + // downstream by the cloud export pipeline's `UrlRedactingSpanProcessor`, | |
| 1080 | + // which is not installed at this level. | |
| 1081 | + const serialized = JSON.stringify( | |
| 1082 | + spans | |
| 1083 | + .filter((span) => span.name.startsWith("mcp.plugin.")) | |
| 1084 | + .map((span) => Object.fromEntries(span.attributes.entries())), | |
| 1085 | + ); | |
| 1086 | + expect(serialized).not.toContain(QUERY_TOKEN); | |
| 1087 | + expect(serialized).not.toContain(USERINFO_PASSWORD); | |
| 1088 | + }), | |
| 1089 | + ); | |
| 1090 | + | |
| 1091 | + it.effect("stamps a sanitized endpoint on the probe_endpoint span", () => | |
| 1092 | + Effect.gen(function* () { | |
| 1093 | + const spans: Array<Tracer.NativeSpan> = []; | |
| 1094 | + const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); | |
| 1095 | + | |
| 1096 | + yield* executor.mcp | |
| 1097 | + .probeEndpoint(`http://127.0.0.1:1/mcp?token=${QUERY_TOKEN}`) | |
| 1098 | + .pipe(Effect.exit, Effect.provideService(Tracer.Tracer, recordingTracer(spans))); | |
| 1099 | + | |
| 1100 | + const probe = spans.find((span) => span.name === "mcp.plugin.probe_endpoint"); | |
| 1101 | + expect(probe).toBeDefined(); | |
| 1102 | + expect(probe?.attributes.get("mcp.endpoint")).toBe("http://127.0.0.1:1/mcp"); | |
| 1103 | + expect(probe?.attributes.get("mcp.endpoint.has_query")).toBe(true); | |
| 1104 | + | |
| 1105 | + // Scoped to the plugin's own spans. Effect's HttpClient separately | |
| 1106 | + // stamps `url.full`/`url.query` on its outgoing client spans | |
| 1107 | + // (`effect/unstable/http/HttpClient.ts:685,690`); those are scrubbed | |
| 1108 | + // downstream by the cloud export pipeline's `UrlRedactingSpanProcessor`, | |
| 1109 | + // which is not installed at this level. | |
| 1110 | + const serialized = JSON.stringify( | |
| 1111 | + spans | |
| 1112 | + .filter((span) => span.name.startsWith("mcp.plugin.")) | |
| 1113 | + .map((span) => Object.fromEntries(span.attributes.entries())), | |
| 1114 | + ); | |
| 1115 | + expect(serialized).not.toContain(QUERY_TOKEN); | |
| 1116 | + }), | |
| 1117 | + ); | |
| 1118 | +}); | |
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | AuthTemplateSlug, |
| 11 | 11 | ConnectionName, |
| 12 | 12 | definePlugin, |
| 13 | + endpointTelemetryAttributes, | |
| 13 | 14 | IntegrationAlreadyExistsError, |
| 14 | 15 | IntegrationSlug, |
| 15 | 16 | mergeAuthTemplates, |
| @@ -896,7 +897,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 880 unmodified lines | ||
| 896 | 897 | }); |
| 897 | 898 | }).pipe( |
| 898 | 899 | Effect.withSpan("mcp.plugin.probe_endpoint", { |
| 899 | − attributes: { "mcp.endpoint": typeof input === "string" ? input : input.endpoint }, | |
| 900 | + // The probed endpoint is raw user paste and routinely carries a | |
| 901 | + // credential in its query string (`?token=…`) — sanitize before | |
| 902 | + // stamping. | |
| 903 | + attributes: endpointTelemetryAttributes( | |
| 904 | + "mcp.endpoint", | |
| 905 | + typeof input === "string" ? input : input.endpoint, | |
| 906 | + ), | |
| 900 | 907 | }), |
| 901 | 908 | ); |
| 902 | 909 | |
| @@ -1486,7 +1493,8 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 583 unmodified lines | ||
| 1486 | 1493 | }).pipe( |
| 1487 | 1494 | Effect.catch(() => Effect.succeed(null)), |
| 1488 | 1495 | Effect.withSpan("mcp.plugin.detect", { |
| 1489 | − attributes: { "mcp.endpoint": url }, | |
| 1496 | + // Same raw-paste input as probe_endpoint — sanitize before stamping. | |
| 1497 | + attributes: endpointTelemetryAttributes("mcp.endpoint", url), | |
| 1490 | 1498 | }), |
| 1491 | 1499 | ), |
| 1492 | 1500 | |