Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/redact-form-credentials.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions src/fetch.redact.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 44 additions & 1 deletion src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,52 @@ function getHeadersObject(headers: Headers | Record<string, string> | 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);
}
Expand Down
Loading