mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
e33106b51e
commit
50ca4725df
+6
-1
@@ -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<void>;
|
||||
/** 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<void> => {
|
||||
let reqBodyGz: Uint8Array | undefined;
|
||||
if (is4xx && reqBodyBytes && reqBodyBytes.byteLength > 0) {
|
||||
if (config.captureErrorReqBody && is4xx && reqBodyBytes && reqBodyBytes.byteLength > 0) {
|
||||
try {
|
||||
reqBodyGz = await gzipBytes(reqBodyBytes);
|
||||
} catch {
|
||||
|
||||
+16
@@ -49,6 +49,9 @@ interface RuntimeConfig {
|
||||
gatewayBaseUrl?: string;
|
||||
gatewayHeaders?: Record<string, string>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user