From 54e7770ae9dd9289059bdcb9955f8a7b340f41e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 06:30:37 +0000 Subject: [PATCH 1/2] Preserve binary request bodies in the MCP fetch gateway Co-authored-by: Kent C. Dodds --- .../worker/src/mcp/fetch-gateway.node.test.ts | 82 +++++++++++++++++++ packages/worker/src/mcp/fetch-gateway.ts | 59 +++++++++---- 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/packages/worker/src/mcp/fetch-gateway.node.test.ts b/packages/worker/src/mcp/fetch-gateway.node.test.ts index 197e22c8b3..75c9f94182 100644 --- a/packages/worker/src/mcp/fetch-gateway.node.test.ts +++ b/packages/worker/src/mcp/fetch-gateway.node.test.ts @@ -223,6 +223,88 @@ test('opt-out header value "on" resolves normally and is stripped; unknown value } }) +test('fetch gateway preserves binary request bodies byte-for-byte', async () => { + // PNG-like header bytes: invalid UTF-8, so the body must skip the text + // pipeline entirely and pass through unchanged. + const binaryBytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00, 0x01, + ]) + const boundary = '----TestBoundary123' + const encoder = new TextEncoder() + const prefix = encoder.encode( + `--${boundary}\r\nContent-Disposition: form-data; name="files[0]"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`, + ) + const suffix = encoder.encode(`\r\n--${boundary}--\r\n`) + const multipartBody = new Uint8Array( + prefix.length + binaryBytes.length + suffix.length, + ) + multipartBody.set(prefix, 0) + multipartBody.set(binaryBytes, prefix.length) + multipartBody.set(suffix, prefix.length + binaryBytes.length) + + const resolveSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: true, + value: 'secret-value', + scope: 'user', + allowedHosts: ['discord.com'], + allowedCapabilities: [], + }) + try { + const request = new Request('https://discord.com/api/channels/1/messages', { + method: 'POST', + headers: { + Authorization: 'Bot {{secret:discordBotToken|scope=user}}', + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }, + body: multipartBody, + }) + const transformed = await expandSecretPlaceholders({ request, props, env }) + // Header placeholders still resolve for binary-bodied requests. + expect(transformed.headers.get('Authorization')).toBe('Bot secret-value') + const transformedBytes = new Uint8Array(await transformed.arrayBuffer()) + expect(transformedBytes).toEqual(multipartBody) + } finally { + resolveSpy.mockRestore() + } +}) + +test('fetch gateway never resolves secret placeholder text embedded in a binary body', async () => { + const resolveSpy = vi.spyOn(secretService, 'resolveSecret') + const encoder = new TextEncoder() + const placeholderText = encoder.encode('{{secret:name|scope=user}}') + const body = new Uint8Array(placeholderText.length + 2) + // Leading invalid UTF-8 byte marks the body as binary. + body[0] = 0xff + body.set(placeholderText, 1) + body[body.length - 1] = 0xfe + + try { + const request = new Request('https://example.com/upload', { + method: 'PUT', + body, + }) + const transformed = await expandSecretPlaceholders({ request, props, env }) + expect(resolveSpy).not.toHaveBeenCalled() + const transformedBytes = new Uint8Array(await transformed.arrayBuffer()) + expect(transformedBytes).toEqual(body) + } finally { + resolveSpy.mockRestore() + } +}) + +test('opt-out header passes binary bodies through unchanged', async () => { + const binaryBody = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]) + const request = new Request('https://example.com/upload', { + method: 'POST', + headers: { [secretResolutionHeaderName]: 'off' }, + body: binaryBody, + }) + const transformed = await expandSecretPlaceholders({ request, props, env }) + expect(new Uint8Array(await transformed.arrayBuffer())).toEqual(binaryBody) +}) + test('fetch gateway expands placeholders in form-urlencoded bodies', async () => { const resolveSpy = vi .spyOn(secretService, 'resolveSecret') diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts index e862b16288..928a000c75 100644 --- a/packages/worker/src/mcp/fetch-gateway.ts +++ b/packages/worker/src/mcp/fetch-gateway.ts @@ -161,7 +161,7 @@ export async function expandSecretPlaceholders(input: { { method: input.request.method, headers, - body: requestBody ?? undefined, + body: requestBodyInit(requestBody), redirect: input.request.redirect, credentials: input.request.credentials, mode: input.request.mode, @@ -288,11 +288,13 @@ export async function expandSecretPlaceholders(input: { const nextBody = requestBody == null ? undefined - : replaceSecretPlaceholdersInRequestBody( - headers, - requestBody, - replacements, - ) + : requestBody.kind === 'binary' + ? requestBody.bytes + : replaceSecretPlaceholdersInRequestBody( + headers, + requestBody.text, + replacements, + ) const nextRedirect = hasReferencedSecrets && input.request.redirect === 'follow' ? 'manual' @@ -412,26 +414,26 @@ function collectReferencedBasicAuthSecretPlaceholders( function collectReferencedBasicAuthSecretPlaceholdersFromRequestBody( headers: Headers, - requestBody: string | null, + requestBody: GatewayRequestBody | null, ) { - if (!requestBody) return [] + if (requestBody?.kind !== 'text' || !requestBody.text) return [] return isFormUrlEncodedRequest(headers) ? dedupeBasicAuthSecretPlaceholders( - parseBasicAuthSecretPlaceholdersFromFormUrlEncoded(requestBody), + parseBasicAuthSecretPlaceholdersFromFormUrlEncoded(requestBody.text), ) - : collectReferencedBasicAuthSecretPlaceholders([requestBody]) + : collectReferencedBasicAuthSecretPlaceholders([requestBody.text]) } function collectReferencedSecretsFromRequestBody( headers: Headers, - requestBody: string | null, + requestBody: GatewayRequestBody | null, ) { - if (!requestBody) return [] + if (requestBody?.kind !== 'text' || !requestBody.text) return [] return isFormUrlEncodedRequest(headers) ? dedupeReferencedSecrets( - parseSecretPlaceholdersFromFormUrlEncoded(requestBody), + parseSecretPlaceholdersFromFormUrlEncoded(requestBody.text), ) - : collectReferencedSecrets([requestBody]) + : collectReferencedSecrets([requestBody.text]) } function dedupeReferencedSecrets(referencedSecrets: Array) { @@ -470,9 +472,34 @@ function isFormUrlEncodedRequest(headers: Headers) { return contentType.startsWith('application/x-www-form-urlencoded') } -async function readRequestBody(request: Request) { +type GatewayRequestBody = + | { kind: 'text'; text: string } + | { kind: 'binary'; bytes: Uint8Array } + +/** + * Read the outbound request body without corrupting binary payloads. Bodies + * that decode as valid UTF-8 keep the text pipeline (secret placeholder + * scanning and replacement). Anything else — for example multipart uploads + * carrying raw file bytes — passes through byte-for-byte, and secret + * placeholders inside such a body are intentionally not resolved (URL and + * header placeholders still are). + */ +async function readRequestBody( + request: Request, +): Promise { if (!shouldSendBody(request.method)) return null - return request.text() + const bytes = new Uint8Array(await request.arrayBuffer()) + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return { kind: 'text', text } + } catch { + return { kind: 'binary', bytes } + } +} + +function requestBodyInit(requestBody: GatewayRequestBody | null) { + if (requestBody == null) return undefined + return requestBody.kind === 'binary' ? requestBody.bytes : requestBody.text } function shouldSendBody(method: string) { From d3e7a4f13c3d79de9d2e2254b1ac4189bc81c83a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Jul 2026 06:49:31 +0000 Subject: [PATCH 2/2] Preserve leading UTF-8 BOM when decoding gateway text bodies Co-authored-by: Kent C. Dodds --- .../worker/src/mcp/fetch-gateway.node.test.ts | 17 +++++++++++++++++ packages/worker/src/mcp/fetch-gateway.ts | 7 ++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/mcp/fetch-gateway.node.test.ts b/packages/worker/src/mcp/fetch-gateway.node.test.ts index 75c9f94182..68f19155fa 100644 --- a/packages/worker/src/mcp/fetch-gateway.node.test.ts +++ b/packages/worker/src/mcp/fetch-gateway.node.test.ts @@ -270,6 +270,23 @@ test('fetch gateway preserves binary request bodies byte-for-byte', async () => } }) +test('fetch gateway preserves a leading UTF-8 BOM in text request bodies', async () => { + const bomBody = new Uint8Array([ + 0xef, + 0xbb, + 0xbf, + ...new TextEncoder().encode('{"note":"bom-prefixed json"}'), + ]) + const request = new Request('https://example.com/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: bomBody, + }) + const transformed = await expandSecretPlaceholders({ request, props, env }) + const transformedBytes = new Uint8Array(await transformed.arrayBuffer()) + expect(transformedBytes).toEqual(bomBody) +}) + test('fetch gateway never resolves secret placeholder text embedded in a binary body', async () => { const resolveSpy = vi.spyOn(secretService, 'resolveSecret') const encoder = new TextEncoder() diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts index 928a000c75..fe8d32c601 100644 --- a/packages/worker/src/mcp/fetch-gateway.ts +++ b/packages/worker/src/mcp/fetch-gateway.ts @@ -490,7 +490,12 @@ async function readRequestBody( if (!shouldSendBody(request.method)) return null const bytes = new Uint8Array(await request.arrayBuffer()) try { - const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + // ignoreBOM keeps a leading UTF-8 BOM in the decoded text so text + // bodies round-trip byte-for-byte after placeholder expansion. + const text = new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(bytes) return { kind: 'text', text } } catch { return { kind: 'binary', bytes }