Skip to content

Commit 39085bd

Browse files
committed
fix(zoho-desk): delta-audit findings - prevState scope, status leak, HTML sniffer
An audit of the commits the earlier five audits never saw. All four findings are in code written as fixes for those audits, which is where this branch has repeatedly introduced new problems. `includePrevState` was sent for Ticket_Comment_Update. The previous commit gated it on an `_Update` suffix and claimed Zoho supports it on every update event. Zoho's webhook doc lists the attribute on Ticket/Contact/Agent/Task/Article update events but NOT on Ticket_Comment_Update, which documents only `departmentIds`. That made it an undocumented filter key on a live subscription create - the same class of risk the same commit reverted `limit=200` for, so it failed that commit's own stated bar. Now an explicit set rather than a suffix rule. The status/priority split did not stop the leak it was written for. The mapping used `operation === 'list_tickets' ? filterValue : updateValue`, whose bare else covers all eight other operations - so a stale Update Ticket status was forwarded into get_ticket, list_comments and the rest. Harmless on the wire (those tools ignore it) but exactly the stale-value pattern the neighbouring gates exist to prevent. Both fields are now scoped to the two operations that declare them. The HTML sniffer destroyed plain text. `/<[a-z!\/][^>]*>/` fires on any `<` followed by a letter with a later `>`, so realistic ticket bodies lost content: "if x<y then z>0" became "if x0", and "replace <username> with the real name" lost the placeholder. It now requires a real element - a paired tag, a self-closing tag, a comment/doctype - or an entity, and the entity arm covers hex references it previously missed. Regression tests verified by reverting to the loose pattern and watching them go red. The reconnect data-center carry-forward is scoped to client-credential providers. As written it added a DB read plus a decrypt to every service-account reconnect for every provider - Slack, Atlassian, all token-paste providers - to carry a field only Zoho has. Also: the JWKS cache-bound TSDoc had been orphaned onto the wrong constant by an earlier insertion, and `cooldownDuration` was dropped since it restated jose's default while only `timeoutDuration` needed justifying.
1 parent 50680f7 commit 39085bd

5 files changed

Lines changed: 83 additions & 22 deletions

File tree

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -483,10 +483,21 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
483483
// stale (or half-typed) JSON left behind after switching away from
484484
// Update Ticket would otherwise fail every unrelated operation with
485485
// "Invalid JSON provided for custom fields" - on runs that never send it.
486-
// Both tools take `status` / `priority`; pick the field belonging to the
487-
// selected operation so a stale value from the other one can never leak.
488-
const activeStatus = params.operation === 'list_tickets' ? rawStatusFilter : rawStatus
489-
const activePriority = params.operation === 'list_tickets' ? rawPriorityFilter : rawPriority
486+
// Only list_tickets and update_ticket declare status/priority. The other
487+
// eight operations must receive neither - a ternary with a bare `else`
488+
// would forward a stale Update Ticket value into e.g. get_ticket.
489+
const activeStatus =
490+
params.operation === 'list_tickets'
491+
? rawStatusFilter
492+
: params.operation === 'update_ticket'
493+
? rawStatus
494+
: undefined
495+
const activePriority =
496+
params.operation === 'list_tickets'
497+
? rawPriorityFilter
498+
: params.operation === 'update_ticket'
499+
? rawPriority
500+
: undefined
490501
if (activeStatus !== undefined && activeStatus !== null && activeStatus !== '') {
491502
result.status = activeStatus
492503
}

