diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 176fa63d9..05e0fce96 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -59,7 +59,11 @@ import profileRouter from './routes/profile.router'; import toolsRouter from './routes/tools.router'; import trackRouter from './routes/track.router'; import webhookRouter from './routes/webhook.router'; -import { HttpError, normalizeError } from './utils/errors'; +import { + buildErrorRequestContext, + HttpError, + normalizeError, +} from './utils/errors'; import { logger } from './utils/logger'; declare module 'fastify' { @@ -412,16 +416,7 @@ export async function buildApp( // log as warn so they don't drown out real server errors. const label = error instanceof HttpError ? 'internal server error' : 'request error'; - const reqCtx = { - id: request.id, - url: request.url, - method: request.method, - query: request.query, - headers: request.headers, - body: - (request as FastifyRequest & { rawBody?: string }).rawBody ?? - request.body, - }; + const reqCtx = buildErrorRequestContext(request); if (status >= 500) { request.log.error({ err: error, req: reqCtx }, label); } else { diff --git a/apps/api/src/hooks/request-logging.hook.test.ts b/apps/api/src/hooks/request-logging.hook.test.ts new file mode 100644 index 000000000..39255df66 --- /dev/null +++ b/apps/api/src/hooks/request-logging.hook.test.ts @@ -0,0 +1,62 @@ +/** + * Tests for requestLoggingHook. + * + * The behaviour guarded here: the logged payload never carries a query-string + * credential. The tRPC branch drops the query entirely; every other request + * goes through sanitizeUrl. + */ + +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { describe, expect, it, vi } from 'vitest'; +import { requestLoggingHook } from './request-logging.hook'; + +const SECRET = 'c3VwZXItc2VjcmV0LXRva2Vu'; + +function makeReq(url: string, overrides: Partial = {}) { + const info = vi.fn(); + const request = { + url, + method: 'GET', + headers: {}, + log: { info }, + ...overrides, + } as unknown as FastifyRequest; + return { request, info }; +} + +const reply = { elapsedTime: 12 } as unknown as FastifyReply; + +describe('requestLoggingHook', () => { + it('does not log the value of a sensitive query parameter', async () => { + const { request, info } = makeReq(`/mcp?token=${SECRET}&projectId=p1`); + + await requestLoggingHook(request, reply); + + expect(info).toHaveBeenCalledTimes(1); + const payload = info.mock.calls[0]?.[0]; + expect(JSON.stringify(payload)).not.toContain(SECRET); + expect(payload.url).toBe('/mcp?token=[REDACTED]&projectId=p1'); + }); + + it('logs the bare path for tRPC requests', async () => { + const { request, info } = makeReq(`/trpc/report.get?token=${SECRET}`); + + await requestLoggingHook(request, reply); + + const payload = info.mock.calls[0]?.[0]; + expect(payload.url).toBe('/trpc/report.get'); + expect(JSON.stringify(payload)).not.toContain(SECRET); + }); + + it('still recognises /track by path when the query is filtered', async () => { + const { request, info } = makeReq(`/track?token=${SECRET}`, { + body: { type: 'track' }, + } as Partial); + + await requestLoggingHook(request, reply); + + const payload = info.mock.calls[0]?.[0]; + expect(payload.body).toEqual({ type: 'track' }); + expect(payload.url).toBe('/track?token=[REDACTED]'); + }); +}); diff --git a/apps/api/src/hooks/request-logging.hook.ts b/apps/api/src/hooks/request-logging.hook.ts index c3f1c877c..8008c65eb 100644 --- a/apps/api/src/hooks/request-logging.hook.ts +++ b/apps/api/src/hooks/request-logging.hook.ts @@ -1,5 +1,6 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { path, pick } from 'ramda'; +import { sanitizeUrl } from '../utils/sanitize-url'; const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc']; const ignoreMethods = ['OPTIONS']; @@ -46,7 +47,7 @@ export async function requestLoggingHook( clientIpHeader: string; userAgent: string; } = { - url: request.url, + url: sanitizeUrl(request.url), method: request.method, elapsed: reply.elapsedTime, headers: pick( diff --git a/apps/api/src/utils/errors.test.ts b/apps/api/src/utils/errors.test.ts new file mode 100644 index 000000000..f40e52ec9 --- /dev/null +++ b/apps/api/src/utils/errors.test.ts @@ -0,0 +1,39 @@ +/** + * Tests for buildErrorRequestContext — the request context the error handler + * attaches to error logs. + */ + +import type { FastifyRequest } from 'fastify'; +import { describe, expect, it } from 'vitest'; +import { buildErrorRequestContext } from './errors'; + +const SECRET = 'c3VwZXItc2VjcmV0LXRva2Vu'; + +describe('buildErrorRequestContext', () => { + it('does not carry the value of a sensitive query parameter in the url', () => { + const ctx = buildErrorRequestContext({ + id: 'req-1', + url: `/mcp?token=${SECRET}&projectId=p1`, + method: 'POST', + query: {}, + headers: {}, + body: undefined, + } as unknown as FastifyRequest); + + expect(ctx.url).toBe('/mcp?token=[REDACTED]&projectId=p1'); + }); + + it('prefers the raw body when fastify captured one', () => { + const ctx = buildErrorRequestContext({ + id: 'req-2', + url: '/track', + method: 'POST', + query: {}, + headers: {}, + rawBody: '{"type":"track"}', + body: { type: 'track' }, + } as unknown as FastifyRequest); + + expect(ctx.body).toBe('{"type":"track"}'); + }); +}); diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts index b6bb8e41e..27e3ae6e4 100644 --- a/apps/api/src/utils/errors.ts +++ b/apps/api/src/utils/errors.ts @@ -1,3 +1,6 @@ +import type { FastifyRequest } from 'fastify'; +import { sanitizeUrl } from './sanitize-url'; + export class LogError extends Error { public readonly payload?: Record; @@ -90,3 +93,21 @@ export function normalizeError(error: unknown): NormalizedError { errorName: 'Error', }; } + +/** + * The request context attached to error logs. `query` and `headers` are + * objects, so the logger redacts sensitive entries by key; the URL is a + * string and has to be filtered here. + */ +export function buildErrorRequestContext(request: FastifyRequest) { + return { + id: request.id, + url: sanitizeUrl(request.url), + method: request.method, + query: request.query, + headers: request.headers, + body: + (request as FastifyRequest & { rawBody?: string }).rawBody ?? + request.body, + }; +} diff --git a/apps/api/src/utils/rate-limiter.test.ts b/apps/api/src/utils/rate-limiter.test.ts new file mode 100644 index 000000000..599f5b685 --- /dev/null +++ b/apps/api/src/utils/rate-limiter.test.ts @@ -0,0 +1,41 @@ +/** + * Tests for the rate limiter's onExceeded log line — it records the request + * URL, which on some routes carries a credential in the query string. + */ + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@openpanel/redis', () => ({ getRedisCache: vi.fn() })); + +const { activateRateLimiter } = await import('./rate-limiter'); + +const SECRET = 'c3VwZXItc2VjcmV0LXRva2Vu'; + +async function captureOptions() { + const register = vi.fn(); + await activateRateLimiter({ + fastify: { register } as never, + max: 10, + }); + return register.mock.calls[0]?.[1] as { + onExceeded: (req: unknown) => void; + }; +} + +describe('activateRateLimiter', () => { + it('does not log the value of a sensitive query parameter', async () => { + const options = await captureOptions(); + const warn = vi.fn(); + + options.onExceeded({ + headers: { 'openpanel-client-id': 'client-1' }, + socket: { remoteAddress: '127.0.0.1' }, + url: `/mcp?token=${SECRET}&projectId=p1`, + log: { warn }, + }); + + const payload = warn.mock.calls[0]?.[0]; + expect(JSON.stringify(payload)).not.toContain(SECRET); + expect(payload.url).toBe('/mcp?token=[REDACTED]&projectId=p1'); + }); +}); diff --git a/apps/api/src/utils/rate-limiter.ts b/apps/api/src/utils/rate-limiter.ts index b30e96fb6..817eb6903 100644 --- a/apps/api/src/utils/rate-limiter.ts +++ b/apps/api/src/utils/rate-limiter.ts @@ -1,6 +1,7 @@ import { getTrustedIpFromHeaders } from '@openpanel/common/server/get-client-ip'; import { getRedisCache } from '@openpanel/redis'; import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { sanitizeUrl } from './sanitize-url'; export async function activateRateLimiter({ fastify, @@ -49,7 +50,7 @@ export async function activateRateLimiter({ clientId: req.headers['openpanel-client-id'], ip, ipHeader: header, - url: req.url, + url: sanitizeUrl(req.url), userAgent: req.headers['user-agent'], }, 'rate limit exceeded', diff --git a/apps/api/src/utils/sanitize-url.test.ts b/apps/api/src/utils/sanitize-url.test.ts new file mode 100644 index 000000000..027c525bf --- /dev/null +++ b/apps/api/src/utils/sanitize-url.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for sanitizeUrl — the helper used wherever a raw request URL is + * logged. Query strings on some routes carry credentials, and the logger only + * redacts by object key, so the value has to be filtered before it is logged. + */ + +import { describe, expect, it } from 'vitest'; +import { sanitizeUrl } from './sanitize-url'; + +describe('sanitizeUrl', () => { + it('replaces a token value and keeps the path', () => { + expect(sanitizeUrl('/mcp?token=abc123')).toBe('/mcp?token=[REDACTED]'); + }); + + it('keeps parameters that are not sensitive', () => { + expect(sanitizeUrl('/mcp?token=abc123&projectId=proj-1')).toBe( + '/mcp?token=[REDACTED]&projectId=proj-1' + ); + }); + + it('matches parameter names case-insensitively', () => { + expect(sanitizeUrl('/mcp?TOKEN=abc&Token=def&accessToken=ghi')).toBe( + '/mcp?TOKEN=[REDACTED]&Token=[REDACTED]&accessToken=[REDACTED]' + ); + }); + + it('replaces every occurrence of a repeated parameter', () => { + expect(sanitizeUrl('/mcp?token=abc&token=def')).toBe( + '/mcp?token=[REDACTED]&token=[REDACTED]' + ); + }); + + it('replaces several sensitive parameters in one URL', () => { + expect(sanitizeUrl('/x?token=abc&client_secret=shh&apikey=k&page=2')).toBe( + '/x?token=[REDACTED]&client_secret=[REDACTED]&apikey=[REDACTED]&page=2' + ); + }); + + it('leaves a URL without a query string untouched', () => { + expect(sanitizeUrl('/mcp')).toBe('/mcp'); + }); + + it('leaves an empty query string untouched', () => { + expect(sanitizeUrl('/mcp?')).toBe('/mcp?'); + }); + + it('does not throw on a malformed query string', () => { + expect(sanitizeUrl('/x?%zz=1&&=&token')).toBe( + '/x?%zz=1&&=&token=[REDACTED]' + ); + }); + + it('keeps the encoding of values it does not touch', () => { + expect(sanitizeUrl('/x?path=%2Fhome%3Fa%3Db&token=abc')).toBe( + '/x?path=%2Fhome%3Fa%3Db&token=[REDACTED]' + ); + }); + + it('works on absolute URLs too', () => { + expect(sanitizeUrl('https://api.openpanel.dev/mcp?token=abc')).toBe( + 'https://api.openpanel.dev/mcp?token=[REDACTED]' + ); + }); +}); diff --git a/apps/api/src/utils/sanitize-url.ts b/apps/api/src/utils/sanitize-url.ts new file mode 100644 index 000000000..8345c4284 --- /dev/null +++ b/apps/api/src/utils/sanitize-url.ts @@ -0,0 +1,10 @@ +/** + * Request URLs are logged as plain strings, so the logger's key-based + * redaction never looks inside them. Some routes take credentials in the + * query string (the MCP endpoint accepts `?token=`), which would otherwise + * land verbatim in request logs. + * + * Re-exported from the logger package so the sensitive-parameter list has a + * single definition. + */ +export { sanitizeUrlQuery as sanitizeUrl } from '@openpanel/logger'; diff --git a/apps/public/content/docs/mcp/index.mdx b/apps/public/content/docs/mcp/index.mdx index fe9a720ce..6b0929ccb 100644 --- a/apps/public/content/docs/mcp/index.mdx +++ b/apps/public/content/docs/mcp/index.mdx @@ -13,16 +13,20 @@ https://api.openpanel.dev/mcp ## Authentication -The token can be passed as a query parameter or an `Authorization` header — both are equivalent: +Pass the token as an `Authorization` header: ``` -https://api.openpanel.dev/mcp?token=YOUR_TOKEN +Authorization: Bearer YOUR_TOKEN ``` +If your client can't set headers, pass it as a query parameter instead: + ``` -Authorization: Bearer YOUR_TOKEN +https://api.openpanel.dev/mcp?token=YOUR_TOKEN ``` +Both work. Prefer the header: query strings travel through browser history, shell history and proxy logs, so a token in the URL is easier to leak by accident. + ### Token format The token is a **base64-encoded** string of your client ID and client secret joined by a colon: @@ -44,7 +48,13 @@ The easiest way to get your MCP token is directly from the dashboard — no term echo -n "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" | base64 ``` -Then append it to the MCP URL: +Then send it with every request: + +``` +Authorization: Bearer +``` + +Or, for clients that can't set headers, append it to the MCP URL: ``` https://api.openpanel.dev/mcp?token= @@ -72,22 +82,27 @@ Add the following to your `claude_desktop_config.json`: "mcpServers": { "openpanel": { "type": "streamable-http", - "url": "https://api.openpanel.dev/mcp?token=YOUR_TOKEN" + "url": "https://api.openpanel.dev/mcp", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } } } } ``` +If your client doesn't read `headers`, drop that block and put the token in the URL instead: `"url": "https://api.openpanel.dev/mcp?token=YOUR_TOKEN"`. + ## Connecting with Claude Code CLI -With the Claude CLI you can use the `--header` flag to pass the token via `Authorization: Bearer` rather than embedding it in the URL: +Use the `--header` flag to pass the token via `Authorization: Bearer`: ```bash claude mcp add --transport http openpanel https://api.openpanel.dev/mcp \ --header "Authorization: Bearer YOUR_TOKEN" ``` -Or with the token in the URL (equivalent): +Or with the token in the URL: ```bash claude mcp add --transport http openpanel "https://api.openpanel.dev/mcp?token=YOUR_TOKEN" diff --git a/apps/public/content/features/mcp.json b/apps/public/content/features/mcp.json index bef4208d4..39b40d756 100644 --- a/apps/public/content/features/mcp.json +++ b/apps/public/content/features/mcp.json @@ -77,7 +77,7 @@ }, { "title": "Add the URL to your AI client", - "description": "Paste `https://api.openpanel.dev/mcp?token=YOUR_TOKEN` into your AI client's MCP server config. In Claude Desktop that's one JSON block in `claude_desktop_config.json`. In Cursor and Windsurf it's under MCP settings." + "description": "Paste `https://api.openpanel.dev/mcp` into your AI client's MCP server config and send the token as an `Authorization: Bearer YOUR_TOKEN` header. Clients that can't set headers take it in the URL instead, as `?token=YOUR_TOKEN`. In Claude Desktop that's one JSON block in `claude_desktop_config.json`. In Cursor and Windsurf it's under MCP settings." }, { "title": "Start asking questions", @@ -134,7 +134,7 @@ }, { "question": "How does authentication work?", - "answer": "Your token is `base64(clientId:clientSecret)`. Generate it with `echo -n \"CLIENT_ID:CLIENT_SECRET\" | base64` and append it to the URL as `?token=YOUR_TOKEN`. You can also pass it as an `Authorization: Bearer` header if your client supports that." + "answer": "Your token is `base64(clientId:clientSecret)`. Generate it with `echo -n \"CLIENT_ID:CLIENT_SECRET\" | base64` and send it as an `Authorization: Bearer YOUR_TOKEN` header. If your client can't set headers, append it to the URL as `?token=YOUR_TOKEN` instead—it works the same, but a token in a URL is easier to leak into history and logs." }, { "question": "What's the difference between a read client and a root client?", diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.mcp.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.mcp.tsx index e0789e9cd..b1e61404c 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.mcp.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.mcp.tsx @@ -32,7 +32,10 @@ type AiClient = { }; const buildClients = (mcpEndpoint: string): AiClient[] => { - const url = `${mcpEndpoint}?token=${TOKEN_PLACEHOLDER}`; + const url = mcpEndpoint; + const headers = { Authorization: `Bearer ${TOKEN_PLACEHOLDER}` }; + // Fallback for clients that can't send headers. + const urlWithToken = `${mcpEndpoint}?token=${TOKEN_PLACEHOLDER}`; return [ { @@ -54,6 +57,7 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { openpanel: { type: 'streamable-http', url, + headers, }, }, }, @@ -66,12 +70,14 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { name: 'Claude Code (CLI)', description: ( <> - Run this once in your terminal. The token can also be passed as a - header via --header "Authorization: Bearer BASE64_TOKEN". + Run this once in your terminal. If your setup can't pass headers, use{' '} + "{urlWithToken}" as the URL instead and drop the{' '} + --header flag. ), language: 'bash', - snippet: () => `claude mcp add --transport http openpanel "${url}"`, + snippet: () => + `claude mcp add --transport http openpanel ${url} \\\n --header "Authorization: Bearer ${TOKEN_PLACEHOLDER}"`, }, { id: 'cursor', @@ -91,6 +97,7 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { openpanel: { url, transport: 'streamable-http', + headers, }, }, }, @@ -115,6 +122,7 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { mcpServers: { openpanel: { serverUrl: url, + headers, }, }, }, @@ -140,6 +148,7 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { openpanel: { type: 'http', url, + headers, }, }, }, @@ -153,7 +162,8 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { description: ( <> Copy the JSON below, then run the Install Server{' '} - command in Raycast — it auto-fills the form from your clipboard. + command in Raycast — it auto-fills the form from your clipboard. The + install form takes a URL only, so the token goes in the query string. ), language: 'json', @@ -162,7 +172,7 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { { name: 'openpanel', transport: 'streamable-http', - url, + url: urlWithToken, }, null, 2, @@ -174,7 +184,6 @@ const buildClients = (mcpEndpoint: string): AiClient[] => { function Component() { const { apiUrl } = useAppContext(); const mcpEndpoint = `${apiUrl}/mcp`; - const fullUrl = `${mcpEndpoint}?token=${TOKEN_PLACEHOLDER}`; const clients = buildClients(mcpEndpoint); return ( @@ -189,11 +198,12 @@ function Component() {
- +

- Replace {TOKEN_PLACEHOLDER} with{' '} - base64(clientId:clientSecret). You can also pass it as an{' '} - Authorization: Bearer header instead of a query param. + Send your token as an Authorization: Bearer header, with{' '} + base64(clientId:clientSecret) in place of{' '} + {TOKEN_PLACEHOLDER}. Clients that can't set headers take + it as a ?token= query param instead.

diff --git a/packages/logger/index.test.ts b/packages/logger/index.test.ts new file mode 100644 index 000000000..f1599d9a5 --- /dev/null +++ b/packages/logger/index.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { redactSensitive, sanitizeUrlQuery } from './index'; + +describe('sanitizeUrlQuery', () => { + it('replaces sensitive parameter values and keeps the rest', () => { + expect(sanitizeUrlQuery('/x?token=abc&foo=1')).toBe( + '/x?token=[REDACTED]&foo=1' + ); + }); + + it('returns URLs without a query string unchanged', () => { + expect(sanitizeUrlQuery('/x')).toBe('/x'); + expect(sanitizeUrlQuery('/x?')).toBe('/x?'); + }); +}); + +describe('redactSensitive', () => { + it('filters the query of a string url value', () => { + expect(redactSensitive({ url: '/x?token=abc&foo=1' })).toEqual({ + url: '/x?token=[REDACTED]&foo=1', + }); + }); + + it('covers keys that merely contain url', () => { + expect(redactSensitive({ requestUrl: '/x?apikey=abc' })).toEqual({ + requestUrl: '/x?apikey=[REDACTED]', + }); + }); + + it('leaves a non-string url value to the existing recursion', () => { + expect(redactSensitive({ url: { path: '/x', token: 'abc' } })).toEqual({ + url: { path: '/x', token: '[REDACTED]' }, + }); + }); + + it('still redacts sensitive keys by name', () => { + expect(redactSensitive({ authorization: 'Bearer abc', page: 2 })).toEqual({ + authorization: '[REDACTED]', + page: 2, + }); + }); +}); diff --git a/packages/logger/index.ts b/packages/logger/index.ts index fc75ecf0d..33bac2092 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -22,7 +22,7 @@ export const rawStderrWrite = process.stderr.write.bind(process.stderr); // Substring match (lowercased). Catches camelCase, snake_case, prefixed and // suffixed variants in one entry — e.g. 'token' covers accessToken, // refresh_token, jwtToken, etc. -const SENSITIVE_KEY_PATTERNS = [ +export const SENSITIVE_KEY_PATTERNS = [ 'password', 'passwd', 'pwd', @@ -46,7 +46,53 @@ const SENSITIVE_KEY_PATTERNS = [ const MAX_REDACT_DEPTH = 5; -function redactSensitive(value: unknown, depth = 0): unknown { +const REDACTED = '[REDACTED]'; + +function isSensitiveKey(key: string): boolean { + const lowered = key.toLowerCase(); + return SENSITIVE_KEY_PATTERNS.some((k) => lowered.includes(k)); +} + +/** + * Replace the values of sensitive query parameters in a request URL, keeping + * the path and every other parameter intact. A URL string carries its + * credentials inside one value, so key-based redaction never sees them — + * this splits the query apart so the same key patterns apply. + * + * Parameters are matched by name the same way object keys are: lowercased + * substring. The query is rebuilt from the raw text rather than through + * URLSearchParams so untouched values keep their original encoding. + */ +export function sanitizeUrlQuery(url: string): string { + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) { + return url; + } + + const query = url.slice(queryIndex + 1); + if (query === '') { + return url; + } + + const sanitized = query + .split('&') + .map((param) => { + const equalsIndex = param.indexOf('='); + const rawName = equalsIndex === -1 ? param : param.slice(0, equalsIndex); + let name = rawName; + try { + name = decodeURIComponent(rawName.replace(/\+/g, ' ')); + } catch { + // Malformed percent-encoding — match on the raw name instead. + } + return isSensitiveKey(name) ? `${rawName}=${REDACTED}` : param; + }) + .join('&'); + + return `${url.slice(0, queryIndex)}?${sanitized}`; +} + +export function redactSensitive(value: unknown, depth = 0): unknown { if (value instanceof Error) { return { ...value, @@ -72,8 +118,12 @@ function redactSensitive(value: unknown, depth = 0): unknown { const result: Record = {}; for (const [key, val] of Object.entries(value as Record)) { const lowered = key.toLowerCase(); - if (SENSITIVE_KEY_PATTERNS.some((k) => lowered.includes(k))) { - result[key] = '[REDACTED]'; + if (isSensitiveKey(key)) { + result[key] = REDACTED; + } else if (lowered.includes('url') && typeof val === 'string') { + // Backstop for anything that logs a URL without going through the + // caller-side helper: the credentials sit in the query, not the key. + result[key] = sanitizeUrlQuery(val); } else { result[key] = redactSensitive(val, depth + 1); } diff --git a/packages/logger/vitest.config.ts b/packages/logger/vitest.config.ts new file mode 100644 index 000000000..f87a2039f --- /dev/null +++ b/packages/logger/vitest.config.ts @@ -0,0 +1,3 @@ +import { getSharedVitestConfig } from '../../vitest.shared'; + +export default getSharedVitestConfig({ __dirname });