Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions packages/worker/src/mcp/fetch-gateway.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,105 @@ 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 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()
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')
Expand Down
64 changes: 48 additions & 16 deletions packages/worker/src/mcp/fetch-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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<ReferencedSecret>) {
Expand Down Expand Up @@ -470,9 +472,39 @@ 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<ArrayBuffer> }

/**
* 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<GatewayRequestBody | null> {
if (!shouldSendBody(request.method)) return null
return request.text()
const bytes = new Uint8Array(await request.arrayBuffer())
try {
// 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 }
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function requestBodyInit(requestBody: GatewayRequestBody | null) {
if (requestBody == null) return undefined
return requestBody.kind === 'binary' ? requestBody.bytes : requestBody.text
}

function shouldSendBody(method: string) {
Expand Down
Loading