From 27266bbcdceac03da13a16dbd5dbb9dd58c5c843 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 15 May 2026 14:50:13 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=B0=EF=B8=8F=20fix:=20Redact=20Outboun?= =?UTF-8?q?d=20Telemetry=20URL=20Queries=20(#13133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: redact outbound telemetry URL queries * fix: handle telemetry redaction edge cases * fix: keep sanitized telemetry URLs absolute * fix: infer https telemetry URL scheme * fix: avoid port-only telemetry protocol inference * fix: bracket ipv6 telemetry hosts --- packages/api/src/telemetry/sdk.spec.ts | 194 +++++++++++++++++++++++- packages/api/src/telemetry/sdk.ts | 199 ++++++++++++++++++++++++- 2 files changed, 391 insertions(+), 2 deletions(-) diff --git a/packages/api/src/telemetry/sdk.spec.ts b/packages/api/src/telemetry/sdk.spec.ts index 548ecc6f2a..951b1d91ef 100644 --- a/packages/api/src/telemetry/sdk.spec.ts +++ b/packages/api/src/telemetry/sdk.spec.ts @@ -1,10 +1,17 @@ import { Socket } from 'node:net'; import { IncomingMessage } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; import type { Span } from '@opentelemetry/api'; +import type { RequestOptions } from 'node:http'; interface HttpInstrumentationOptions { requestHook?: (span: Span, request: object) => void; startIncomingSpanHook?: (request: IncomingMessage) => Record; + startOutgoingSpanHook?: (request: RequestOptions) => Record; +} + +interface UndiciInstrumentationOptions { + startSpanHook?: (request: { origin?: string; path?: string }) => Record; } const mockStart = jest.fn(); @@ -21,7 +28,10 @@ const mockHttpInstrumentation = jest.fn((options?: HttpInstrumentationOptions) = const mockIORedisInstrumentation = jest.fn(() => ({ name: 'ioredis' })); const mockMongoDBInstrumentation = jest.fn(() => ({ name: 'mongodb' })); const mockMongooseInstrumentation = jest.fn(() => ({ name: 'mongoose' })); -const mockUndiciInstrumentation = jest.fn(() => ({ name: 'undici' })); +const mockUndiciInstrumentation = jest.fn((options?: UndiciInstrumentationOptions) => ({ + name: 'undici', + options, +})); const mockResourceFromAttributes = jest.fn((attributes: object) => ({ attributes })); jest.mock( @@ -225,6 +235,188 @@ describe('telemetry SDK lifecycle', () => { expect(JSON.stringify(attributes)).not.toContain('secret-state'); }); + it('redacts outgoing HTTP URL query attributes before client spans are exported', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'http:', + hostname: '127.0.0.1', + port: 33169, + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890&user_text=sensitive+prompt+words', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]&user_text=[REDACTED]', + 'http.url': 'http://127.0.0.1:33169/custom-action?api_key=[REDACTED]&user_text=[REDACTED]', + 'url.full': 'http://127.0.0.1:33169/custom-action?api_key=[REDACTED]&user_text=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]&user_text=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + expect(JSON.stringify(attributes)).not.toContain('sensitive+prompt+words'); + }); + + it('redacts delimiter-less outgoing query segments and preserves separate HTTP ports', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'https:', + host: 'api.example.com', + port: 8443, + path: '/custom-action?LC_ACTION_QUERY_SECRET_67890&user_text=sensitive+prompt+words', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?[REDACTED]&user_text=[REDACTED]', + 'http.url': 'https://api.example.com:8443/custom-action?[REDACTED]&user_text=[REDACTED]', + 'url.full': 'https://api.example.com:8443/custom-action?[REDACTED]&user_text=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': '[REDACTED]&user_text=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + expect(JSON.stringify(attributes)).not.toContain('sensitive+prompt+words'); + }); + + it('keeps outgoing HTTP URLs absolute when request options omit an origin', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'http:', + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'http://localhost/custom-action?api_key=[REDACTED]', + 'url.full': 'http://localhost/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('uses the agent protocol for outgoing URL attributes when request protocol is absent', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + agent: new HttpsAgent(), + hostname: 'api.example.com', + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'https://api.example.com/custom-action?api_key=[REDACTED]', + 'url.full': 'https://api.example.com/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('does not infer HTTPS from port 443 without protocol context', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + hostname: 'api.example.com', + port: 443, + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'http://api.example.com:443/custom-action?api_key=[REDACTED]', + 'url.full': 'http://api.example.com:443/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('brackets IPv6 hostnames in outgoing HTTP URL attributes', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'http:', + hostname: '::1', + port: 8080, + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'http://[::1]:8080/custom-action?api_key=[REDACTED]', + 'url.full': 'http://[::1]:8080/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('redacts outgoing Undici URL query attributes before fetch spans are exported', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockUndiciInstrumentation.mock.calls[0]?.[0]; + const startSpanHook = instrumentationOptions?.startSpanHook; + + if (!startSpanHook) { + throw new Error('Undici instrumentation startSpanHook was not configured'); + } + + const attributes = startSpanHook({ + origin: 'https://api.openweathermap.org', + path: '/data/3.0/onecall?appid=OPENWEATHER_SECRET_123&lat=40.71&lon=-74.01', + }); + + expect(attributes).toEqual({ + 'http.target': '/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'http.url': + 'https://api.openweathermap.org/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'url.full': + 'https://api.openweathermap.org/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'url.path': '/data/3.0/onecall', + 'url.query': 'appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('OPENWEATHER_SECRET_123'); + expect(JSON.stringify(attributes)).not.toContain('40.71'); + expect(JSON.stringify(attributes)).not.toContain('-74.01'); + }); + it('reflects lifecycle status from the controller getter', async () => { const controller = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); diff --git a/packages/api/src/telemetry/sdk.ts b/packages/api/src/telemetry/sdk.ts index 49bd8a7ffc..90c3b1977c 100644 --- a/packages/api/src/telemetry/sdk.ts +++ b/packages/api/src/telemetry/sdk.ts @@ -10,6 +10,7 @@ import { MongooseInstrumentation } from '@opentelemetry/instrumentation-mongoose import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node'; import type { Span, Attributes } from '@opentelemetry/api'; +import type { RequestOptions } from 'node:http'; import type { TelemetryConfig, TelemetryStatus } from './config'; import { getTelemetryConfig } from './config'; @@ -28,6 +29,21 @@ interface RegisteredSignal { listener: NodeJS.SignalsListener; } +interface RequestUrlParts { + href?: string; + search?: string; + pathname?: string; +} + +interface AgentProtocol { + protocol?: string; +} + +interface UndiciRequestInfo { + path?: string; + origin?: string; +} + let activeSdk: NodeSDK | undefined; let pendingSdk: NodeSDK | undefined; let startPromise: Promise | undefined; @@ -95,6 +111,183 @@ function getSanitizedIncomingUrlAttributes( return attributes; } +function getStringValue(value: string | number | null | undefined): string | undefined { + if (value == null) { + return undefined; + } + + const stringValue = String(value).trim(); + return stringValue || undefined; +} + +function getRedactedQuery(search: string): string | undefined { + const query = search.startsWith('?') ? search.slice(1) : search; + if (!query) { + return undefined; + } + + return query + .split('&') + .map((part) => { + const separatorIndex = part.indexOf('='); + if (separatorIndex < 0) { + return REDACTED_QUERY_VALUE; + } + + const key = part.slice(0, separatorIndex); + if (!key) { + return REDACTED_QUERY_VALUE; + } + + return `${key}=${REDACTED_QUERY_VALUE}`; + }) + .join('&'); +} + +function getSanitizedUrlAttributesFromParts( + origin: string | undefined, + pathname: string, + search: string, +): Attributes { + const path = pathname || '/'; + const redactedQuery = getRedactedQuery(search); + const target = redactedQuery ? `${path}?${redactedQuery}` : path; + const fullUrl = origin ? `${origin}${target}` : target; + const attributes: Attributes = { + 'http.target': target, + 'http.url': fullUrl, + 'url.full': fullUrl, + 'url.path': path, + }; + + if (redactedQuery) { + attributes['url.query'] = redactedQuery; + } + + return attributes; +} + +function getFallbackUrlParts(rawUrl: string): { pathname: string; search: string } { + const queryIndex = rawUrl.indexOf('?'); + if (queryIndex < 0) { + return { pathname: rawUrl || '/', search: '' }; + } + + return { + pathname: rawUrl.slice(0, queryIndex) || '/', + search: rawUrl.slice(queryIndex), + }; +} + +function getSanitizedOutgoingUrlAttributes(rawUrl: string, origin?: string): Attributes { + const hasOrigin = /^[a-z][a-z\d+\-.]*:\/\//i.test(rawUrl); + + try { + const parsedUrl = new URL(rawUrl, origin ?? 'http://localhost'); + const safeOrigin = hasOrigin || origin ? parsedUrl.origin : undefined; + return getSanitizedUrlAttributesFromParts(safeOrigin, parsedUrl.pathname, parsedUrl.search); + } catch { + const { pathname, search } = getFallbackUrlParts(rawUrl); + return getSanitizedUrlAttributesFromParts(origin, pathname, search); + } +} + +function normalizeProtocol(protocol: string): string { + return protocol.endsWith(':') ? protocol : `${protocol}:`; +} + +function getRequestAgentProtocol(request: RequestOptions): string | undefined { + const { agent } = request; + if (!agent || typeof agent === 'boolean') { + return undefined; + } + + return getStringValue((agent as AgentProtocol).protocol); +} + +function getOutgoingHttpProtocol(request: RequestOptions): string { + const protocol = getStringValue(request.protocol) ?? getRequestAgentProtocol(request); + if (!protocol) { + return 'http:'; + } + + return normalizeProtocol(protocol); +} + +function getUrlAuthorityHost(host: string): string { + try { + const parsedHost = new URL(`http://${host}`); + return parsedHost.host; + } catch { + if (host.includes(':') && !host.startsWith('[')) { + return `[${host}]`; + } + } + + return host; +} + +function getHostWithPort(host: string, port: string | undefined): string { + const authorityHost = getUrlAuthorityHost(host); + if (!port) { + return authorityHost; + } + + try { + const parsedHost = new URL(`http://${authorityHost}`); + if (parsedHost.port) { + return authorityHost; + } + } catch { + return authorityHost; + } + + return `${authorityHost}:${port}`; +} + +function getOutgoingHttpOrigin(request: RequestOptions): string | undefined { + const protocol = getOutgoingHttpProtocol(request); + const host = getStringValue(request.host); + const port = getStringValue(request.port); + if (host) { + return `${protocol}//${getHostWithPort(host, port)}`; + } + + const hostname = getStringValue(request.hostname); + const resolvedHostname = hostname ?? 'localhost'; + return `${protocol}//${getHostWithPort(resolvedHostname, port)}`; +} + +function getOutgoingHttpUrl(request: RequestOptions & RequestUrlParts): string { + if (request.path) { + return request.path; + } + + if (request.href) { + return request.href; + } + + const pathname = request.pathname || '/'; + if (!request.search) { + return pathname; + } + + const search = request.search.startsWith('?') ? request.search : `?${request.search}`; + return `${pathname}${search}`; +} + +function getSanitizedOutgoingHttpUrlAttributes(request: RequestOptions): Attributes { + const requestWithUrlParts = request as RequestOptions & RequestUrlParts; + return getSanitizedOutgoingUrlAttributes( + getOutgoingHttpUrl(requestWithUrlParts), + getOutgoingHttpOrigin(request), + ); +} + +function getSanitizedUndiciUrlAttributes(request: UndiciRequestInfo): Attributes { + return getSanitizedOutgoingUrlAttributes(request.path ?? '/', request.origin); +} + function getResourceAttributes(config: TelemetryConfig): Attributes { const attributes: Attributes = { [ATTR_SERVICE_NAME]: config.serviceName, @@ -123,6 +316,8 @@ function createSdk(config: TelemetryConfig): NodeSDK { }, startIncomingSpanHook: (request: IncomingMessage) => getSanitizedIncomingUrlAttributes(request, config.healthPath), + startOutgoingSpanHook: (request: RequestOptions) => + getSanitizedOutgoingHttpUrlAttributes(request), ignoreIncomingRequestHook: (request: IncomingMessage) => shouldIgnoreIncomingRequest(request, config.healthPath), }), @@ -130,7 +325,9 @@ function createSdk(config: TelemetryConfig): NodeSDK { new MongoDBInstrumentation(), new MongooseInstrumentation(), new IORedisInstrumentation(), - new UndiciInstrumentation(), + new UndiciInstrumentation({ + startSpanHook: (request: UndiciRequestInfo) => getSanitizedUndiciUrlAttributes(request), + }), ], };