Skip to content

Commit cf8469e

Browse files
committed
fix(mcp): decode Content-Encoding on the undici.request transport (fetch parity)
undici.request returns raw bytes; fetch auto-decompresses. Restore that: pipe a gzip/deflate/br response body through the matching zlib decoder before the WHATWG bridge and strip content-encoding/content-length. maxResponseSize still caps the compressed wire bytes; errors forward into the decoder and the source is torn down when the decoded stream ends or is cancelled. Adds a gzip decode test.
1 parent 72f9fc9 commit cf8469e

2 files changed

Lines changed: 71 additions & 2 deletions

File tree

apps/sim/lib/core/security/guarded-request-fetch.server.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* serves buffered reads, and settles the reader when the source is destroyed without an error.
1010
*/
1111
import { Readable } from 'node:stream'
12+
import { gzipSync } from 'node:zlib'
1213
import { beforeEach, describe, expect, it, vi } from 'vitest'
1314

1415
const { mockAgent, mockUndiciRequest } = vi.hoisted(() => {
@@ -180,6 +181,27 @@ describe('createSsrfGuardedFetchWithDispatcher (undici.request backed)', () => {
180181
await reader.read()
181182
})
182183

184+
it('decodes a gzip Content-Encoding body and strips the framing headers (fetch parity)', async () => {
185+
const gzipped = gzipSync(Buffer.from(JSON.stringify({ ok: true, msg: 'compressed' })))
186+
const source = new Readable({ read() {} })
187+
source.push(gzipped)
188+
source.push(null)
189+
mockUndiciRequest.mockResolvedValueOnce(
190+
undiciReply(
191+
200,
192+
{ 'content-type': 'application/json', 'content-encoding': 'gzip', 'content-length': '40' },
193+
source
194+
)
195+
)
196+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
197+
198+
const response = await fetch('https://mcp.example.com/data', { method: 'GET' })
199+
200+
expect(await response.json()).toEqual({ ok: true, msg: 'compressed' })
201+
expect(response.headers.get('content-encoding')).toBeNull()
202+
expect(response.headers.get('content-length')).toBeNull()
203+
})
204+
183205
it('rejects the reader when the source is destroyed without an error (abort/reset)', async () => {
184206
const source = new Readable({ read() {} }) // stays open, never pushes
185207
mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, source))

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Readable } from 'node:stream'
2+
import zlib from 'node:zlib'
23
import dns from 'dns/promises'
34
import http from 'http'
45
import https from 'https'
@@ -676,6 +677,28 @@ function toUndiciRequestBody(
676677
return body as unknown as string | Buffer | Uint8Array | Readable
677678
}
678679

680+
/**
681+
* Decompression transform for a `Content-Encoding`, or `null` to pass the body through.
682+
* `undici.fetch` decodes the body automatically; `undici.request` does not, so this restores
683+
* fetch parity for gzip/deflate/br responses (common behind CDNs). Unknown/absent encodings
684+
* pass through untouched.
685+
*/
686+
function contentEncodingDecoder(
687+
encoding: string
688+
): zlib.Gunzip | zlib.Inflate | zlib.BrotliDecompress | null {
689+
switch (encoding) {
690+
case 'gzip':
691+
case 'x-gzip':
692+
return zlib.createGunzip()
693+
case 'deflate':
694+
return zlib.createInflate()
695+
case 'br':
696+
return zlib.createBrotliDecompress()
697+
default:
698+
return null
699+
}
700+
}
701+
679702
/**
680703
* Bridges an undici `request()` Node `Readable` into a WHATWG `ReadableStream` for a
681704
* `Response` body. Node's built-in `Readable.toWeb` is NOT used: its adapter throws an
@@ -811,8 +834,32 @@ async function undiciRequestAsResponse(
811834
// 204/205/304 carry no body; `new Response` rejects a non-null body for them. Drain
812835
// undici's (empty) stream so its socket returns to the Agent pool instead of leaking.
813836
const isNullBody = statusCode === 204 || statusCode === 205 || statusCode === 304
814-
if (isNullBody) body.resume()
815-
const response = new Response(isNullBody ? null : nodeReadableToWebStream(body), {
837+
if (isNullBody) {
838+
body.resume()
839+
const response = new Response(null, { status: statusCode, headers: responseHeaders })
840+
Object.defineProperty(response, 'url', { value: url, configurable: true })
841+
return response
842+
}
843+
844+
// Decode Content-Encoding like `fetch` does (`request()` returns raw bytes). `maxResponseSize`
845+
// still caps the compressed wire bytes on `body`; forward its errors into the decoder so the
846+
// cap and any socket reset still surface as a stream error, and tear the source down when the
847+
// decoded stream ends or is cancelled so the socket can't leak.
848+
const contentEncoding = String(headers['content-encoding'] ?? '')
849+
.toLowerCase()
850+
.trim()
851+
const decoder = contentEncodingDecoder(contentEncoding)
852+
let bodyStream: Readable = body
853+
if (decoder) {
854+
body.once('error', (err) => decoder.destroy(err))
855+
decoder.once('close', () => body.destroy())
856+
bodyStream = body.pipe(decoder)
857+
// The bridged body is now decoded; drop framing headers that would misdescribe it.
858+
responseHeaders.delete('content-encoding')
859+
responseHeaders.delete('content-length')
860+
}
861+
862+
const response = new Response(nodeReadableToWebStream(bodyStream), {
816863
status: statusCode,
817864
headers: responseHeaders,
818865
})

0 commit comments

Comments
 (0)