Skip to content

Commit aa06e23

Browse files
authored
Sanitize sensitive headers in debug mode. Add tests (#5)
* Sanitize sensitive headers in debug mode. Add tests * Don't need sanitizeUrl()
1 parent 0f2e8c5 commit aa06e23

File tree

3 files changed

+198
-10
lines changed

3 files changed

+198
-10
lines changed

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
TypeScript client library for the [Iterable API](https://api.iterable.com/api/docs).
44

5-
**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.
5+
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.
66

7-
Pull requests for bugfixes and improvements are always welcome and appreciated.
7+
We welcome early adopters and feedback! If you're building with it, please stay in touch via issues or pull requests.
88

99
## Installation
1010

@@ -48,17 +48,19 @@ const client = new IterableClient({
4848
apiKey: 'your-api-key',
4949
baseUrl: 'https://api.iterable.com', // optional
5050
timeout: 30000, // optional
51-
debug: false // optional
51+
debug: true, // log requests/responses (headers/params redacted)
52+
debugVerbose: false // set true to log response bodies (CAUTION: may contain PII)
5253
});
5354
```
5455

5556
### Environment Variables
5657

5758
```bash
5859
ITERABLE_API_KEY=your-api-key # Required
59-
ITERABLE_DEBUG=true # Optional: Enable debug logging
60-
LOG_LEVEL=info # Optional: Log level
61-
LOG_FILE=./logs/iterable.log # Optional: Log to file
60+
ITERABLE_DEBUG=true # Enable basic debug logging
61+
ITERABLE_DEBUG_VERBOSE=true # Enable full body logging (includes PII)
62+
LOG_LEVEL=info # Log level
63+
LOG_FILE=./logs/iterable.log # Log to file
6264
```
6365

6466
## Development

src/client/base.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,32 @@ export class BaseIterableClient {
8080
);
8181

8282
// Add debug logging interceptors (only when debug is enabled)
83+
// WARNING: Never enable debug mode in production as it may log sensitive information
8384
if (clientConfig.debug) {
85+
const sanitizeHeaders = (headers: any) => {
86+
if (!headers) return undefined;
87+
const sensitive = ["api-key", "authorization", "cookie", "set-cookie"];
88+
const sanitized = { ...headers };
89+
Object.keys(sanitized).forEach((key) => {
90+
if (sensitive.includes(key.toLowerCase())) {
91+
sanitized[key] = "[REDACTED]";
92+
}
93+
});
94+
return sanitized;
95+
};
96+
8497
this.client.interceptors.request.use((request) => {
8598
logger.debug("API request", {
8699
method: request.method?.toUpperCase(),
87100
url: request.url,
101+
headers: sanitizeHeaders(request.headers),
88102
});
89103
return request;
90104
});
91105

92-
// Helper function to create log data from response/error
93106
const createResponseLogData = (response: any, includeData = false) => ({
94107
status: response.status,
95-
url: response.config?.url || response.config.url,
108+
url: response.config?.url,
96109
...(includeData && { data: response.data }),
97110
});
98111

@@ -106,14 +119,14 @@ export class BaseIterableClient {
106119
},
107120
(error) => {
108121
if (error.response) {
122+
// CRITICAL: Only log response data if verbose debug is enabled to prevent PII leaks
109123
logger.error(
110124
"API error",
111-
createResponseLogData(error.response, true)
125+
createResponseLogData(error.response, clientConfig.debugVerbose)
112126
);
113127
} else {
114128
logger.error("Network error", { message: error.message });
115129
}
116-
// Error is already converted by the error handling interceptor above
117130
return Promise.reject(error);
118131
}
119132
);

tests/unit/sanitization.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { beforeEach, describe, expect, it, jest } from "@jest/globals";
2+
import axios from "axios";
3+
4+
// Automock axios
5+
jest.mock("axios");
6+
const mockedAxios = axios as jest.Mocked<typeof axios>;
7+
8+
import { BaseIterableClient } from "../../src/client/base.js";
9+
// Import the real logger to spy on it
10+
import { logger } from "../../src/logger.js";
11+
12+
describe("Debug Logging Sanitization", () => {
13+
let mockClientInstance: any;
14+
let requestInterceptor: any;
15+
let responseInterceptorError: any;
16+
17+
let debugSpy: any;
18+
let errorSpy: any;
19+
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
23+
// Spy on logger methods
24+
// We use mockImplementation to silence the console output during tests
25+
debugSpy = jest.spyOn(logger, "debug").mockImplementation(() => logger);
26+
errorSpy = jest.spyOn(logger, "error").mockImplementation(() => logger);
27+
28+
requestInterceptor = undefined;
29+
responseInterceptorError = undefined;
30+
31+
mockClientInstance = {
32+
interceptors: {
33+
request: {
34+
use: jest.fn((callback) => {
35+
requestInterceptor = callback;
36+
return 0;
37+
}),
38+
},
39+
response: {
40+
use: jest.fn((success, error) => {
41+
responseInterceptorError = error;
42+
return 0;
43+
}),
44+
},
45+
},
46+
get: jest.fn(),
47+
defaults: { headers: {} },
48+
};
49+
50+
if (jest.isMockFunction(mockedAxios.create)) {
51+
mockedAxios.create.mockReturnValue(mockClientInstance);
52+
} else {
53+
(mockedAxios as any).create = jest.fn().mockReturnValue(mockClientInstance);
54+
}
55+
});
56+
57+
it("should call axios.create and register interceptors", () => {
58+
new BaseIterableClient({
59+
apiKey: "test-api-key",
60+
debug: true,
61+
});
62+
63+
expect(mockedAxios.create).toHaveBeenCalled();
64+
expect(mockClientInstance.interceptors.request.use).toHaveBeenCalled();
65+
expect(requestInterceptor).toBeDefined();
66+
});
67+
68+
it("should redact sensitive headers in debug logs", () => {
69+
new BaseIterableClient({
70+
apiKey: "test-api-key",
71+
debug: true,
72+
});
73+
74+
if (!requestInterceptor) throw new Error("Request interceptor missing");
75+
76+
const requestConfig = {
77+
method: "get",
78+
url: "/test",
79+
headers: {
80+
Authorization: "Bearer secret-token",
81+
"Cookie": "session=secret",
82+
"X-Custom": "safe",
83+
"Api-Key": "real-api-key",
84+
},
85+
};
86+
87+
requestInterceptor(requestConfig);
88+
89+
expect(debugSpy).toHaveBeenCalledWith(
90+
"API request",
91+
expect.objectContaining({
92+
headers: expect.objectContaining({
93+
"Api-Key": "[REDACTED]",
94+
Authorization: "[REDACTED]",
95+
Cookie: "[REDACTED]",
96+
"X-Custom": "safe",
97+
}),
98+
})
99+
);
100+
});
101+
102+
it("should NOT log error response data by default (debugVerbose=false)", async () => {
103+
new BaseIterableClient({
104+
apiKey: "test-api-key",
105+
debug: true,
106+
debugVerbose: false,
107+
});
108+
109+
if (!responseInterceptorError) throw new Error("Response interceptor missing");
110+
111+
const sensitiveError = { message: "User [email protected] not found" };
112+
const errorResponse = {
113+
response: {
114+
status: 404,
115+
config: { url: "/error" },
116+
data: sensitiveError,
117+
},
118+
};
119+
120+
try {
121+
await responseInterceptorError(errorResponse);
122+
} catch {
123+
// Expected
124+
}
125+
126+
expect(errorSpy).toHaveBeenCalledWith(
127+
"API error",
128+
expect.objectContaining({
129+
status: 404,
130+
})
131+
);
132+
133+
const errorLog = errorSpy.mock.calls.find(
134+
(call: any) => call[0] === "API error"
135+
);
136+
const errorData = errorLog?.[1] as any;
137+
138+
expect(errorData.data).toBeUndefined();
139+
});
140+
141+
it("should log error response data when debugVerbose is true", async () => {
142+
new BaseIterableClient({
143+
apiKey: "test-api-key",
144+
debug: true,
145+
debugVerbose: true,
146+
});
147+
148+
if (!responseInterceptorError) throw new Error("Response interceptor missing");
149+
150+
const errorBody = { error: "details" };
151+
const errorResponse = {
152+
response: {
153+
status: 400,
154+
config: { url: "/error" },
155+
data: errorBody,
156+
},
157+
};
158+
159+
try {
160+
await responseInterceptorError(errorResponse);
161+
} catch {
162+
// Expected
163+
}
164+
165+
expect(errorSpy).toHaveBeenCalledWith(
166+
"API error",
167+
expect.objectContaining({
168+
status: 400,
169+
data: errorBody,
170+
})
171+
);
172+
});
173+
});

0 commit comments

Comments
 (0)