From ecc40f521399050c70461f8c5106d2110e35a3ae Mon Sep 17 00:00:00 2001 From: Andrew Boni Date: Tue, 18 Nov 2025 13:42:33 -0800 Subject: [PATCH 1/2] Sanitize sensitive headers in debug mode. Add tests --- README.md | 14 +- src/client/base.ts | 53 +++++++- tests/unit/sanitization.test.ts | 229 ++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 tests/unit/sanitization.test.ts diff --git a/README.md b/README.md index c9c3a2f..a5a023f 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ TypeScript client library for the [Iterable API](https://api.iterable.com/api/docs). -**Important:** This library is maintained for use in the [Iterable MCP server](https://github.com/Iterable/mcp-server) and internal Iterable projects. The API may change at any time. If using this library directly in your own projects, be prepared to adapt to breaking changes. +This library is currently in active development. While it is used in production by the [Iterable MCP server](https://github.com/Iterable/mcp-server), it is still considered experimental. We are rapidly iterating on features and improvements, so you may encounter breaking changes or incomplete type definitions. -Pull requests for bugfixes and improvements are always welcome and appreciated. +We welcome early adopters and feedback! If you're building with it, please stay in touch via issues or pull requests. ## Installation @@ -48,7 +48,8 @@ const client = new IterableClient({ apiKey: 'your-api-key', baseUrl: 'https://api.iterable.com', // optional timeout: 30000, // optional - debug: false // optional + debug: true, // log requests/responses (headers/params redacted) + debugVerbose: false // set true to log response bodies (CAUTION: may contain PII) }); ``` @@ -56,9 +57,10 @@ const client = new IterableClient({ ```bash ITERABLE_API_KEY=your-api-key # Required -ITERABLE_DEBUG=true # Optional: Enable debug logging -LOG_LEVEL=info # Optional: Log level -LOG_FILE=./logs/iterable.log # Optional: Log to file +ITERABLE_DEBUG=true # Enable basic debug logging +ITERABLE_DEBUG_VERBOSE=true # Enable full body logging (includes PII) +LOG_LEVEL=info # Log level +LOG_FILE=./logs/iterable.log # Log to file ``` ## Development diff --git a/src/client/base.ts b/src/client/base.ts index ab6f322..e41f726 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -80,19 +80,62 @@ export class BaseIterableClient { ); // Add debug logging interceptors (only when debug is enabled) + // WARNING: Never enable debug mode in production as it may log sensitive information if (clientConfig.debug) { + const sanitizeHeaders = (headers: any) => { + if (!headers) return undefined; + const sensitive = ["api-key", "authorization", "cookie", "set-cookie"]; + const sanitized = { ...headers }; + Object.keys(sanitized).forEach((key) => { + if (sensitive.includes(key.toLowerCase())) { + sanitized[key] = "[REDACTED]"; + } + }); + return sanitized; + }; + + const sanitizeUrl = (url?: string) => { + if (!url) return url; + try { + // Use the actual base URL for realistic parsing context + const baseUrl = clientConfig.baseUrl || "https://api.iterable.com"; + const urlObj = new URL(url, baseUrl); + const sensitive = ["api_key", "apiKey", "token", "secret"]; + + let modified = false; + sensitive.forEach(param => { + if (urlObj.searchParams.has(param)) { + urlObj.searchParams.set(param, "[REDACTED]"); + modified = true; + } + }); + + if (!modified) return url; + + // If the input was an absolute URL, return the full sanitized URL + if (/^https?:\/\//i.test(url)) { + return urlObj.toString(); + } + + // For relative URLs, return just the path and query components + return urlObj.pathname + urlObj.search; + } catch { + return url; + } + }; + this.client.interceptors.request.use((request) => { logger.debug("API request", { method: request.method?.toUpperCase(), - url: request.url, + url: sanitizeUrl(request.url), + headers: sanitizeHeaders(request.headers), }); return request; }); - // Helper function to create log data from response/error const createResponseLogData = (response: any, includeData = false) => ({ status: response.status, - url: response.config?.url || response.config.url, + url: sanitizeUrl(response.config?.url), ...(includeData && { data: response.data }), }); @@ -106,14 +149,14 @@ export class BaseIterableClient { }, (error) => { if (error.response) { + // CRITICAL: Only log response data if verbose debug is enabled to prevent PII leaks logger.error( "API error", - createResponseLogData(error.response, true) + createResponseLogData(error.response, clientConfig.debugVerbose) ); } else { logger.error("Network error", { message: error.message }); } - // Error is already converted by the error handling interceptor above return Promise.reject(error); } ); diff --git a/tests/unit/sanitization.test.ts b/tests/unit/sanitization.test.ts new file mode 100644 index 0000000..3a03445 --- /dev/null +++ b/tests/unit/sanitization.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import axios from "axios"; + +// Automock axios +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; + +// Import the real logger to spy on it +import { logger } from "../../src/logger.js"; +import { BaseIterableClient } from "../../src/client/base.js"; + +describe("Debug Logging Sanitization", () => { + let mockClientInstance: any; + let requestInterceptor: any; + let responseInterceptorSuccess: any; + let responseInterceptorError: any; + + let debugSpy: any; + let errorSpy: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Spy on logger methods + // We use mockImplementation to silence the console output during tests + debugSpy = jest.spyOn(logger, "debug").mockImplementation(() => logger); + errorSpy = jest.spyOn(logger, "error").mockImplementation(() => logger); + + requestInterceptor = undefined; + responseInterceptorSuccess = undefined; + responseInterceptorError = undefined; + + mockClientInstance = { + interceptors: { + request: { + use: jest.fn((callback) => { + requestInterceptor = callback; + return 0; + }), + }, + response: { + use: jest.fn((success, error) => { + responseInterceptorSuccess = success; + responseInterceptorError = error; + return 0; + }), + }, + }, + get: jest.fn(), + defaults: { headers: {} }, + }; + + if (jest.isMockFunction(mockedAxios.create)) { + mockedAxios.create.mockReturnValue(mockClientInstance); + } else { + (mockedAxios as any).create = jest.fn().mockReturnValue(mockClientInstance); + } + }); + + it("should call axios.create and register interceptors", () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + }); + + expect(mockedAxios.create).toHaveBeenCalled(); + expect(mockClientInstance.interceptors.request.use).toHaveBeenCalled(); + expect(requestInterceptor).toBeDefined(); + }); + + it("should redact sensitive headers in debug logs", () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + }); + + if (!requestInterceptor) throw new Error("Request interceptor missing"); + + const requestConfig = { + method: "get", + url: "/test", + headers: { + Authorization: "Bearer secret-token", + "Cookie": "session=secret", + "X-Custom": "safe", + "Api-Key": "real-api-key", + }, + }; + + requestInterceptor(requestConfig); + + expect(debugSpy).toHaveBeenCalledWith( + "API request", + expect.objectContaining({ + headers: expect.objectContaining({ + "Api-Key": "[REDACTED]", + Authorization: "[REDACTED]", + Cookie: "[REDACTED]", + "X-Custom": "safe", + }), + }) + ); + }); + + it("should redact sensitive query parameters in debug logs", () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + baseUrl: "https://api.iterable.com", + }); + + if (!requestInterceptor) throw new Error("Request interceptor missing"); + + const requestConfig = { + method: "get", + url: "https://api.iterable.com/test?apiKey=secret-key&token=secret-token&safe=value", + }; + + requestInterceptor(requestConfig); + + const logCall = debugSpy.mock.calls.find( + (call: any) => call[0] === "API request" + ); + + const logData = logCall?.[1] as any; + const decodedUrl = decodeURIComponent(logData.url); + expect(decodedUrl).toContain("apiKey=[REDACTED]"); + expect(decodedUrl).toContain("token=[REDACTED]"); + expect(decodedUrl).toContain("safe=value"); + }); + + it("should handle relative URLs in debug logs", () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + baseUrl: "https://api.iterable.com", + }); + + if (!requestInterceptor) throw new Error("Request interceptor missing"); + + const requestConfig = { + method: "get", + url: "/test?apiKey=secret-key&token=secret-token", + }; + + requestInterceptor(requestConfig); + + const logCall = debugSpy.mock.calls.find( + (call: any) => call[0] === "API request" + ); + + const logData = logCall?.[1] as any; + const decodedUrl = decodeURIComponent(logData.url); + expect(decodedUrl).toContain("/test?apiKey=[REDACTED]&token=[REDACTED]"); + expect(logData.url).not.toContain("https://"); + }); + + it("should NOT log error response data by default (debugVerbose=false)", async () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + debugVerbose: false, + }); + + if (!responseInterceptorError) throw new Error("Response interceptor missing"); + + const sensitiveError = { message: "User email@example.com not found" }; + const errorResponse = { + response: { + status: 404, + config: { url: "/error" }, + data: sensitiveError, + }, + }; + + try { + await responseInterceptorError(errorResponse); + } catch (e) { + // Expected + } + + expect(errorSpy).toHaveBeenCalledWith( + "API error", + expect.objectContaining({ + status: 404, + }) + ); + + const errorLog = errorSpy.mock.calls.find( + (call: any) => call[0] === "API error" + ); + const errorData = errorLog?.[1] as any; + + expect(errorData.data).toBeUndefined(); + }); + + it("should log error response data when debugVerbose is true", async () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + debugVerbose: true, + }); + + if (!responseInterceptorError) throw new Error("Response interceptor missing"); + + const errorBody = { error: "details" }; + const errorResponse = { + response: { + status: 400, + config: { url: "/error" }, + data: errorBody, + }, + }; + + try { + await responseInterceptorError(errorResponse); + } catch (e) { + // Expected + } + + expect(errorSpy).toHaveBeenCalledWith( + "API error", + expect.objectContaining({ + status: 400, + data: errorBody, + }) + ); + }); +}); From 15c7d05cf4a357696aa10f6ca31528dd4c3f40a9 Mon Sep 17 00:00:00 2001 From: Andrew Boni Date: Tue, 18 Nov 2025 14:53:24 -0800 Subject: [PATCH 2/2] Don't need sanitizeUrl() --- src/client/base.ts | 34 ++---------------- tests/unit/sanitization.test.ts | 62 ++------------------------------- 2 files changed, 5 insertions(+), 91 deletions(-) diff --git a/src/client/base.ts b/src/client/base.ts index e41f726..b48afe3 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -94,40 +94,10 @@ export class BaseIterableClient { return sanitized; }; - const sanitizeUrl = (url?: string) => { - if (!url) return url; - try { - // Use the actual base URL for realistic parsing context - const baseUrl = clientConfig.baseUrl || "https://api.iterable.com"; - const urlObj = new URL(url, baseUrl); - const sensitive = ["api_key", "apiKey", "token", "secret"]; - - let modified = false; - sensitive.forEach(param => { - if (urlObj.searchParams.has(param)) { - urlObj.searchParams.set(param, "[REDACTED]"); - modified = true; - } - }); - - if (!modified) return url; - - // If the input was an absolute URL, return the full sanitized URL - if (/^https?:\/\//i.test(url)) { - return urlObj.toString(); - } - - // For relative URLs, return just the path and query components - return urlObj.pathname + urlObj.search; - } catch { - return url; - } - }; - this.client.interceptors.request.use((request) => { logger.debug("API request", { method: request.method?.toUpperCase(), - url: sanitizeUrl(request.url), + url: request.url, headers: sanitizeHeaders(request.headers), }); return request; @@ -135,7 +105,7 @@ export class BaseIterableClient { const createResponseLogData = (response: any, includeData = false) => ({ status: response.status, - url: sanitizeUrl(response.config?.url), + url: response.config?.url, ...(includeData && { data: response.data }), }); diff --git a/tests/unit/sanitization.test.ts b/tests/unit/sanitization.test.ts index 3a03445..dbc0843 100644 --- a/tests/unit/sanitization.test.ts +++ b/tests/unit/sanitization.test.ts @@ -5,14 +5,13 @@ import axios from "axios"; jest.mock("axios"); const mockedAxios = axios as jest.Mocked; +import { BaseIterableClient } from "../../src/client/base.js"; // Import the real logger to spy on it import { logger } from "../../src/logger.js"; -import { BaseIterableClient } from "../../src/client/base.js"; describe("Debug Logging Sanitization", () => { let mockClientInstance: any; let requestInterceptor: any; - let responseInterceptorSuccess: any; let responseInterceptorError: any; let debugSpy: any; @@ -27,7 +26,6 @@ describe("Debug Logging Sanitization", () => { errorSpy = jest.spyOn(logger, "error").mockImplementation(() => logger); requestInterceptor = undefined; - responseInterceptorSuccess = undefined; responseInterceptorError = undefined; mockClientInstance = { @@ -40,7 +38,6 @@ describe("Debug Logging Sanitization", () => { }, response: { use: jest.fn((success, error) => { - responseInterceptorSuccess = success; responseInterceptorError = error; return 0; }), @@ -102,59 +99,6 @@ describe("Debug Logging Sanitization", () => { ); }); - it("should redact sensitive query parameters in debug logs", () => { - new BaseIterableClient({ - apiKey: "test-api-key", - debug: true, - baseUrl: "https://api.iterable.com", - }); - - if (!requestInterceptor) throw new Error("Request interceptor missing"); - - const requestConfig = { - method: "get", - url: "https://api.iterable.com/test?apiKey=secret-key&token=secret-token&safe=value", - }; - - requestInterceptor(requestConfig); - - const logCall = debugSpy.mock.calls.find( - (call: any) => call[0] === "API request" - ); - - const logData = logCall?.[1] as any; - const decodedUrl = decodeURIComponent(logData.url); - expect(decodedUrl).toContain("apiKey=[REDACTED]"); - expect(decodedUrl).toContain("token=[REDACTED]"); - expect(decodedUrl).toContain("safe=value"); - }); - - it("should handle relative URLs in debug logs", () => { - new BaseIterableClient({ - apiKey: "test-api-key", - debug: true, - baseUrl: "https://api.iterable.com", - }); - - if (!requestInterceptor) throw new Error("Request interceptor missing"); - - const requestConfig = { - method: "get", - url: "/test?apiKey=secret-key&token=secret-token", - }; - - requestInterceptor(requestConfig); - - const logCall = debugSpy.mock.calls.find( - (call: any) => call[0] === "API request" - ); - - const logData = logCall?.[1] as any; - const decodedUrl = decodeURIComponent(logData.url); - expect(decodedUrl).toContain("/test?apiKey=[REDACTED]&token=[REDACTED]"); - expect(logData.url).not.toContain("https://"); - }); - it("should NOT log error response data by default (debugVerbose=false)", async () => { new BaseIterableClient({ apiKey: "test-api-key", @@ -175,7 +119,7 @@ describe("Debug Logging Sanitization", () => { try { await responseInterceptorError(errorResponse); - } catch (e) { + } catch { // Expected } @@ -214,7 +158,7 @@ describe("Debug Logging Sanitization", () => { try { await responseInterceptorError(errorResponse); - } catch (e) { + } catch { // Expected }