LibreChat/packages/api
Nick Haffie-Emslie 3758380c61
🔌 fix: Follow 307/308 redirects in MCP streamable HTTP transport (#12850)
* 🔌 fix: Follow 307/308 redirects in MCP streamable HTTP transport

Some MCP servers (e.g. Coda) return 308 Permanent Redirect to route
doc-scoped tool calls to a different endpoint path. The fetch wrapper
used `redirect: 'manual'` for SSRF protection, which silently dropped
these redirects and caused tool calls to fail with empty error bodies.

Follow 307/308 redirects (method-preserving per RFC 7538) up to a
depth of 5. SSRF safety is preserved because the same undici Agent
with its SSRF-safe connect function validates redirect targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🛡️ fix: Harden MCP 307/308 redirect handling against SSRF and credential leaks

- Validate every redirect target against `resolveHostnameSSRF` so allowlist
  deployments (which disable connect-time SSRF protection) still block hops to
  private/reserved IPs.
- Strip `Authorization`, `Cookie`, `mcp-session-id`, and any user-injected
  headers when a 307/308 crosses an origin boundary, mirroring browser/Fetch
  behavior so a redirecting MCP server can't exfiltrate credentials.
- Cancel the intermediate response body before each next hop so undici can
  reuse pooled sockets rather than holding them until GC.
- Restructure redirect test helpers to be same-origin (matching real-world
  Coda-style routing), drop dead setup code, fix the misleading "5 hops
  successfully" test, and add coverage for SSRF-blocked redirects, cross-
  origin credential stripping, and same-origin credential preservation.

* 🛡️ fix: Also strip `serverConfig.headers` on cross-origin MCP redirects

Previously only runtime `setRequestHeaders` keys were treated as secret on a
307/308 cross-origin hop. API keys baked into `serverConfig.headers` (passed
through `requestInit.headers` at transport construction time) survived
stripping, so a malicious MCP endpoint could exfiltrate them by returning a
cross-origin `Location`. Pass the configured header keys through to
`createFetchFunction` so both runtime and config secrets are stripped.

The cross-origin credential test now also configures `serverConfig.headers`
to lock in this behavior.

* 🧹 chore: Tighten MCP redirect-stripping coverage and helper duplication

- Add `proxy-authorization` to the cross-origin forbidden header set so a
  forward-proxy credential header would also be stripped on a cross-origin
  hop, matching the Fetch-spec list.
- Strengthen the cross-origin credential test with positive assertions that
  benign protocol headers (`accept`, `content-type`) survive the hop, so a
  regression that strips everything indiscriminately would now fail.
- Extract the duplicated MCP request handler / session-teardown logic from
  three test helpers into shared `createMCPRequestHandler` and
  `closeMCPSessions` utilities.

* 🛠️ fix: Handle `Request` inputs in MCP `customFetch` URL derivation

`customFetch` is typed to accept `UndiciRequestInfo` (`string | URL | Request`),
but `Request.prototype.toString()` returns `"[object Request]"`. The previous
implementation derived `originalOrigin` and the redirect base via
`url.toString()`, so a `Request` input would throw inside `new URL(...)` before
any network call — a regression even when no redirect was involved.

Add a `getRequestUrlString` helper that extracts the URL string for all three
shapes, track the URL string alongside the fetch input through the redirect
loop, and add parameterized tests that exercise `customFetch` with each shape.

* 🛠️ fix: Don't override `Request` input headers in MCP `buildFetchInit`

Previously `buildFetchInit` always set `headers` on the returned init —
even when neither `init.headers` nor runtime headers contributed anything.
Passing `headers: {}` to `undiciFetch` overrides the headers carried on a
`Request` input (auth tokens, MCP session, protocol negotiation), so
Request-based wrappers could fail authentication even without a redirect
in play. Skip the `headers` override entirely when there is nothing to
merge.

Adds a regression test that supplies `Authorization` and a custom header
on the `Request` itself and asserts both reach the target server.

* 🛠️ fix: Preserve `Request` method/body across MCP redirects + guard cross-origin strip

Two regressions surfaced by extending `customFetch` to accept `Request` inputs:

1. **307/308 method/body loss.** The redirect loop switches `url` to the
   new `Location` string, but the original `Request`'s method and body
   stayed bound to the (now-discarded) `Request` object. A redirected
   POST silently became a GET with no payload — the exact behavior the
   method-preserving codes are designed to prevent. Added a
   `resolveFetchInput` helper that runs once at the top of `customFetch`,
   extracts a `Request`'s method/body/headers into the shared init, and
   buffers the body via `arrayBuffer()` so 307/308 retries can replay it.

2. **Cross-origin strip crashed on absent headers.** After the previous
   fix that stopped `buildFetchInit` from setting `headers: {}`,
   `currentInit.headers` could legitimately be `undefined`. The
   cross-origin branch read it as a `Record` and called `Object.entries`
   on `undefined`, throwing `TypeError`. Guard the branch on
   `currentInit.headers != null` — when there are no headers there is
   nothing to strip.

Adds two regression tests: a POST-with-body `Request` that 308-redirects
cross-origin (asserts both method and body survive) and a no-headers
cross-origin redirect (asserts the strip path no longer crashes).

* 🛠️ fix: Forward `Request.signal` through MCP `customFetch` normalization

`resolveFetchInput` was copying method/body/headers off a `Request` input
but dropping `Request.signal` on the floor, so a caller that wired an
`AbortController` onto the `Request` for cancellation/timeouts lost that
wiring as soon as we re-shaped the input into the `(string, init)` pair
used by the redirect loop. Subsequent aborts no longer reached the
in-flight fetch — a regression from the pre-PR code, which forwarded
the original `Request` directly to undici.

Forward the signal alongside method/body/headers, with explicit
`init.signal` still winning per Fetch-spec semantics. Regression test
aborts a controller before calling \`customFetch\` with the wired
`Request` and asserts the call rejects.

* 🧪 test: Pin URL.origin contract for protocol-downgrade redirect handling

Audit follow-up. The cross-origin strip path keys off
`targetUrl.origin !== originalOrigin`, and `URL.origin` is defined as
`scheme + "://" + host + ":" + port`, so a same-host `https → http`
redirect produces a different origin and trips the strip path through
the existing logic — no separate code path needed.

Pin that contract with a small unit test so a future change to URL
semantics (or a refactor that swaps in a different comparison) doesn't
silently regress protocol-downgrade stripping. Standing up a TLS
fixture (self-signed cert, undici skip-verify, etc.) just to re-prove
the URL spec is wasted complexity.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2026-04-29 22:12:29 +09:00
..
src 🔌 fix: Follow 307/308 redirects in MCP streamable HTTP transport (#12850) 2026-04-29 22:12:29 +09:00
types 🔬 ci: Add TypeScript Type Checks to Backend Workflow and Fix All Type Errors (#12451) 2026-03-28 21:06:39 -04:00
.gitignore
babel.config.cjs
jest.config.mjs 🌱 fix: Inject Code-Tool Files Into Graph Sessions on First Call (+ read_file Sandbox Fallback) (#12831) 2026-04-27 08:56:39 +09:00
jest.setup.cjs 🌱 fix: Inject Code-Tool Files Into Graph Sessions on First Call (+ read_file Sandbox Fallback) (#12831) 2026-04-27 08:56:39 +09:00
package.json 📦 chore: update @librechat/agents to v3.1.74 (#12869) 2026-04-29 10:20:52 +09:00
rollup.config.js
tsconfig-paths-bootstrap.mjs
tsconfig.build.json
tsconfig.json 📦 chore: Update TypeScript Config for TS v7 (#12794) 2026-04-23 12:51:03 -04:00
tsconfig.spec.json 📦 chore: Update TypeScript Config for TS v7 (#12794) 2026-04-23 12:51:03 -04:00