From f0e4980ea7583824183da4b648ef8216dfa9bba3 Mon Sep 17 00:00:00 2001 From: Brent Date: Thu, 23 Jul 2026 16:32:12 -0500 Subject: [PATCH] SMOODEV-2716: Redact client_secret from url-encoded request-body logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request/response logger stored the raw form body as an opaque string, so an OAuth client_credentials token exchange leaked its client_secret to CloudWatch in plaintext (surfaced during the AuthIssuer P0). The logger redacts by object key, but a url-encoded body isn't an object it can see into. getRequestBody now scrubs sensitive x-www-form-urlencoded params via the new exported redactFormCredentials (splits on the first `=` so base64 padding survives; leaves JSON bodies untouched). Covers all body log sites since they route through getRequestBody. TypeScript only — Python/Rust/Go/.NET parity is a follow-up. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018tRq2Rge3gGHCjJdNx6xDD --- .changeset/redact-form-credentials.md | 5 +++ src/fetch.redact.test.ts | 26 ++++++++++++++++ src/fetch.ts | 45 ++++++++++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .changeset/redact-form-credentials.md create mode 100644 src/fetch.redact.test.ts diff --git a/.changeset/redact-form-credentials.md b/.changeset/redact-form-credentials.md new file mode 100644 index 0000000..a708002 --- /dev/null +++ b/.changeset/redact-form-credentials.md @@ -0,0 +1,5 @@ +--- +'@smooai/fetch': patch +--- + +SMOODEV-2716: Redact credential params (`client_secret`, `client_id`, tokens, etc.) from url-encoded request bodies before they reach any log record. The request/response logging stored the raw form body as an opaque string, so an OAuth `client_credentials` token exchange leaked its `client_secret` to CloudWatch in plaintext. `getRequestBody` now scrubs sensitive `x-www-form-urlencoded` params via the exported `redactFormCredentials`. TypeScript only for now — the Python/Rust/Go/.NET implementations need the same redaction as a parity follow-up. diff --git a/src/fetch.redact.test.ts b/src/fetch.redact.test.ts new file mode 100644 index 0000000..83080be --- /dev/null +++ b/src/fetch.redact.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { redactFormCredentials } from './fetch'; + +// SMOODEV-2716: url-encoded request bodies are logged as opaque strings, so an +// OAuth token-exchange body leaked `client_secret` to CloudWatch. These pin the +// redaction that closes that gap. +describe('redactFormCredentials', () => { + it('redacts client_secret / client_id in a token-exchange body, preserving base64 = padding', () => { + const body = 'grant_type=client_credentials&client_id=abc-123&client_secret=sk_EL%2BPixKPBBe%3D'; + const out = redactFormCredentials(body); + expect(out).toContain('grant_type=client_credentials'); + expect(out).toContain('client_secret=[REDACTED]'); + expect(out).toContain('client_id=[REDACTED]'); + expect(out).not.toContain('sk_EL'); + }); + + it('leaves a form body with no sensitive params unchanged', () => { + const body = 'grant_type=client_credentials&scope=read'; + expect(redactFormCredentials(body)).toBe(body); + }); + + it('does not corrupt a JSON string body (not form-encoded)', () => { + const body = '{"client_secret":"sk_x","a":1}'; + expect(redactFormCredentials(body)).toBe(body); + }); +}); diff --git a/src/fetch.ts b/src/fetch.ts index a87c0b9..4cfb9b9 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -381,9 +381,52 @@ function getHeadersObject(headers: Headers | Record | undefined) return headers; } +// Credential params whose values must never reach a log record. The logger +// redacts by object key (headers, JSON fields), but a url-encoded request body +// is an opaque string it can't see into — so `client_secret=sk_...` in an OAuth +// token-exchange body sails through in plaintext. SMOODEV-2716: a config-client +// token exchange leaked its client_secret to CloudWatch this way. +const REDACTED_FORM_PARAMS = new Set([ + 'client_secret', + 'client_id', + 'secret', + 'password', + 'access_token', + 'refresh_token', + 'token', + 'code', + 'assertion', + 'client_assertion', + 'api_key', + 'apikey', +]); + +// Scrub sensitive values from an x-www-form-urlencoded body before it is logged. +// Splits on the FIRST `=` per pair so base64 values with `=` padding survive. +export function redactFormCredentials(body: string): string { + const trimmed = body.trimStart(); + // Only touch form-encoded strings; leave JSON (logger key-redacts that) alone. + if (!body.includes('=') || trimmed.startsWith('{') || trimmed.startsWith('[')) return body; + let changed = false; + const out = body + .split('&') + .map((pair) => { + const eq = pair.indexOf('='); + if (eq === -1) return pair; + const key = pair.slice(0, eq); + if (REDACTED_FORM_PARAMS.has(key.toLowerCase())) { + changed = true; + return `${key}=[REDACTED]`; + } + return pair; + }) + .join('&'); + return changed ? out : body; +} + function getRequestBody(body: any): string | undefined { if (!body) return undefined; - if (typeof body === 'string') return body; + if (typeof body === 'string') return redactFormCredentials(body); if (typeof body === 'object') return JSON.stringify(body); return String(body); }