From 0fdb2be3d764e0745d3e2a35f56abeef8f6d0f4b Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 17 Mar 2026 09:59:22 -0700 Subject: [PATCH 1/2] improvement(mothership): tool display titles, html sanitization, and ui fixes - Use TOOL_UI_METADATA as fallback for tool display titles (fast_edit shows "Editing workflow" instead of "Fast Edit") - Harden HTML-to-text extraction with replaceUntilStable to prevent nested tag injection - Decode HTML entities in a single pass to avoid double-unescaping - Fix Google Drive/Docs query escaping for backslashes in folder IDs - Replace regex with indexOf for email sender/display name parsing - Update embedded workflow run tooltip to "Run workflow" --- apps/sim/app/api/webhooks/agentmail/route.ts | 21 ++++++-- .../message-content/message-content.tsx | 7 ++- .../resource-content/resource-content.tsx | 2 +- .../sim/connectors/google-docs/google-docs.ts | 2 +- .../connectors/google-drive/google-drive.ts | 2 +- apps/sim/lib/mothership/inbox/format.ts | 54 +++++++++++++++---- apps/sim/lib/mothership/inbox/response.ts | 5 +- 7 files changed, 73 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 15ecf2693e5..c23a2f551c9 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -267,11 +267,24 @@ async function createRejectedTask( * Format: "username@domain.com" or "Display Name " */ function extractSenderEmail(from: string): string { - const match = from.match(/<([^>]+)>/) - return (match?.[1] || from).toLowerCase().trim() + const openBracket = from.indexOf('<') + const closeBracket = from.indexOf('>', openBracket + 1) + if (openBracket !== -1 && closeBracket !== -1) { + return from + .substring(openBracket + 1, closeBracket) + .toLowerCase() + .trim() + } + return from.toLowerCase().trim() } function extractDisplayName(from: string): string | null { - const match = from.match(/^(.+?)\s*): ToolCallData { return { id: tc.id, toolName: tc.name, - displayTitle: tc.displayTitle || formatToolName(tc.name), + displayTitle: + tc.displayTitle || + TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title || + formatToolName(tc.name), status: tc.status, result: tc.result, } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index f920cec2216..9ceeb35ac5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -197,7 +197,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor -

{isExecuting ? 'Stop' : 'Run'}

+

{isExecuting ? 'Stop' : 'Run workflow'}

diff --git a/apps/sim/connectors/google-docs/google-docs.ts b/apps/sim/connectors/google-docs/google-docs.ts index 2c80dc23e18..7e935ecf9e7 100644 --- a/apps/sim/connectors/google-docs/google-docs.ts +++ b/apps/sim/connectors/google-docs/google-docs.ts @@ -162,7 +162,7 @@ function buildQuery(sourceConfig: Record): string { const folderId = sourceConfig.folderId as string | undefined if (folderId?.trim()) { - parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`) + parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`) } return parts.join(' and ') diff --git a/apps/sim/connectors/google-drive/google-drive.ts b/apps/sim/connectors/google-drive/google-drive.ts index 4d60d6f7874..36d31067b10 100644 --- a/apps/sim/connectors/google-drive/google-drive.ts +++ b/apps/sim/connectors/google-drive/google-drive.ts @@ -112,7 +112,7 @@ function buildQuery(sourceConfig: Record): string { const folderId = sourceConfig.folderId as string | undefined if (folderId?.trim()) { - parts.push(`'${folderId.trim().replace(/'/g, "\\'")}' in parents`) + parts.push(`'${folderId.trim().replace(/\\/g, '\\\\').replace(/'/g, "\\'")}' in parents`) } const fileType = (sourceConfig.fileType as string) || 'all' diff --git a/apps/sim/lib/mothership/inbox/format.ts b/apps/sim/lib/mothership/inbox/format.ts index cf290f67f17..8fb98d67616 100644 --- a/apps/sim/lib/mothership/inbox/format.ts +++ b/apps/sim/lib/mothership/inbox/format.ts @@ -94,26 +94,60 @@ function isForwardedEmail(subject: string | null, body: string | null): boolean return false } +/** + * Repeatedly applies a regex replacement until the string stabilises. + * Prevents incomplete sanitization from nested/overlapping patterns + * like `ipt>`. + */ +export function replaceUntilStable(input: string, pattern: RegExp, replacement: string): string { + let prev = input + let next = prev.replace(pattern, replacement) + while (next !== prev) { + prev = next + next = prev.replace(pattern, replacement) + } + return next +} + +const HTML_ENTITY_MAP: Record = { + ' ': ' ', + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", +} + +/** + * Decodes known HTML entities in a single pass to avoid double-unescaping. + * A two-step decode (e.g. `&` -> `&` then `<` -> `<`) would turn + * `&lt;` into `<`, which is incorrect. + */ +function decodeHtmlEntities(text: string): string { + return text.replace(/&(?:nbsp|amp|lt|gt|quot|#39);/g, (match) => HTML_ENTITY_MAP[match] ?? match) +} + /** * Basic HTML to text extraction. */ function extractTextFromHtml(html: string | null): string | null { if (!html) return null - return html - .replace(/]*>[\s\S]*?<\/style>/gi, '') - .replace(/]*>[\s\S]*?<\/script>/gi, '') + let text = html + text = replaceUntilStable(text, /]*>[\s\S]*?<\/style\s*>/gi, '') + text = replaceUntilStable(text, /]*>[\s\S]*?<\/script\s*>/gi, '') + + text = text .replace(//gi, '\n') .replace(/<\/p>/gi, '\n\n') .replace(/<\/div>/gi, '\n') .replace(/<\/li>/gi, '\n') - .replace(/<[^>]+>/g, '') - .replace(/ /g, ' ') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") + + text = replaceUntilStable(text, /<[^>]+>/g, '') + + text = decodeHtmlEntities(text) .replace(/\n{3,}/g, '\n\n') .trim() + + return text } diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts index b708f4f44fd..786d43c2e22 100644 --- a/apps/sim/lib/mothership/inbox/response.ts +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { marked } from 'marked' import { getBaseUrl } from '@/lib/core/utils/urls' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' +import { replaceUntilStable } from '@/lib/mothership/inbox/format' import type { InboxTask } from '@/lib/mothership/inbox/types' const logger = createLogger('InboxResponse') @@ -82,7 +83,9 @@ const EMAIL_STYLES = ` function stripRawHtml(text: string): string { return text .split(/(```[\s\S]*?```)/g) - .map((segment, i) => (i % 2 === 0 ? segment.replace(/<\/?[a-z][^>]*>/gi, '') : segment)) + .map((segment, i) => + i % 2 === 0 ? replaceUntilStable(segment, /<\/?[a-z][^>]*>/gi, '') : segment + ) .join('') } From 395668fb5179aabe7aa2820be3efc81bf534f63a Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 17 Mar 2026 10:30:18 -0700 Subject: [PATCH 2/2] fix(security): decode entities before tag stripping and cap loop iterations Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/sim/lib/mothership/inbox/format.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/mothership/inbox/format.ts b/apps/sim/lib/mothership/inbox/format.ts index 8fb98d67616..ddac7421a7e 100644 --- a/apps/sim/lib/mothership/inbox/format.ts +++ b/apps/sim/lib/mothership/inbox/format.ts @@ -99,10 +99,16 @@ function isForwardedEmail(subject: string | null, body: string | null): boolean * Prevents incomplete sanitization from nested/overlapping patterns * like `ipt>`. */ -export function replaceUntilStable(input: string, pattern: RegExp, replacement: string): string { +export function replaceUntilStable( + input: string, + pattern: RegExp, + replacement: string, + maxIterations = 100 +): string { let prev = input let next = prev.replace(pattern, replacement) - while (next !== prev) { + let iterations = 0 + while (next !== prev && iterations++ < maxIterations) { prev = next next = prev.replace(pattern, replacement) } @@ -134,6 +140,9 @@ function extractTextFromHtml(html: string | null): string | null { if (!html) return null let text = html + + text = decodeHtmlEntities(text) + text = replaceUntilStable(text, /]*>[\s\S]*?<\/style\s*>/gi, '') text = replaceUntilStable(text, /]*>[\s\S]*?<\/script\s*>/gi, '') @@ -145,9 +154,7 @@ function extractTextFromHtml(html: string | null): string | null { text = replaceUntilStable(text, /<[^>]+>/g, '') - text = decodeHtmlEntities(text) - .replace(/\n{3,}/g, '\n\n') - .trim() + text = text.replace(/\n{3,}/g, '\n\n').trim() return text }