From 50ca4725dfcf6fc26e416030069633dbc73c11d0 Mon Sep 17 00:00:00 2001 From: Shubham Srivastava Date: Thu, 16 Jul 2026 23:00:09 +0530 Subject: [PATCH] fix(proxy): stop persisting 4xx request bodies by default (privacy) (#74) Request bodies hold full prompts and any secrets in context. On 4xx (429s are routine) they were gzipped to ~/.pxpipe/events.jsonl / 4xx-bodies/ with no retention limit. Gate the capture behind captureErrorReqBody (env PXPIPE_DEBUG_CAPTURE_4XX=1), default off, with a one-time startup warning. The upstream errorBody (no user content) and the sha8 hash still land. Closes #69. Co-authored-by: Claude Opus 4.8 --- src/core/proxy.ts | 7 ++++++- src/node.ts | 16 +++++++++++++++ tests/proxy-usage.test.ts | 41 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/core/proxy.ts b/src/core/proxy.ts index f48ed06..a73c82c 100644 --- a/src/core/proxy.ts +++ b/src/core/proxy.ts @@ -37,6 +37,11 @@ export interface ProxyConfig { transform?: TransformOptions | (() => TransformOptions); /** Called after every request — useful for logging / metrics in the host. */ onRequest?: (event: ProxyEvent) => void | Promise; + /** Persist the gzipped request body on 4xx (→ reqBodyGz, sidecar/inline). + * Off by default: request bodies hold full prompts and any secrets in + * context, so raw-body capture is opt-in debugging only. The upstream + * error body (errorBody) is unaffected — it carries no user content. */ + captureErrorReqBody?: boolean; } export interface ProxyEvent { @@ -637,7 +642,7 @@ export function createProxy(config: ProxyConfig = {}) { // Gzip body lazily (only on 4xx). Async IIFE keeps fire() synchronous. const finalize = async (): Promise => { let reqBodyGz: Uint8Array | undefined; - if (is4xx && reqBodyBytes && reqBodyBytes.byteLength > 0) { + if (config.captureErrorReqBody && is4xx && reqBodyBytes && reqBodyBytes.byteLength > 0) { try { reqBodyGz = await gzipBytes(reqBodyBytes); } catch { diff --git a/src/node.ts b/src/node.ts index a53a437..ff75f3b 100644 --- a/src/node.ts +++ b/src/node.ts @@ -49,6 +49,9 @@ interface RuntimeConfig { gatewayBaseUrl?: string; gatewayHeaders?: Record; eventsFile: string; + /** Persist 4xx request bodies to disk for debugging. Off unless + * PXPIPE_DEBUG_CAPTURE_4XX=1. */ + captureErrorReqBody: boolean; } const DEFAULT_CONFIG_FILE = path.join(os.homedir(), '.config', 'pxpipe', 'config.json'); @@ -117,6 +120,9 @@ function parseCli(argv: string[]): RuntimeConfig { eventsFile: process.env.PXPIPE_LOG ?? path.join(os.homedir(), '.pxpipe', 'events.jsonl'), + // Off by default: 4xx request bodies hold full prompts + any secrets in + // context. Opt in for debugging only. (issue #69) + captureErrorReqBody: process.env.PXPIPE_DEBUG_CAPTURE_4XX === '1', }; } @@ -168,6 +174,9 @@ Environment: PXPIPE_LOG JSONL events path (default ~/.pxpipe/events.jsonl) PXPIPE_DUMP_DIR debug: write every rendered PNG here (what the model sees); off unless set. Compress arm only. + PXPIPE_DEBUG_CAPTURE_4XX debug: set to 1 to persist full 4xx request bodies + (prompts + any secrets in context) to disk. Off by + default; on-disk telemetry keeps only hashes. Use with Claude Code: ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude @@ -959,6 +968,7 @@ async function main(): Promise { upstream: opts.upstream, openAIUpstream: opts.openAIUpstream, openAIApiKey: opts.openAIApiKey, + captureErrorReqBody: opts.captureErrorReqBody, // Per-request transform options: // 1. Runtime kill switch — when the dashboard "passthrough" toggle // is off, force compress=false so /v1/messages forwards @@ -1099,6 +1109,12 @@ async function main(): Promise { console.log(`[pxpipe] openai upstream → ${routes.openai}`); console.log(`[pxpipe] tracking events → ${opts.eventsFile}`); console.log(`[pxpipe] dashboard → http://127.0.0.1:${opts.port}/`); + if (opts.captureErrorReqBody) { + console.warn( + `[pxpipe] PXPIPE_DEBUG_CAPTURE_4XX=1 — persisting full 4xx request bodies ` + + `(prompts + any secrets in context) to ${bodySidecarDir}. Debugging only.`, + ); + } }); // server.close() only stops accepting new connections and waits for open diff --git a/tests/proxy-usage.test.ts b/tests/proxy-usage.test.ts index 66a734d..0e7cb0b 100644 --- a/tests/proxy-usage.test.ts +++ b/tests/proxy-usage.test.ts @@ -896,11 +896,13 @@ describe('proxy usage extraction', () => { return new Uint8Array(await new Response(stream).arrayBuffer()); } - it('captures the FULL gzipped transformed body on 4xx + sets reqBodySha8', async () => { + it('captures the FULL gzipped transformed body on 4xx (opt-in) + sets reqBodySha8', async () => { // Pair with errorBody so a future debugger can reconstruct // "we sent X, Anthropic said Y" from the JSONL alone. We gzip the body // so even a 170 KiB transformed payload fits inline once base64'd // (typical PNG-heavy bodies compress to <10% of source). + // captureErrorReqBody is off by default (privacy, issue #69); this test + // opts in explicitly. const restore = mockUpstream( () => new Response(JSON.stringify({ error: { type: 'bad' } }), { @@ -912,6 +914,7 @@ describe('proxy usage extraction', () => { let captured: ProxyEvent | undefined; const proxy = createProxy({ transform: {}, + captureErrorReqBody: true, onRequest: (e) => { captured = e; }, @@ -948,6 +951,42 @@ describe('proxy usage extraction', () => { expect(parsed.messages[0].role).toBe('user'); }); + it('does NOT capture the 4xx request body by default (privacy, issue #69)', async () => { + // Request bodies hold full prompts + any secrets in context, so raw-body + // capture is opt-in only. errorBody (the upstream error) still lands. + const restore = mockUpstream( + () => + new Response(JSON.stringify({ error: { type: 'bad' } }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }), + ); + + let captured: ProxyEvent | undefined; + const proxy = createProxy({ + transform: {}, + onRequest: (e) => { + captured = e; + }, + }); + + const res = await proxy( + new Request('http://localhost/v1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: SAMPLE_REQ_BODY, + }), + ); + await res.text(); + await new Promise((r) => setTimeout(r, 20)); + restore(); + + expect(captured!.status).toBe(400); + expect(captured!.reqBodySha8).toMatch(/^[0-9a-f]{8}$/); // hash still lands + expect(captured!.reqBodyGz).toBeUndefined(); // but not the raw body + expect(captured!.errorBody).toBeDefined(); // upstream error still captured + }); + it('does NOT gzip the request body on 2xx (but still sets reqBodySha8)', async () => { const restore = mockUpstream( () =>