diff --git a/src/cli.ts b/src/cli.ts index c07d5750..8c7423b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -74,6 +74,11 @@ async function main(argv: string[]) { "Request headers to redact (values will be replaced by XXXX)", commaSeparatedList, ) + .option( + "--redact-body-fields ", + "JSON body fields to redact in request and response bodies (values will be replaced by XXXX)", + commaSeparatedList, + ) .option( "--no-drop-conditional-request-headers", "When running in record mode, by default, `If-*` headers from outgoing requests are dropped in an attempt to prevent the suite of conditional responses being returned (e.g. 304). Supplying this flag disables this default behaviour", @@ -112,6 +117,7 @@ async function main(argv: string[]) { const sendProxyPort: boolean = options.sendProxyPort === undefined ? false : options.sendProxyPort; const redactHeaders: string[] = options.redactHeaders; + const redactBodyFields: string[] = options.redactBodyFields; const preventConditionalRequests: boolean = !!options.dropConditionalRequestHeaders; const httpsCA: string = options.httpsCa || ""; @@ -178,6 +184,7 @@ async function main(argv: string[]) { defaultTapeName, enableLogging: true, redactHeaders, + redactBodyFields, preventConditionalRequests, httpsCA, httpsKey, diff --git a/src/persistence.spec.ts b/src/persistence.spec.ts index 7aa35131..10cebc45 100644 --- a/src/persistence.spec.ts +++ b/src/persistence.spec.ts @@ -1,5 +1,10 @@ import { brotliCompressSync, gzipSync } from "zlib"; -import { persistTape, reviveTape, redactRequestHeaders } from "./persistence"; +import { + persistTape, + reviveTape, + redactRequestHeaders, + redactRecordBodyFields, +} from "./persistence"; // Note the repetition. This is necessary otherwise Brotli compression // will be null. @@ -504,3 +509,251 @@ describe("Persistence", () => { }); }); }); + +describe("Body Field Redaction", () => { + it("redacts simple JSON fields in request body", () => { + const requestJson = JSON.stringify({ + email: "user@example.com", + password: "secret123", + username: "testuser", + }); + + const record = { + request: { + method: "POST", + path: "/login", + headers: {}, + body: Buffer.from(requestJson, "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from("{}", "utf8"), + }, + }; + + redactRecordBodyFields(record, ["password"]); + + const redactedRequest = JSON.parse(record.request.body.toString("utf8")); + expect(redactedRequest.email).toEqual("user@example.com"); + expect(redactedRequest.password).toEqual("XXXX"); + expect(redactedRequest.username).toEqual("testuser"); + }); + + it("redacts fields case-insensitively", () => { + const requestJson = JSON.stringify({ + Password: "secret123", + ACCESS_TOKEN: "token456", + }); + + const record = { + request: { + method: "POST", + path: "/login", + headers: {}, + body: Buffer.from(requestJson, "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from("{}", "utf8"), + }, + }; + + redactRecordBodyFields(record, ["password", "access_token"]); + + const redactedRequest = JSON.parse(record.request.body.toString("utf8")); + expect(redactedRequest.Password).toEqual("XXXX"); + expect(redactedRequest.ACCESS_TOKEN).toEqual("XXXX"); + }); + + it("redacts nested JSON fields", () => { + const requestJson = JSON.stringify({ + user: { + email: "user@example.com", + credentials: { + password: "secret123", + api_key: "key789", + }, + }, + }); + + const record = { + request: { + method: "POST", + path: "/api/user", + headers: {}, + body: Buffer.from(requestJson, "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from("{}", "utf8"), + }, + }; + + redactRecordBodyFields(record, ["password", "api_key"]); + + const redactedRequest = JSON.parse(record.request.body.toString("utf8")); + expect(redactedRequest.user.email).toEqual("user@example.com"); + expect(redactedRequest.user.credentials.password).toEqual("XXXX"); + expect(redactedRequest.user.credentials.api_key).toEqual("XXXX"); + }); + + it("redacts fields in arrays", () => { + const requestJson = JSON.stringify({ + users: [ + { username: "user1", password: "pass1" }, + { username: "user2", password: "pass2" }, + ], + }); + + const record = { + request: { + method: "POST", + path: "/api/users", + headers: {}, + body: Buffer.from(requestJson, "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from("{}", "utf8"), + }, + }; + + redactRecordBodyFields(record, ["password"]); + + const redactedRequest = JSON.parse(record.request.body.toString("utf8")); + expect(redactedRequest.users[0].username).toEqual("user1"); + expect(redactedRequest.users[0].password).toEqual("XXXX"); + expect(redactedRequest.users[1].username).toEqual("user2"); + expect(redactedRequest.users[1].password).toEqual("XXXX"); + }); + + it("redacts fields in response body", () => { + const responseJson = JSON.stringify({ + user: { + id: 123, + access_token: "token123", + refresh_token: "refresh456", + }, + }); + + const record = { + request: { + method: "POST", + path: "/login", + headers: {}, + body: Buffer.from("{}", "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from(responseJson, "utf8"), + }, + }; + + redactRecordBodyFields(record, ["access_token", "refresh_token"]); + + const redactedResponse = JSON.parse(record.response.body.toString("utf8")); + expect(redactedResponse.user.id).toEqual(123); + expect(redactedResponse.user.access_token).toEqual("XXXX"); + expect(redactedResponse.user.refresh_token).toEqual("XXXX"); + }); + + it("does not modify non-JSON bodies", () => { + const plainText = "This is plain text, not JSON"; + + const record = { + request: { + method: "POST", + path: "/text", + headers: {}, + body: Buffer.from(plainText, "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from("OK", "utf8"), + }, + }; + + redactRecordBodyFields(record, ["password"]); + + expect(record.request.body.toString("utf8")).toEqual(plainText); + expect(record.response.body.toString("utf8")).toEqual("OK"); + }); + + it("does not modify binary bodies", () => { + const record = { + request: { + method: "POST", + path: "/binary", + headers: {}, + body: BINARY_REQUEST, + }, + response: { + status: { code: 200 }, + headers: {}, + body: BINARY_RESPONSE, + }, + }; + + const originalRequest = Buffer.from(BINARY_REQUEST); + const originalResponse = Buffer.from(BINARY_RESPONSE); + + redactRecordBodyFields(record, ["password"]); + + expect(record.request.body).toEqual(originalRequest); + expect(record.response.body).toEqual(originalResponse); + }); + + it("handles empty body gracefully", () => { + const record = { + request: { + method: "GET", + path: "/empty", + headers: {}, + body: Buffer.from("", "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from("", "utf8"), + }, + }; + + redactRecordBodyFields(record, ["password"]); + + expect(record.request.body.toString("utf8")).toEqual(""); + expect(record.response.body.toString("utf8")).toEqual(""); + }); + + it("does nothing when no fields to redact", () => { + const requestJson = JSON.stringify({ + email: "user@example.com", + password: "secret123", + }); + + const record = { + request: { + method: "POST", + path: "/login", + headers: {}, + body: Buffer.from(requestJson, "utf8"), + }, + response: { + status: { code: 200 }, + headers: {}, + body: Buffer.from("{}", "utf8"), + }, + }; + + redactRecordBodyFields(record, []); + + const redactedRequest = JSON.parse(record.request.body.toString("utf8")); + expect(redactedRequest.email).toEqual("user@example.com"); + expect(redactedRequest.password).toEqual("secret123"); + }); +}); diff --git a/src/persistence.ts b/src/persistence.ts index 455cb169..22f51763 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -20,6 +20,7 @@ export class Persistence { constructor( private readonly tapeDir: string, private readonly redactHeaders: string[], + private readonly redactBodyFields: string[], ) {} /** @@ -41,10 +42,11 @@ export class Persistence { } /** - * Redacts the request headers of the given record, depending on the redactHeaders array + * Redacts the request headers and body fields of the given record */ private redact(record: TapeRecord): TapeRecord { redactRequestHeaders(record, this.redactHeaders); + redactRecordBodyFields(record, this.redactBodyFields); return record; } @@ -90,6 +92,76 @@ export function redactRequestHeaders( }); } +/** + * Redacts JSON body fields in request and response bodies + */ +export function redactRecordBodyFields( + record: TapeRecord, + redactFields: string[], +) { + if (redactFields.length === 0) { + return; + } + + // Redact request body + record.request.body = redactBufferFields(record.request.body, redactFields); + + // Redact response body + record.response.body = redactBufferFields(record.response.body, redactFields); +} + +/** + * Redacts fields in a Buffer by parsing as JSON if possible + */ +function redactBufferFields(buffer: Buffer, redactFields: string[]): Buffer { + if (!buffer || buffer.length === 0) { + return buffer; + } + + try { + const bodyString = buffer.toString("utf8"); + const parsed = JSON.parse(bodyString); + + // Recursively redact fields + redactObjectFields(parsed, redactFields); + + // Convert back to buffer + return Buffer.from(JSON.stringify(parsed), "utf8"); + } catch (e) { + // If not JSON or can't parse, return original buffer + return buffer; + } +} + +/** + * Recursively redacts fields in an object or array + */ +function redactObjectFields(obj: any, redactFields: string[]): void { + if (typeof obj !== "object" || obj === null) { + return; + } + + if (Array.isArray(obj)) { + // Handle arrays + obj.forEach((item) => redactObjectFields(item, redactFields)); + } else { + // Handle objects + Object.keys(obj).forEach((key) => { + // Check if this key should be redacted (case-insensitive) + const shouldRedact = redactFields.some( + (field) => field.toLowerCase() === key.toLowerCase(), + ); + + if (shouldRedact) { + obj[key] = "XXXX"; + } else if (typeof obj[key] === "object" && obj[key] !== null) { + // Recursively redact nested objects/arrays + redactObjectFields(obj[key], redactFields); + } + }); + } +} + export function persistTape(record: TapeRecord): PersistedTapeRecord { return { request: { diff --git a/src/server.ts b/src/server.ts index be62d91c..016a3143 100644 --- a/src/server.ts +++ b/src/server.ts @@ -44,6 +44,7 @@ export class RecordReplayServer { timeout?: number; enableLogging?: boolean; redactHeaders?: string[]; + redactBodyFields?: string[]; preventConditionalRequests?: boolean; httpsCA?: string; httpsKey?: string; @@ -60,7 +61,12 @@ export class RecordReplayServer { this.timeout = options.timeout || 5000; this.loggingEnabled = options.enableLogging || false; const redactHeaders = options.redactHeaders || []; - this.persistence = new Persistence(options.tapeDir, redactHeaders); + const redactBodyFields = options.redactBodyFields || []; + this.persistence = new Persistence( + options.tapeDir, + redactHeaders, + redactBodyFields, + ); this.defaultTape = options.defaultTapeName; this.preventConditionalRequests = options.preventConditionalRequests; this.rewriteBeforeDiffRules = diff --git a/src/tests/tapes/match-requests/tape.yml b/src/tests/tapes/match-requests/tape.yml index 95548d9c..516cd4dd 100644 --- a/src/tests/tapes/match-requests/tape.yml +++ b/src/tests/tapes/match-requests/tape.yml @@ -3,12 +3,13 @@ http_interactions: method: POST path: /json/identity headers: - accept: 'application/json, text/plain, */*' - content-type: application/json;charset=utf-8 - user-agent: axios/0.18.1 + accept: application/json, text/plain, */* + content-type: application/json + user-agent: axios/1.7.2 content-length: '12' - host: 'localhost:4000' - connection: close + accept-encoding: gzip, compress, deflate, br + host: localhost:4000 + connection: keep-alive body: encoding: utf8 data: '{"field3":1}' @@ -21,8 +22,9 @@ http_interactions: content-type: application/json; charset=utf-8 content-length: '29' etag: W/"1d-VScr985hiId2x6Go7UQU+rhoiH4" - date: 'Wed, 10 Jul 2019 06:19:13 GMT' - connection: close + date: Wed, 07 Jan 2026 14:21:52 GMT + connection: keep-alive + keep-alive: timeout=5 body: encoding: utf8 data: '{"field3":1,"requestCount":8}' @@ -31,12 +33,13 @@ http_interactions: method: POST path: /json/identity headers: - accept: 'application/json, text/plain, */*' - content-type: application/json;charset=utf-8 - user-agent: axios/0.18.1 + accept: application/json, text/plain, */* + content-type: application/json + user-agent: axios/1.7.2 content-length: '12' - host: 'localhost:4000' - connection: close + accept-encoding: gzip, compress, deflate, br + host: localhost:4000 + connection: keep-alive body: encoding: utf8 data: '{"field3":1}' @@ -49,8 +52,9 @@ http_interactions: content-type: application/json; charset=utf-8 content-length: '29' etag: W/"1d-0K0LkiFwFoK7E4ko+TIPXZRaod4" - date: 'Wed, 10 Jul 2019 06:19:13 GMT' - connection: close + date: Wed, 07 Jan 2026 14:21:52 GMT + connection: keep-alive + keep-alive: timeout=5 body: encoding: utf8 data: '{"field3":1,"requestCount":9}'