Skip to content

Commit 57ab9f8

Browse files
lawrencecchenclaude
andcommitted
Redact query values in transcript upstream URLs (security audit)
Transcript metadata persisted the full upstream URL including the client query string; providers that accept credentials in query params would have their keys stored in DO transcripts. redactedUpstreamURL keeps origin + path + query key names and replaces every value with [redacted] across the HTTP and WebSocket proxy meta; the actual upstream fetch URL is unchanged. Tests assert the recorded meta keeps the key name, drops the value, and the upstream request still receives the original query. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 093efcc commit 57ab9f8

4 files changed

Lines changed: 75 additions & 14 deletions

File tree

cloudflare/packages/worker/src/contract.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,14 @@ export const upstreamURLForRequest = (
764764
return output
765765
}
766766

767+
export const redactedUpstreamURL = (url: URL | string): string => {
768+
const input = new URL(url.toString())
769+
const query = [...input.searchParams]
770+
.map(([key]) => `${encodeURIComponent(key)}=[redacted]`)
771+
.join("&")
772+
return `${input.origin}${input.pathname}${query ? `?${query}` : ""}`
773+
}
774+
767775
const upstreamPath = (
768776
requestPath: string,
769777
account: StoredAccountContract,

cloudflare/packages/worker/src/index.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
normalizeAgentType,
1515
parseProxyRouteInput,
1616
providerForAccount,
17+
redactedUpstreamURL,
1718
refreshFailureFromError,
1819
refreshOAuthCredentials,
1920
safeGoAccount,
@@ -2718,11 +2719,8 @@ const proxyUpstream = async (
27182719
{ status: 503 }
27192720
)
27202721
}
2721-
const upstreamURL = upstreamURLForRequest(
2722-
request.url,
2723-
account,
2724-
defaultUpstreamForAccount(env, account)
2725-
)
2722+
const defaultUpstream = defaultUpstreamForAccount(env, account)
2723+
const upstreamURL = upstreamURLForRequest(request.url, account, defaultUpstream)
27262724
const headers = setUpstreamAuthHeaders(request.headers, account)
27272725
const agentType = routeInput.agentType ?? "codex"
27282726
const transcriptMetaInput = {
@@ -2736,7 +2734,7 @@ const proxyUpstream = async (
27362734
account: account.id,
27372735
method: request.method,
27382736
path: new URL(request.url).pathname,
2739-
upstream: upstreamURL.toString(),
2737+
upstream: redactedUpstreamURL(upstreamURL),
27402738
headers: redactedHeaders(stripSubrouterHeaders(request.headers)),
27412739
},
27422740
} satisfies TranscriptEventInput
@@ -2814,11 +2812,8 @@ const proxyUpstreamWebSocket = async (
28142812
{ status: 503 }
28152813
)
28162814
}
2817-
const upstreamURL = upstreamURLForRequest(
2818-
request.url,
2819-
account,
2820-
defaultUpstreamForAccount(env, account)
2821-
)
2815+
const defaultUpstream = defaultUpstreamForAccount(env, account)
2816+
const upstreamURL = upstreamURLForRequest(request.url, account, defaultUpstream)
28222817
const headers = setUpstreamAuthHeaders(request.headers, account)
28232818
headers.set("Upgrade", "websocket")
28242819
await actor.recordTranscriptMeta({
@@ -2832,8 +2827,8 @@ const proxyUpstreamWebSocket = async (
28322827
account: account.id,
28332828
method: request.method,
28342829
path: new URL(request.url).pathname,
2835-
upstream: defaultUpstreamForAccount(env, account),
2836-
upstream_url: upstreamURL.toString(),
2830+
upstream: redactedUpstreamURL(defaultUpstream),
2831+
upstream_url: redactedUpstreamURL(upstreamURL),
28372832
headers: redactedHeaders(headers),
28382833
},
28392834
})

cloudflare/packages/worker/test/contract.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
fetchProviderUsage,
66
refreshOAuthCredentials,
77
parseProxyRouteInput,
8+
redactedUpstreamURL,
89
safeGoAccount,
910
scopedSessionKey,
1011
setUpstreamAuthHeaders,
@@ -138,6 +139,16 @@ describe("subrouter Durable Object contract", () => {
138139
).toBe("https://api.openai.com/v1/responses")
139140
})
140141

142+
test("redacted upstream URLs keep parameter names without values", () => {
143+
expect(
144+
redactedUpstreamURL(
145+
"https://api.openai.com/v1/chat/completions?key=sk-secret123&empty=&key=second#ignored"
146+
)
147+
).toBe(
148+
"https://api.openai.com/v1/chat/completions?key=[redacted]&empty=[redacted]&key=[redacted]"
149+
)
150+
})
151+
141152
test("codex oauth refresh rotates access, refresh, and id tokens", async () => {
142153
const now = 1_780_000_000_000
143154
const expired = jwt(Math.floor((now - 1_000) / 1000))

cloudflare/packages/worker/test/proxy-streaming.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,52 @@ describe("HTTP proxy streaming transcripts", () => {
131131
})
132132
})
133133

134+
test("redacts upstream query values in transcript meta without changing the upstream request", async () => {
135+
const events: TranscriptEvent[] = []
136+
const waitUntilPromises: Promise<unknown>[] = []
137+
let fetchedURL = ""
138+
const actor = fakeActor({
139+
async recordTranscriptMeta(input) {
140+
events.push({ kind: "meta", input })
141+
return { ok: true }
142+
},
143+
async recordTranscriptBody(input) {
144+
events.push({ kind: "body", input })
145+
return { ok: true }
146+
},
147+
})
148+
149+
setFetch(async (input) => {
150+
fetchedURL = new Request(input).url
151+
return new Response("ok", { status: 200 })
152+
})
153+
const response = await proxyUpstream(
154+
proxyRequest({
155+
body: "client-body",
156+
sessionId: "redacted-query-session",
157+
query: "?api_key=sk-secret123&debug=true",
158+
}),
159+
fakeEnv(),
160+
actor,
161+
routeInput("redacted-query-session"),
162+
undefined,
163+
(promise: Promise<unknown>) => waitUntilPromises.push(promise)
164+
)
165+
166+
expect(response.status).toBe(200)
167+
await response.text()
168+
await Promise.all(waitUntilPromises)
169+
170+
expect(fetchedURL).toBe(
171+
"https://upstream.example/backend-api/codex/responses?api_key=sk-secret123&debug=true"
172+
)
173+
expect(events[0]?.input.payload.upstream).toBe(
174+
"https://upstream.example/backend-api/codex/responses?api_key=[redacted]&debug=[redacted]"
175+
)
176+
expect(JSON.stringify(events[0]?.input.payload)).toContain("api_key")
177+
expect(JSON.stringify(events[0]?.input.payload)).not.toContain("sk-secret123")
178+
})
179+
134180
test("isolates deferred transcript RPC failures from the client response", async () => {
135181
const waitUntilPromises: Promise<unknown>[] = []
136182
const actor = fakeActor({
@@ -298,8 +344,9 @@ const proxyRequest = (input: {
298344
readonly method?: string
299345
readonly body?: string
300346
readonly sessionId: string
347+
readonly query?: string
301348
}): Request =>
302-
new Request("https://subrouter.example/v1/responses", {
349+
new Request(`https://subrouter.example/v1/responses${input.query ?? ""}`, {
303350
method: input.method ?? "POST",
304351
headers: {
305352
Authorization: "Bearer tenant-key",

0 commit comments

Comments
 (0)