Skip to content

Commit 06a76bd

Browse files
committed
fix(security): stop redirects replaying request bodies and leaking credentials
`secureFetchWithPinnedIP` passed its options straight into the redirect recursion, so a 301/302/303 replayed the original method and body — delivering a non-idempotent write twice — and forwarded `Authorization` and every other caller header to whatever origin the upstream named. `followRedirectsGuarded`, a hundred lines above it in the same file, already had the correct RFC 9110 rules. The two had drifted, and the drift is the bug. Both now route through one `resolveRedirectHop`: - 303, and 301/302 on POST, degrade to a bodyless GET and drop the entity headers that described the removed body. - A cross-origin hop drops every caller header, not just `Authorization`. - A cross-origin hop that would forward a body is refused. `stripAuthOnRedirect` still narrows same-origin hops for endpoints that redirect to a target carrying its own signed URL. Verified by stashing the fix and re-running: 4 of the 6 new tests fail against the old code. The 2 that pass either way cover same-origin behaviour that was already correct.
1 parent 00d8a3f commit 06a76bd

2 files changed

Lines changed: 345 additions & 33 deletions

File tree

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

Lines changed: 128 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { createLogger } from '@sim/logger'
88
import { preferIpv4, resolveHostAddresses } from '@sim/security/dns'
99
import { isLoopbackIp, isPrivateIp, isPrivateIpHost, unwrapIpv6Brackets } from '@sim/security/ssrf'
1010
import { toError } from '@sim/utils/errors'
11-
import { omit } from '@sim/utils/object'
1211
import { HttpProxyAgent } from 'http-proxy-agent'
1312
import { HttpsProxyAgent } from 'https-proxy-agent'
1413
import * as ipaddr from 'ipaddr.js'
@@ -365,8 +364,18 @@ export interface SecureFetchOptions {
365364
*/
366365
maxResponseBytes?: number
367366
signal?: AbortSignal
368-
/** Drop the Authorization header when following a redirect, so it is not sent to the redirect target's origin. */
367+
/**
368+
* Drop the Authorization header when following a redirect. A cross-origin hop already
369+
* drops every caller header unconditionally; this narrows same-origin hops too, for
370+
* endpoints that redirect to a target carrying its own signed URL.
371+
*/
369372
stripAuthOnRedirect?: boolean
373+
/**
374+
* Refuse a redirect that would resend the request body, because for this
375+
* caller a second delivery is unsafe (`once`/`billable`). Redirects that drop
376+
* the body still follow — they cannot repeat the write.
377+
*/
378+
refuseBodyPreservingRedirect?: boolean
370379
/**
371380
* Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}).
372381
* When set, the connection routes through this proxy and target-IP pinning is
@@ -526,6 +535,87 @@ function assertGuardedRedirectTarget(url: URL, allowedPinnedIp?: string): void {
526535
}
527536
}
528537

538+
/** Headers that describe a request body and must not outlive it. */
539+
const ENTITY_HEADERS = [
540+
'content-length',
541+
'content-type',
542+
'content-encoding',
543+
'transfer-encoding',
544+
] as const
545+
546+
/** Case-insensitive header removal — callers supply arbitrary casing. */
547+
function stripHeaders(
548+
headers: Record<string, string>,
549+
remove: readonly string[]
550+
): Record<string, string> {
551+
const drop = new Set(remove.map((name) => name.toLowerCase()))
552+
const kept: Record<string, string> = {}
553+
for (const [name, value] of Object.entries(headers)) {
554+
if (!drop.has(name.toLowerCase())) kept[name] = value
555+
}
556+
return kept
557+
}
558+
559+
interface RedirectHopPolicy {
560+
/** Method for the next hop. */
561+
method: string
562+
/** Whether the body — and the entity headers describing it — must be dropped. */
563+
dropBody: boolean
564+
/** Whether every caller-supplied header must be dropped (cross-origin hop). */
565+
dropHeaders: boolean
566+
}
567+
568+
/**
569+
* Decides how a request may be replayed on a redirect target, per RFC 9110 section 15.4.
570+
*
571+
* Both redirect followers in this file route through here so they cannot drift. They had
572+
* drifted: {@link secureFetchWithPinnedIP} replayed the original method and body on
573+
* 301/302/303 — delivering a non-idempotent write twice — and forwarded `Authorization`
574+
* and every other caller header to whatever origin the upstream named, while
575+
* {@link followRedirectsGuarded} implemented the correct rules a hundred lines above it.
576+
*
577+
* Throws when a faithful replay would forward a body across an origin boundary: the
578+
* redirect target is chosen by the peer, so a preserved 307/308 body would hand the
579+
* caller's payload — and any credential inside it — to an open-redirect destination.
580+
*/
581+
function resolveRedirectHop(args: {
582+
status: number
583+
method: string
584+
hasBody: boolean
585+
sameOrigin: boolean
586+
/**
587+
* Set by a delivery whose duplicate is unsafe (`once`, `billable`). Only a
588+
* body-PRESERVING hop can deliver twice inside one attempt, so only that is
589+
* refused — a 303, or a 301/302 on POST, becomes a bodyless GET and cannot
590+
* repeat the write, which is why those still follow. Refusing every redirect
591+
* instead would fail the upload-and-parse endpoints that legitimately answer
592+
* a POST with a redirect to storage.
593+
*/
594+
refuseBodyPreservingRedirect?: boolean
595+
}): RedirectHopPolicy {
596+
const method = args.method.toUpperCase()
597+
// 303 always, and 301/302 on POST by long-standing client convention, degrade to a
598+
// bodyless GET. A retained Content-Length/Content-Type on a bodyless GET is malformed.
599+
const dropBody =
600+
args.status === 303 || ((args.status === 301 || args.status === 302) && method === 'POST')
601+
const preservesBody = args.hasBody && !dropBody
602+
if (!args.sameOrigin && preservesBody) {
603+
throw new Error('Blocked by SSRF policy: cross-origin redirect would forward a request body')
604+
}
605+
if (args.refuseBodyPreservingRedirect && preservesBody) {
606+
throw new Error(
607+
`Redirect would resend the request body to the redirect target, which for this delivery could ` +
608+
`deliver it twice. HTTP ${args.status} preserves the method and body; only a redirect that ` +
609+
`drops the body is followed for a delivery whose duplicate is unsafe.`
610+
)
611+
}
612+
return {
613+
method: dropBody ? 'GET' : method,
614+
dropBody,
615+
dropHeaders: !args.sameOrigin,
616+
}
617+
}
618+
529619
/**
530620
* Manual, revalidating redirect follower used by the guarded fetch. Auto-follow
531621
* is unsafe here on two counts the connect-time lookup cannot cover: IP-literal
@@ -574,32 +664,21 @@ export async function followRedirectsGuarded(
574664
}
575665
const nextUrl = new URL(location, currentUrl)
576666
assertGuardedRedirectTarget(nextUrl, options?.allowRedirectToIp)
577-
// Per the fetch spec: 303 (and 301/302 on POST) switch to a bodyless GET, dropping
578-
// the entity headers that described the removed body (a retained Content-Length /
579-
// Content-Type on a bodyless GET is malformed and undici rejects it).
580-
if (status === 303 || ((status === 301 || status === 302) && method === 'POST')) {
581-
method = 'GET'
582-
body = undefined
583-
if (headers !== undefined) {
584-
const sanitized = new Headers(headers as HeadersInit)
585-
sanitized.delete('content-length')
586-
sanitized.delete('content-type')
587-
sanitized.delete('content-encoding')
588-
sanitized.delete('transfer-encoding')
589-
// double-cast-allowed: Headers is a valid undici HeadersInit at runtime but the DOM/undici types differ
590-
headers = sanitized as unknown as UndiciRequestInit['headers']
591-
}
592-
}
593-
if (nextUrl.origin !== currentUrl.origin) {
667+
const hopPolicy = resolveRedirectHop({
668+
status,
669+
method,
670+
hasBody: body !== undefined && body !== null,
671+
sameOrigin: nextUrl.origin === currentUrl.origin,
672+
})
673+
method = hopPolicy.method
674+
if (hopPolicy.dropBody) body = undefined
675+
if (hopPolicy.dropHeaders) {
594676
headers = undefined
595-
// 307/308 preserve method+body; forwarding a body cross-origin can hand OAuth
596-
// client secrets / tokens to an open-redirect target now that redirects really
597-
// dial the new origin. No legitimate MCP/OAuth flow does this — refuse it.
598-
if (body !== undefined && body !== null) {
599-
throw new Error(
600-
'Blocked by SSRF policy: cross-origin redirect would forward a request body'
601-
)
602-
}
677+
} else if (hopPolicy.dropBody && headers !== undefined) {
678+
const sanitized = new Headers(headers as HeadersInit)
679+
for (const name of ENTITY_HEADERS) sanitized.delete(name)
680+
// double-cast-allowed: Headers is a valid undici HeadersInit at runtime but the DOM/undici types differ
681+
headers = sanitized as unknown as UndiciRequestInit['headers']
603682
}
604683
currentUrl = nextUrl
605684
}
@@ -1018,12 +1097,28 @@ export async function secureFetchWithPinnedIP(
10181097
settledReject(new Error(`Redirect blocked: ${validation.error}`))
10191098
return
10201099
}
1021-
const redirectOptions = options.stripAuthOnRedirect
1022-
? {
1023-
...options,
1024-
headers: omit(options.headers ?? {}, ['Authorization', 'authorization']),
1025-
}
1026-
: options
1100+
const hop = resolveRedirectHop({
1101+
status: statusCode,
1102+
method: options.method ?? 'GET',
1103+
hasBody: options.body !== undefined && options.body !== null,
1104+
sameOrigin: new URL(redirectUrl).origin === parsed.origin,
1105+
refuseBodyPreservingRedirect: options.refuseBodyPreservingRedirect,
1106+
})
1107+
let redirectHeaders = hop.dropHeaders ? undefined : options.headers
1108+
if (redirectHeaders && hop.dropBody) {
1109+
redirectHeaders = stripHeaders(redirectHeaders, ENTITY_HEADERS)
1110+
}
1111+
// A cross-origin hop already dropped every header; this keeps the opt-in
1112+
// promise on the same-origin hops it still applies to.
1113+
if (redirectHeaders && options.stripAuthOnRedirect) {
1114+
redirectHeaders = stripHeaders(redirectHeaders, ['authorization'])
1115+
}
1116+
const redirectOptions: SecureFetchOptions & { allowHttp?: boolean } = {
1117+
...options,
1118+
method: hop.method,
1119+
body: hop.dropBody ? undefined : options.body,
1120+
headers: redirectHeaders,
1121+
}
10271122
return secureFetchWithPinnedIP(
10281123
redirectUrl,
10291124
validation.resolvedIP!,

0 commit comments

Comments
 (0)