Skip to content

Commit b47a136

Browse files
committed
fix(zoho-desk): apply the Zoho host allowlist to the organizations route
The organizations route built its URL from the client-supplied apiDomain and attached the OAuth token without the https-Zoho-host allowlist the attachment route already enforced, so a session-access caller could point the server at an arbitrary origin and leak the token. Extract the shared isZohoHost allowlist and an assertZohoUrl guard into tools/zoho_desk/utils (two consumers now), guard the organizations URL before fetching, and refactor the attachment route to reuse the shared helper. Adds tests for the allowlist and guard.
1 parent 50d74c4 commit b47a136

4 files changed

Lines changed: 99 additions & 46 deletions

File tree

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

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
buildZohoDeskHeaders,
1111
deriveAttachmentName,
1212
getZohoDeskApiBase,
13+
isZohoHost,
1314
resolveZohoAttachmentUrl,
1415
} from '@/tools/zoho_desk/utils'
1516

@@ -19,40 +20,6 @@ const logger = createLogger('ZohoDeskAttachmentAPI')
1920

2021
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
2122

22-
/**
23-
* Zoho-owned apex domains across data centers. The `href` on an attachment is
24-
* user/LLM-influenced, so the download host must be anchored to one of these
25-
* with a strict suffix match — a naive `contains "zoho."` check would accept an
26-
* attacker domain like `zoho.attacker.com` or `desk.zoho.com.attacker.com` and
27-
* leak the OAuth token + orgId to it.
28-
*/
29-
const ZOHO_ALLOWED_APEX_DOMAINS = [
30-
'zoho.com',
31-
'zoho.eu',
32-
'zoho.in',
33-
'zoho.com.au',
34-
'zoho.jp',
35-
'zoho.ca',
36-
'zoho.sa',
37-
'zoho.com.cn',
38-
'zoho.uk',
39-
'zohoapis.com',
40-
'zohoapis.eu',
41-
'zohoapis.in',
42-
'zohoapis.com.au',
43-
'zohoapis.jp',
44-
'zohoapis.ca',
45-
'zohoapis.sa',
46-
'zohoapis.com.cn',
47-
'zohoapis.uk',
48-
]
49-
50-
/** True only when the hostname is exactly a Zoho apex or a subdomain of one. */
51-
function isZohoHost(hostname: string): boolean {
52-
const host = hostname.toLowerCase()
53-
return ZOHO_ALLOWED_APEX_DOMAINS.some((apex) => host === apex || host.endsWith(`.${apex}`))
54-
}
55-
5623
export const POST = withRouteHandler(async (request: NextRequest) => {
5724
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
5825
if (!authResult.success) {

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

Lines changed: 21 additions & 12 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, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
8+
import { assertZohoUrl, getZohoDeskApiBase, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
99

1010
export const dynamic = 'force-dynamic'
1111

@@ -27,21 +27,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2727
if (!parsed.success) return parsed.response
2828
const { accessToken, apiDomain } = parsed.data.body
2929

30+
// apiDomain is client-supplied, so anchor the outbound host to a Zoho apex
31+
// before attaching the OAuth token - otherwise a caller could point the server
32+
// at an arbitrary origin and leak the token.
33+
let organizationsUrl: URL
34+
try {
35+
organizationsUrl = assertZohoUrl(
36+
`${getZohoDeskApiBase({ apiDomain: apiDomain ?? undefined })}/organizations`
37+
)
38+
} catch {
39+
return NextResponse.json({ error: 'apiDomain must be an https Zoho host' }, { status: 400 })
40+
}
41+
3042
try {
3143
// The organizations endpoint is the one Desk call that does not require an
3244
// orgId header, so it can bootstrap the organization selector before a
3345
// portal has been chosen.
34-
const response = await fetch(
35-
`${getZohoDeskApiBase({ apiDomain: apiDomain ?? undefined })}/organizations`,
36-
{
37-
method: 'GET',
38-
headers: {
39-
Authorization: `Zoho-oauthtoken ${accessToken}`,
40-
'Content-Type': 'application/json',
41-
},
42-
signal: AbortSignal.timeout(15_000),
43-
}
44-
)
46+
const response = await fetch(organizationsUrl.toString(), {
47+
method: 'GET',
48+
headers: {
49+
Authorization: `Zoho-oauthtoken ${accessToken}`,
50+
'Content-Type': 'application/json',
51+
},
52+
signal: AbortSignal.timeout(15_000),
53+
})
4554

4655
const data = await response.json().catch(() => ({}))
4756
if (!response.ok) {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
assertZohoUrl,
67
buildZohoDeskHeaders,
78
convertZohoHtmlToText,
89
deriveAttachmentName,
910
deriveZohoContentText,
1011
getZohoDeskApiBase,
1112
getZohoDeskErrorMessage,
13+
isZohoHost,
1214
resolveZohoAttachmentUrl,
1315
withDerivedContentText,
1416
} from '@/tools/zoho_desk/utils'
@@ -93,6 +95,33 @@ describe('zoho desk tool utils', () => {
9395
})
9496
})
9597

98+
describe('isZohoHost', () => {
99+
it('accepts Zoho apex hosts and their subdomains across data centers', () => {
100+
expect(isZohoHost('desk.zoho.com')).toBe(true)
101+
expect(isZohoHost('desk.zoho.eu')).toBe(true)
102+
expect(isZohoHost('zohoapis.com.au')).toBe(true)
103+
expect(isZohoHost('DESK.ZOHO.IN')).toBe(true)
104+
})
105+
106+
it('rejects lookalike and attacker hosts', () => {
107+
expect(isZohoHost('zoho.attacker.com')).toBe(false)
108+
expect(isZohoHost('desk.zoho.com.attacker.com')).toBe(false)
109+
expect(isZohoHost('notzoho.com')).toBe(false)
110+
expect(isZohoHost('evil.com')).toBe(false)
111+
})
112+
})
113+
114+
describe('assertZohoUrl', () => {
115+
it('returns the URL for an https Zoho host', () => {
116+
expect(assertZohoUrl('https://desk.zoho.eu/api/v1/organizations').host).toBe('desk.zoho.eu')
117+
})
118+
119+
it('throws for a non-Zoho host or non-https scheme', () => {
120+
expect(() => assertZohoUrl('https://attacker.com/api/v1/organizations')).toThrow()
121+
expect(() => assertZohoUrl('http://desk.zoho.com/api/v1/organizations')).toThrow()
122+
})
123+
})
124+
96125
describe('resolveZohoAttachmentUrl', () => {
97126
const apiBase = 'https://desk.zoho.com/api/v1'
98127

apps/sim/tools/zoho_desk/utils.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,54 @@ 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+
755
/**
856
* Convert Zoho Desk rich-text HTML (comment / thread / ticket bodies) to
957
* readable plain text. Mirrors the per-integration `html-to-text` configuration

0 commit comments

Comments
 (0)