apps/sim/lib/credentials/orchestration/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { NextRequest } from 'next/server'
88
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
99
import { getCredentialActorContext } from '@/lib/credentials/access'
1010
import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
11+
import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
1112
import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion'
1213
import {
1314
deleteWorkspaceEnvCredentials,
@@ -168,8 +169,12 @@ export async function performUpdateCredential(
168169
// like the Zoho data center would be silently dropped, moving an EU/IN/AU
169170
// credential back to the US accounts server. Carry the stored value forward
170171
// when the caller did not supply one.
172+
// Scoped to the providers that actually have a dataCenter field, so no
173+
// other service-account reconnect (Slack, Atlassian, every token-paste
174+
// provider) pays for a DB read plus a decrypt it can never use.
171175
const carriedDataCenter =
172-
params.dataCenter === undefined
176+
params.dataCenter === undefined &&
177+
isClientCredentialAccountProviderId(access.credential.providerId ?? '')
173178
? await readStoredDataCenter(access.credential.id)
174179
: params.dataCenter
175180

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

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,6 @@ const ZOHO_DESK_BASE_URL_REGEX = /__zoho_domain__:([^\s,]+)/
3434
*/
3535
const jwksCache = new Map<string, ReturnType<typeof jose.createRemoteJWKSet>>()
3636

37-
/**
38-
* Bound on distinct Desk hosts held in {@link jwksCache}. The host is derived
39-
* from `providerConfig.apiDomain`, which `SYSTEM_MANAGED_FIELDS` protects from
40-
* *diffing* but not from being written by a workspace member. `safeZohoDeskBase`
41-
* already clamps it to a Zoho apex so no key material is ever fetched off-Zoho,
42-
* but any `*.zoho.com` label still passes - so without a cap, repeated writes
43-
* plus webhook hits could grow one JWKS instance (and its key cache) per label.
44-
* Zoho has a handful of data centers; anything beyond this is not legitimate
45-
* traffic, so evicting oldest-first is safe.
46-
*/
4737
/**
4838
* Events whose subscription filter accepts `departmentIds`. Zoho documents the
4939
* rest as taking no filter at all.
@@ -58,6 +48,31 @@ const DEPARTMENT_FILTERABLE_EVENTS = new Set([
5848
'Task_Update',
5949
])
6050

51+
/**
52+
* Events whose filter accepts `includePrevState`. Enumerated rather than derived
53+
* from an `_Update` suffix: Zoho documents the attribute on Ticket/Contact/Agent/
54+
* Task/Article update events but NOT on `Ticket_Comment_Update`, which lists only
55+
* `departmentIds`. Sending an undocumented filter key on a live create is the
56+
* same class of risk as an undocumented query param.
57+
*/
58+
const PREV_STATE_EVENTS = new Set([
59+
'Ticket_Update',
60+
'Contact_Update',
61+
'Agent_Update',
62+
'Task_Update',
63+
'Article_Update',
64+
])
65+
66+
/**
67+
* Bound on distinct Desk hosts held in {@link jwksCache}. The host is derived
68+
* from `providerConfig.apiDomain`, which `SYSTEM_MANAGED_FIELDS` protects from
69+
* *diffing* but not from being written by a workspace member. `safeZohoDeskBase`
70+
* already clamps it to a Zoho apex so no key material is ever fetched off-Zoho,
71+
* but any `*.zoho.com` label still passes - so without a cap, repeated writes
72+
* plus webhook hits could grow one JWKS instance (and its key cache) per label.
73+
* Zoho has a handful of data centers; anything beyond this is not legitimate
74+
* traffic, so evicting oldest-first is safe.
75+
*/
6176
const JWKS_CACHE_MAX_ENTRIES = 16
6277

6378
function getJwks(deskHost: string): ReturnType<typeof jose.createRemoteJWKSet> {
@@ -73,7 +88,6 @@ function getJwks(deskHost: string): ReturnType<typeof jose.createRemoteJWKSet> {
7388
// jose's default timeoutDuration is 5000ms, exactly the deadline.
7489
const created = jose.createRemoteJWKSet(new URL(`https://${deskHost}/.well-known/jwks.json`), {
7590
timeoutDuration: 1500,
76-
cooldownDuration: 30_000,
7791
})
7892
jwksCache.set(deskHost, created)
7993
return created
@@ -281,11 +295,10 @@ export const zohoDeskHandler: WebhookProviderHandler = {
281295
const departmentIds = splitCsv(config.triggerDepartmentIds)
282296
if (departmentIds.length > 0) filter.departmentIds = departmentIds
283297
}
284-
// `includePrevState` defaults to false and is supported on EVERY *_Update
285-
// event, not just tickets - without it Zoho never sends `prevState`, so the
286-
// trigger's declared prevState output would be permanently null for contact,
287-
// agent, task and article updates.
288-
if (eventType.endsWith('_Update')) {
298+
// `includePrevState` defaults to false, so without it Zoho never sends
299+
// `prevState` and the trigger's declared output is permanently null for the
300+
// update events that do support it.
301+
if (PREV_STATE_EVENTS.has(eventType)) {
289302
filter.includePrevState = true
290303
}
291304
if (eventType === 'Ticket_Update') {

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,28 @@ describe('zoho desk tool utils', () => {
289289
}
290290
})
291291

292+
// A bare angle-bracket pair is not markup. These are realistic support-ticket
293+
// bodies, and running them through html-to-text deletes everything between
294+
// the brackets ("if x<y then z>0" became "if x0").
295+
it('preserves plain text containing angle-bracket pairs', () => {
296+
const cases = [
297+
'if x<y then z>0',
298+
'replace <username> with the real name',
299+
'SELECT * FROM t WHERE a<b AND c>d',
300+
]
301+
for (const description of cases) {
302+
const result = withDerivedContentText({ description }) as Record<string, unknown>
303+
expect(result.descriptionText).toBe(description)
304+
}
305+
})
306+
307+
it('strips hex-entity encoded bodies too', () => {
308+
const result = withDerivedContentText({
309+
description: 'Sam&#x27;s order failed',
310+
}) as Record<string, unknown>
311+
expect(result.descriptionText).toBe("Sam's order failed")
312+
})
313+
292314
it('still strips a genuinely HTML description', () => {
293315
const result = withDerivedContentText({
294316
description: '<div>order is <b>late</b></div>',

apps/sim/tools/zoho_desk/utils.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,17 @@ export function deriveZohoContentText(content: unknown, contentType: unknown): s
5151
* entities and delete tag-shaped text that was never markup).
5252
*/
5353
function looksLikeHtml(value: string): boolean {
54-
return /<[a-z!/][^>]*>/i.test(value) || /&(?:[a-z]+|#\d+);/i.test(value)
54+
// Requires a real element - a paired <tag>...</tag>, a self-closing <tag/>, or
55+
// an HTML comment/doctype. A bare `<`...`>` pair is NOT enough: plain support
56+
// text like "if x<y then z>0" or "replace <username> with the real name" would
57+
// otherwise be run through html-to-text and silently lose everything between
58+
// the brackets. Entity form covers named, decimal and hex references.
59+
return (
60+
/<([a-z][a-z0-9]*)\b[^>]*>[\s\S]*<\/\1\s*>/i.test(value) ||
61+
/<[a-z][a-z0-9]*\b[^>]*\/>/i.test(value) ||
62+
/<!(?:--|doctype)/i.test(value) ||
63+
/&(?:[a-z]+|#\d+|#x[0-9a-f]+);/i.test(value)
64+
)
5565
}
5666

5767
/**

0 commit comments

Comments
 (0)