Skip to content

Commit e89452d

Browse files
mzxchandrawaleedlatif1
authored andcommitted
fix(zoho-desk): validate the persisted Desk base against the strict host allowlist
deriveZohoDeskBaseFromApiDomain trusted any host matching `desk.zoho.[a-z.]+`, so a crafted api_domain like `desk.zoho.com.attacker.com` passed and was persisted as the credential's `__zoho_domain__` REST base - later receiving the OAuth token on every Desk tool/webhook call. Gate the derivation on the strict isZohoHost apex allowlist (which rejects that lookalike), extracted with assertZohoUrl into a dependency-free host-allowlist module so the auth token-exchange path validates hosts without pulling in the tool utilities. The attachment and organizations routes now import the shared guard from there. Also: formatInput now emits the normalized null trigger shape for an empty/ malformed event array instead of leaking a raw `[]` to downstream steps. Tests cover the empty-array shape and the lookalike-host rejection.
1 parent 842bad2 commit e89452d

8 files changed

Lines changed: 86 additions & 60 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { isZohoHost } from '@/tools/zoho_desk/host-allowlist'
910
import {
1011
buildZohoDeskHeaders,
1112
deriveAttachmentName,
1213
getZohoDeskApiBase,
13-
isZohoHost,
1414
resolveZohoAttachmentUrl,
1515
} from '@/tools/zoho_desk/utils'
1616

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ 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 { assertZohoUrl, getZohoDeskApiBase, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
8+
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
9+
import { getZohoDeskApiBase, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
910

1011
export const dynamic = 'force-dynamic'
1112

apps/sim/lib/auth/auth.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import { joinInstanceOrganization } from '@/lib/organizations/instance-org'
109109
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
110110
import { disableUserResources } from '@/lib/workflows/lifecycle'
111111
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
112+
import { isZohoHost } from '@/tools/zoho_desk/host-allowlist'
112113

113114
const logger = createLogger('Auth')
114115

@@ -171,14 +172,14 @@ function deriveZohoDeskBaseFromApiDomain(apiDomain?: string): string {
171172
if (!apiDomain) return fallback
172173
try {
173174
const host = new URL(apiDomain).host.toLowerCase()
174-
// Already a data-center Desk host (e.g. desk.zoho.eu) - preserve it so we
175-
// never misroute a valid regional domain back to the US (.com) data center.
176-
if (/(^|\.)desk\.zoho\.[a-z.]+$/.test(host)) return `https://${host}`
177-
// Otherwise map the data-center TLD from the API host (www.zohoapis.<tld> or
178-
// any zoho.<tld>) onto the Desk REST host in the same data center.
175+
// Gate on the strict Zoho apex allowlist before trusting the host: a loose
176+
// `desk.zoho.*` pattern would accept a lookalike like `desk.zoho.com.attacker.com`
177+
// and persist it as the credential's REST base, later leaking the OAuth token.
178+
if (!isZohoHost(host)) return fallback
179+
// Map the data-center TLD from the (now trusted) host onto the Desk REST host
180+
// in the same data center - works for both www.zohoapis.<tld> and desk.zoho.<tld>.
179181
const match = host.match(/zoho(?:apis)?\.([a-z.]+)$/)
180-
if (match?.[1]) return `https://desk.zoho.${match[1]}`
181-
return fallback
182+
return match?.[1] ? `https://desk.zoho.${match[1]}` : fallback
182183
} catch {
183184
return fallback
184185
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,23 @@ describe('zohoDeskHandler', () => {
149149
expect(result?.input).toBe(body)
150150
})
151151

152+
it('emits a normalized null shape for an empty array instead of leaking []', async () => {
153+
const result = await zohoDeskHandler.formatInput?.({
154+
webhook: {},
155+
workflow: { id: 'wf', userId: 'user' },
156+
body: [],
157+
headers: {},
158+
requestId: 'test',
159+
})
160+
expect(result?.input).toEqual({
161+
eventType: null,
162+
eventTime: null,
163+
orgId: null,
164+
payload: null,
165+
prevState: null,
166+
})
167+
})
168+
152169
it('derives a plain-text contentText for html comment/thread payloads', async () => {
153170
const result = await zohoDeskHandler.formatInput?.({
154171
webhook: {},

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,12 @@ export const zohoDeskHandler: WebhookProviderHandler = {
379379
}
380380
const event = body[0]
381381
if (!event || typeof event !== 'object') {
382-
return { input: body }
382+
// Empty or malformed array (e.g. Zoho posts `[]`): emit the normalized
383+
// trigger shape with null fields so downstream steps still see
384+
// eventType/payload/etc. rather than a raw array leaking through.
385+
return {
386+
input: { eventType: null, eventTime: null, orgId: null, payload: null, prevState: null },
387+
}
383388
}
384389
const record = event as Record<string, unknown>
385390
// Comment / thread event payloads carry a raw `content` + `contentType`
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Zoho-owned apex domains across data centers. `apiDomain` and attachment `href`
3+
* values are user/LLM-influenced, so any outbound request that carries the OAuth
4+
* token - and the data-center base persisted at token exchange - must anchor its
5+
* host to one of these with a strict suffix match. A naive `contains "zoho."` or
6+
* `desk.zoho.*` check would accept an attacker domain like `zoho.attacker.com` or
7+
* `desk.zoho.com.attacker.com` and leak the token to it.
8+
*
9+
* Kept in its own dependency-free module so the auth token-exchange path can
10+
* validate hosts without pulling in the heavier tool utilities (e.g. html-to-text).
11+
*/
12+
const ZOHO_ALLOWED_APEX_DOMAINS = [
13+
'zoho.com',
14+
'zoho.eu',
15+
'zoho.in',
16+
'zoho.com.au',
17+
'zoho.jp',
18+
'zoho.ca',
19+
'zoho.sa',
20+
'zoho.com.cn',
21+
'zoho.uk',
22+
'zohoapis.com',
23+
'zohoapis.eu',
24+
'zohoapis.in',
25+
'zohoapis.com.au',
26+
'zohoapis.jp',
27+
'zohoapis.ca',
28+
'zohoapis.sa',
29+
'zohoapis.com.cn',
30+
'zohoapis.uk',
31+
]
32+
33+
/** True only when the hostname is exactly a Zoho apex or a subdomain of one. */
34+
export function isZohoHost(hostname: string): boolean {
35+
const host = hostname.toLowerCase()
36+
return ZOHO_ALLOWED_APEX_DOMAINS.some((apex) => host === apex || host.endsWith(`.${apex}`))
37+
}
38+
39+
/**
40+
* Assert that a URL is a token-safe Zoho target: it must be `https:` and its host
41+
* must be a Zoho apex or subdomain. Returns the parsed URL, or throws (the caller
42+
* maps that to a 400) - used by every route that sends the OAuth token to a host
43+
* derived from user/LLM-influenced input.
44+
*/
45+
export function assertZohoUrl(rawUrl: string): URL {
46+
const url = new URL(rawUrl)
47+
if (url.protocol !== 'https:' || !isZohoHost(url.hostname)) {
48+
throw new Error('URL must be an https Zoho host')
49+
}
50+
return url
51+
}

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5+
import { assertZohoUrl, isZohoHost } from '@/tools/zoho_desk/host-allowlist'
56
import {
6-
assertZohoUrl,
77
buildZohoDeskHeaders,
88
convertZohoHtmlToText,
99
deriveAttachmentName,
1010
deriveZohoContentText,
1111
getZohoDeskApiBase,
1212
getZohoDeskErrorMessage,
13-
isZohoHost,
1413
resolveZohoAttachmentUrl,
1514
withDerivedContentText,
1615
} from '@/tools/zoho_desk/utils'

apps/sim/tools/zoho_desk/utils.ts

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,54 +4,6 @@ import type { ZohoDeskBaseParams } from '@/tools/zoho_desk/types'
44
/** Default Zoho Desk REST host when no data-center-specific base was persisted. */
55
const DEFAULT_ZOHO_DESK_BASE = 'https://desk.zoho.com'
66

7-
/**
8-
* Zoho-owned apex domains across data centers. `apiDomain` and attachment `href`
9-
* values are user/LLM-influenced, so any outbound request that carries the OAuth
10-
* token must anchor its host to one of these with a strict suffix match - a naive
11-
* `contains "zoho."` check would accept an attacker domain like
12-
* `zoho.attacker.com` or `desk.zoho.com.attacker.com` and leak the token to it.
13-
*/
14-
const ZOHO_ALLOWED_APEX_DOMAINS = [
15-
'zoho.com',
16-
'zoho.eu',
17-
'zoho.in',
18-
'zoho.com.au',
19-
'zoho.jp',
20-
'zoho.ca',
21-
'zoho.sa',
22-
'zoho.com.cn',
23-
'zoho.uk',
24-
'zohoapis.com',
25-
'zohoapis.eu',
26-
'zohoapis.in',
27-
'zohoapis.com.au',
28-
'zohoapis.jp',
29-
'zohoapis.ca',
30-
'zohoapis.sa',
31-
'zohoapis.com.cn',
32-
'zohoapis.uk',
33-
]
34-
35-
/** True only when the hostname is exactly a Zoho apex or a subdomain of one. */
36-
export function isZohoHost(hostname: string): boolean {
37-
const host = hostname.toLowerCase()
38-
return ZOHO_ALLOWED_APEX_DOMAINS.some((apex) => host === apex || host.endsWith(`.${apex}`))
39-
}
40-
41-
/**
42-
* Assert that a URL is a token-safe Zoho target: it must be `https:` and its host
43-
* must be a Zoho apex or subdomain. Returns the parsed URL, or throws (the caller
44-
* maps that to a 400) - used by every route that sends the OAuth token to a host
45-
* derived from user/LLM-influenced input.
46-
*/
47-
export function assertZohoUrl(rawUrl: string): URL {
48-
const url = new URL(rawUrl)
49-
if (url.protocol !== 'https:' || !isZohoHost(url.hostname)) {
50-
throw new Error('URL must be an https Zoho host')
51-
}
52-
return url
53-
}
54-
557
/**
568
* Convert Zoho Desk rich-text HTML (comment / thread / ticket bodies) to
579
* readable plain text. Mirrors the per-integration `html-to-text` configuration

0 commit comments

Comments
 (0)