Skip to content

Commit 210aadc

Browse files
committed
fix(zoho-desk): harden attachment download against redirect-based SSRF/token leak
Replace the raw fetch in the attachment route with secureFetchWithValidation (the same guarded fetch the copilot file-download tool uses). The download URL is user/LLM-influenced and Zoho may redirect, so auto-following redirects could send the OAuth token / orgId to an untrusted or internal host. The guarded fetch pins the resolved IP, blocks private/reserved targets on every hop, drops the Authorization header if a redirect leaves the origin (stripAuthOnRedirect), and enforces the 50MB cap while streaming. The strict Zoho apex allowlist still gates the initial origin as defense in depth.
1 parent 687dea5 commit 210aadc

1 file changed

Lines changed: 11 additions & 8 deletions

File tree

  • apps/sim/app/api/tools/zoho_desk/attachment

apps/sim/app/api/tools/zoho_desk/attachment/route.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { zohoDeskGetAttachmentContract } from '@/lib/api/contracts/tools/zoho-desk'
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import {
910
buildZohoDeskHeaders,
@@ -80,10 +81,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8081
}
8182

8283
try {
83-
const response = await fetch(downloadUrl.toString(), {
84+
// Even though the initial host is allowlisted, the download URL is
85+
// user/LLM-influenced and Zoho may redirect. secureFetchWithValidation pins
86+
// the resolved IP, blocks private/reserved targets on every hop, and
87+
// (stripAuthOnRedirect) drops the OAuth token if a redirect leaves the
88+
// original origin, so the credential never reaches an untrusted host.
89+
// maxResponseBytes enforces the size cap while streaming.
90+
const response = await secureFetchWithValidation(downloadUrl.toString(), {
8491
method: 'GET',
8592
headers: buildZohoDeskHeaders({ accessToken, orgId }),
86-
signal: AbortSignal.timeout(30_000),
93+
timeout: 30_000,
94+
maxResponseBytes: MAX_ATTACHMENT_BYTES,
95+
stripAuthOnRedirect: true,
8796
})
8897

8998
if (!response.ok) {
@@ -95,12 +104,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
95104
}
96105

97106
const arrayBuffer = await response.arrayBuffer()
98-
if (arrayBuffer.byteLength > MAX_ATTACHMENT_BYTES) {
99-
return NextResponse.json(
100-
{ success: false, error: 'Attachment exceeds the 50MB download limit' },
101-
{ status: 413 }
102-
)
103-
}
104107

105108
// ToolFileData (consumed by FileToolProcessor) keys the file name as `name`.
106109
const name = deriveAttachmentName(

0 commit comments

Comments
 (0)