Skip to content

Commit e7d5783

Browse files
committed
fix(zoho-desk): OAuth PKCE, DC scope-marker parsing, SSRF, and e2e fixes
OAuth: forward code_verifier in the custom getToken (PKCE is enabled, so the exchange must echo the verifier or Zoho rejects the request with invalid_request). Surface Zoho's error/error_description, which it returns in the JSON body with HTTP 200, instead of collapsing every failure into "no access token". Data-center base parsing: better-auth persists Zoho's scopes comma-joined with no spaces, so the greedy \S+ marker regex swallowed the whole scope list into the host. Stop the capture at a comma or whitespace in both read sites (token route and webhook handler), so apiDomain resolves to the real Desk host. Attachment SSRF: replace the permissive host regex (which accepted attacker domains like zoho.attacker.com) with a strict Zoho-apex suffix allowlist. Block: guard Number() pagination so a non-numeric typo can't send NaN; add the ignoreSourceId -> sourceId loop-guard header to update_ticket (matching add_comment). Organizations route: surface fetch/Zoho failures with a real status instead of a 200 with an empty list, so the org selector no longer fails silently.
1 parent 927a64c commit e7d5783

10 files changed

Lines changed: 141 additions & 26 deletions

File tree

apps/docs/components/icons.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8930,7 +8930,13 @@ export function LogfireIcon(props: SVGProps<SVGSVGElement>) {
89308930

89318931
export function ZohoDeskIcon(props: SVGProps<SVGSVGElement>) {
89328932
return (
8933-
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' aria-hidden='true'>
8933+
<svg
8934+
{...props}
8935+
viewBox='0 0 24 24'
8936+
fill='none'
8937+
xmlns='http://www.w3.org/2000/svg'
8938+
aria-hidden='true'
8939+
>
89348940
<path
89358941
d='M12 2.75c-4.28 0-7.75 3.47-7.75 7.75v3.1A2.6 2.6 0 0 0 3 16.35v1.3A2.6 2.6 0 0 0 5.6 20.25h1.15a.9.9 0 0 0 .9-.9v-4.9a.9.9 0 0 0-.9-.9H6.05v-2.15a5.95 5.95 0 0 1 11.9 0v2.15h-.7a.9.9 0 0 0-.9.9v4.9c0 .17.05.33.13.47-.5.6-1.24.98-2.08.98h-1.02a1.4 1.4 0 0 0-1.31-.9h-1a1.4 1.4 0 0 0 0 2.8h1a1.4 1.4 0 0 0 1.31-.9h1.02c2.06 0 3.74-1.63 3.83-3.67a2.6 2.6 0 0 0 1.44-2.33v-1.3a2.6 2.6 0 0 0-1.25-2.22v-3.1c0-4.28-3.47-7.75-7.75-7.75Z'
89368942
fill='currentColor'

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export const dynamic = 'force-dynamic'
2626
const logger = createLogger('OAuthTokenAPI')
2727

2828
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
29-
const ZOHO_DESK_BASE_URL_REGEX = /__zoho_domain__:([^\s]+)/
29+
// Stop at a comma or whitespace: better-auth persists Zoho's scopes comma-joined
30+
// (no spaces), so a greedy `\S+` would swallow the whole scope list into the host.
31+
// The Desk base URL itself never contains a comma or space.
32+
const ZOHO_DESK_BASE_URL_REGEX = /__zoho_domain__:([^\s,]+)/
3033

3134
/**
3235
* Get an access token for a specific credential

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,38 @@ const logger = createLogger('ZohoDeskAttachmentAPI')
1313

1414
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
1515

16-
/** Only allow downloads from Zoho hosts to prevent SSRF via a crafted href. */
16+
/**
17+
* Zoho-owned apex domains across data centers. The `href` on an attachment is
18+
* user/LLM-influenced, so the download host must be anchored to one of these
19+
* with a strict suffix match — a naive `contains "zoho."` check would accept an
20+
* attacker domain like `zoho.attacker.com` or `desk.zoho.com.attacker.com` and
21+
* leak the OAuth token + orgId to it.
22+
*/
23+
const ZOHO_ALLOWED_APEX_DOMAINS = [
24+
'zoho.com',
25+
'zoho.eu',
26+
'zoho.in',
27+
'zoho.com.au',
28+
'zoho.jp',
29+
'zoho.ca',
30+
'zoho.sa',
31+
'zoho.com.cn',
32+
'zoho.uk',
33+
'zohoapis.com',
34+
'zohoapis.eu',
35+
'zohoapis.in',
36+
'zohoapis.com.au',
37+
'zohoapis.jp',
38+
'zohoapis.ca',
39+
'zohoapis.sa',
40+
'zohoapis.com.cn',
41+
'zohoapis.uk',
42+
]
43+
44+
/** True only when the hostname is exactly a Zoho apex or a subdomain of one. */
1745
function isZohoHost(hostname: string): boolean {
18-
return /(^|\.)zoho\.[a-z.]+$/i.test(hostname) || /(^|\.)zohoapis\.[a-z.]+$/i.test(hostname)
46+
const host = hostname.toLowerCase()
47+
return ZOHO_ALLOWED_APEX_DOMAINS.some((apex) => host === apex || host.endsWith(`.${apex}`))
1948
}
2049

2150
export const POST = withRouteHandler(async (request: NextRequest) => {

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { zohoDeskListOrganizationsContract } from '@/lib/api/contracts/tools/zoh
55
import { parseRequest } from '@/lib/api/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { getZohoDeskApiBase } from '@/tools/zoho_desk/utils'
8+
import { getZohoDeskApiBase, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
99

1010
export const dynamic = 'force-dynamic'
1111

@@ -45,8 +45,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4545

4646
const data = await response.json().catch(() => ({}))
4747
if (!response.ok) {
48-
logger.warn('Failed to list Zoho Desk organizations', { status: response.status })
49-
return NextResponse.json({ organizations: [] })
48+
// Surface the failure instead of returning an empty 200, which would make
49+
// the org dropdown silently render empty on an auth/connectivity error.
50+
const message = getZohoDeskErrorMessage(
51+
data,
52+
`Failed to list organizations (HTTP ${response.status})`
53+
)
54+
logger.warn('Failed to list Zoho Desk organizations', { status: response.status, message })
55+
return NextResponse.json(
56+
{ error: message },
57+
{ status: response.status >= 400 && response.status < 500 ? response.status : 502 }
58+
)
5059
}
5160

5261
const organizations = (Array.isArray(data.data) ? (data.data as ZohoOrganization[]) : [])
@@ -59,7 +68,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5968

6069
return NextResponse.json({ organizations })
6170
} catch (error) {
62-
logger.error('Error listing Zoho Desk organizations', { error: getErrorMessage(error) })
63-
return NextResponse.json({ organizations: [] })
71+
const message = getErrorMessage(error, 'Failed to list organizations')
72+
logger.error('Error listing Zoho Desk organizations', { error: message })
73+
return NextResponse.json({ error: message }, { status: 502 })
6474
}
6575
})

apps/sim/blocks/blocks/zoho-desk.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
187187
type: 'short-input',
188188
mode: 'advanced',
189189
placeholder: 'Loop-guard source ID (from a Zoho Desk trigger)',
190-
condition: { field: 'operation', value: 'add_comment' },
190+
condition: { field: 'operation', value: ['add_comment', 'update_ticket'] },
191191
},
192192
// Update ticket
193193
{
@@ -343,11 +343,20 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
343343
config: {
344344
tool: (params) => `zoho_desk_${params.operation}`,
345345
params: (params) => {
346-
const { oauthCredential, ...rest } = params
346+
// Pull raw pagination out of the spread so invalid values never reach the
347+
// tool; only re-add them when Number() yields a finite value (a non-numeric
348+
// typo would otherwise become NaN and produce an invalid Zoho query param).
349+
const { oauthCredential, from: rawFrom, limit: rawLimit, ...rest } = params
347350
const result: Record<string, unknown> = { ...rest, oauthCredential }
348351

349-
if (rest.from !== undefined && rest.from !== '') result.from = Number(rest.from)
350-
if (rest.limit !== undefined && rest.limit !== '') result.limit = Number(rest.limit)
352+
if (rawFrom !== undefined && rawFrom !== '') {
353+
const from = Number(rawFrom)
354+
if (Number.isFinite(from)) result.from = from
355+
}
356+
if (rawLimit !== undefined && rawLimit !== '') {
357+
const limit = Number(rawLimit)
358+
if (Number.isFinite(limit)) result.limit = limit
359+
}
351360
if (rest.isPublic !== undefined) {
352361
result.isPublic = rest.isPublic === true || rest.isPublic === 'true'
353362
}

apps/sim/lib/auth/auth.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,29 +2068,69 @@ export const auth = betterAuth({
20682068
prompt: 'consent',
20692069
scope: getCanonicalScopesForProvider('zoho-desk').join(','),
20702070
},
2071-
getToken: async ({ code, redirectURI }) => {
2071+
getToken: async ({ code, redirectURI, codeVerifier }) => {
2072+
const tokenParams = new URLSearchParams({
2073+
client_id: env.ZOHO_CLIENT_ID as string,
2074+
client_secret: env.ZOHO_CLIENT_SECRET as string,
2075+
code,
2076+
grant_type: 'authorization_code',
2077+
redirect_uri: redirectURI,
2078+
})
2079+
// PKCE is enabled, so better-auth sent a code_challenge on the authorize
2080+
// request. The exchange MUST echo the matching code_verifier or Zoho
2081+
// rejects the request shape (invalid_request). Verified by isolating
2082+
// pkce:false (which connected) then restoring pkce:true + this verifier.
2083+
if (codeVerifier) tokenParams.set('code_verifier', codeVerifier)
2084+
20722085
const response = await fetch('https://accounts.zoho.com/oauth/v2/token', {
20732086
method: 'POST',
20742087
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2075-
body: new URLSearchParams({
2076-
client_id: env.ZOHO_CLIENT_ID as string,
2077-
client_secret: env.ZOHO_CLIENT_SECRET as string,
2078-
code,
2079-
grant_type: 'authorization_code',
2080-
redirect_uri: redirectURI,
2081-
}),
2088+
body: tokenParams,
20822089
})
20832090
const data = await readResponseJsonWithLimit<Record<string, unknown>>(response, {
20842091
maxBytes: 1024 * 1024,
20852092
label: 'Zoho Desk OAuth token response',
20862093
})
20872094

2088-
if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data)) {
2089-
throw new Error(`Zoho Desk OAuth token exchange failed with HTTP ${response.status}`)
2095+
// Zoho signals OAuth failures in the JSON body, usually with HTTP 200,
2096+
// e.g. { error: 'invalid_code' } or { error: 'invalid_client',
2097+
// error_description: '...' }. The status-only guard therefore never
2098+
// fires, so surface the actual error/description instead of collapsing
2099+
// every failure into one opaque "no access token" string.
2100+
const errorObj =
2101+
data && typeof data === 'object' && !Array.isArray(data)
2102+
? (data as { error?: unknown; error_description?: unknown })
2103+
: {}
2104+
const zohoError = typeof errorObj.error === 'string' ? errorObj.error : undefined
2105+
const zohoErrorDescription =
2106+
typeof errorObj.error_description === 'string'
2107+
? errorObj.error_description
2108+
: undefined
2109+
if (
2110+
!response.ok ||
2111+
!data ||
2112+
typeof data !== 'object' ||
2113+
Array.isArray(data) ||
2114+
zohoError
2115+
) {
2116+
logger.error('Zoho Desk OAuth token exchange failed', {
2117+
status: response.status,
2118+
zohoError: zohoError ?? null,
2119+
zohoErrorDescription: zohoErrorDescription ?? null,
2120+
})
2121+
throw new Error(
2122+
`Zoho Desk OAuth token exchange failed (HTTP ${response.status}${
2123+
zohoError ? `, ${zohoError}` : ''
2124+
}${zohoErrorDescription ? `: ${zohoErrorDescription}` : ''})`
2125+
)
20902126
}
20912127

20922128
const tokens = getOAuth2Tokens(data)
20932129
if (!tokens.accessToken) {
2130+
logger.error('Zoho Desk OAuth token response had no access token', {
2131+
status: response.status,
2132+
bodyKeys: Object.keys(data),
2133+
})
20942134
throw new Error('Zoho Desk OAuth token response did not include an access token')
20952135
}
20962136

apps/sim/lib/webhooks/providers/zoho-desk.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
2222
const logger = createLogger('WebhookProvider:ZohoDesk')
2323

2424
const DEFAULT_ZOHO_DESK_BASE = 'https://desk.zoho.com'
25-
const ZOHO_DESK_BASE_URL_REGEX = /__zoho_domain__:(\S+)/
25+
// Stop at a comma or whitespace: better-auth persists Zoho's scopes comma-joined
26+
// (no spaces), so a greedy `\S+` would swallow the whole scope list into the host.
27+
const ZOHO_DESK_BASE_URL_REGEX = /__zoho_domain__:([^\s,]+)/
2628

2729
/**
2830
* Remote JWKS sets are cached per Desk data-center host. `createRemoteJWKSet`

apps/sim/tools/zoho_desk/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface ZohoDeskUpdateTicketParams extends ZohoDeskBaseParams {
3737
subCategory?: string
3838
dueDate?: string
3939
customFields?: Record<string, unknown>
40+
ignoreSourceId?: string
4041
}
4142

4243
export interface ZohoDeskListCommentsParams extends ZohoDeskBaseParams {

apps/sim/tools/zoho_desk/update_ticket.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,25 @@ export const zohoDeskUpdateTicketTool: ToolConfig<ZohoDeskUpdateTicketParams, Zo
9595
visibility: 'user-or-llm',
9696
description: 'Custom field values as a JSON object',
9797
},
98+
ignoreSourceId: {
99+
type: 'string',
100+
required: false,
101+
visibility: 'hidden',
102+
description:
103+
'Source ID echoed back on the resulting webhook event so this write can be filtered out (loop guard)',
104+
},
98105
},
99106

100107
request: {
101108
url: (params) => `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(params.ticketId)}`,
102109
method: 'PATCH',
103-
headers: (params) => buildZohoDeskHeaders(params),
110+
headers: (params) => {
111+
const headers = buildZohoDeskHeaders(params)
112+
// Echo the webhook subscription's ignoreSourceId so Zoho tags the resulting
113+
// Ticket_Update event with this sourceId, letting our own trigger drop self-writes.
114+
if (params.ignoreSourceId) headers.sourceId = params.ignoreSourceId
115+
return headers
116+
},
104117
body: (params) =>
105118
filterUndefined({
106119
subject: params.subject,

apps/sim/tools/zoho_desk/utils.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ describe('zoho desk tool utils', () => {
5050
})
5151

5252
it('falls back to errorCode then the fallback', () => {
53-
expect(getZohoDeskErrorMessage({ errorCode: 'INVALID_DATA' }, 'fallback')).toBe('INVALID_DATA')
53+
expect(getZohoDeskErrorMessage({ errorCode: 'INVALID_DATA' }, 'fallback')).toBe(
54+
'INVALID_DATA'
55+
)
5456
expect(getZohoDeskErrorMessage(null, 'fallback')).toBe('fallback')
5557
})
5658
})

0 commit comments

Comments
 (0)