From ec90c1ae243ae8928c127429c4446e40384838c3 Mon Sep 17 00:00:00 2001 From: Peer Richelsen Date: Tue, 30 Dec 2025 05:53:04 +0100 Subject: [PATCH] fix: display full URLs in workflow links (#26292) - Exclude 'link' toolbar item from Editor for all workflow actions (not just SMS) - Add replaceCloakedLinksInHtml utility to sanitize HTML and replace cloaked links with visible URLs - Apply sanitization to email content before sending in all workflow email paths This ensures recipients can see the actual destination of URLs, helping them identify potentially malicious links. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/WorkflowStepContainer.tsx | 2 +- .../workflows/api/scheduleEmailReminders.ts | 12 +- .../lib/reminders/emailReminderManager.ts | 3 +- .../ee/workflows/lib/reminders/utils.ts | 42 ++++ .../lib/service/EmailWorkflowService.ts | 3 +- .../test/replaceCloakedLinksInHtml.test.ts | 184 ++++++++++++++++++ 6 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts diff --git a/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx b/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx index a819772cf3..567ff9857f 100644 --- a/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx +++ b/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx @@ -1381,7 +1381,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { (hasActiveTeamPlan || isSMSAction(step.action)) } excludedToolbarItems={ - !isSMSAction(step.action) ? [] : ["blockType", "bold", "italic", "link"] + isSMSAction(step.action) ? ["blockType", "bold", "italic", "link"] : ["link"] } plainText={isSMSAction(step.action)} /> diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index 1504e0f972..d414cd3365 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -33,6 +33,7 @@ import { } from "../lib/reminders/providers/sendgridProvider"; import type { VariablesType } from "../lib/reminders/templates/customTemplate"; import customTemplate from "../lib/reminders/templates/customTemplate"; +import { replaceCloakedLinksInHtml } from "../lib/reminders/utils"; import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate"; import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate"; @@ -139,7 +140,7 @@ export async function handler(req: NextRequest) { let sendTo; switch (reminder.workflowStep.action) { - case WorkflowActions.EMAIL_HOST: + case WorkflowActions.EMAIL_HOST: { sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email; const hosts = reminder?.booking?.eventType?.hosts ?.filter((host) => @@ -155,6 +156,7 @@ export async function handler(req: NextRequest) { sendTo = sendTo ? [sendTo, ...hosts] : hosts; } break; + } case WorkflowActions.EMAIL_ATTENDEE: sendTo = targetAttendee?.email; break; @@ -312,8 +314,8 @@ export async function handler(req: NextRequest) { timeZone: timeZone || "", organizer: reminder.booking.user?.name || "", name: name || "", - ratingUrl: `${bookerUrl}/booking/${reminder.booking.uid}?rating` || "", - noShowUrl: `${bookerUrl}/booking/${reminder.booking.uid}?noShow=true` || "", + ratingUrl: `${bookerUrl}/booking/${reminder.booking.uid}?rating`, + noShowUrl: `${bookerUrl}/booking/${reminder.booking.uid}?noShow=true`, }); } @@ -356,7 +358,7 @@ export async function handler(req: NextRequest) { const mailData = { subject: emailContent.emailSubject, to: Array.isArray(sendTo) ? sendTo : [sendTo], - html: emailContent.emailBody, + html: replaceCloakedLinksInHtml(emailContent.emailBody), attachments: reminder.workflowStep.includeCalendarEvent ? [ { @@ -455,7 +457,7 @@ export async function handler(req: NextRequest) { const mailData = { subject: emailContent.emailSubject, to: [sendTo], - html: emailContent.emailBody, + html: replaceCloakedLinksInHtml(emailContent.emailBody), sender: reminder.workflowStep?.sender, ...(!reminder.booking?.eventType?.hideOrganizerEmail && { replyTo: diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index 5bbe3728cf..1b539f73e3 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -14,6 +14,7 @@ import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider"; import type { WorkflowContextData } from "./reminderScheduler"; import type { VariablesType } from "./templates/customTemplate"; import customTemplate, { transformRoutingFormResponsesToVariableFormat } from "./templates/customTemplate"; +import { replaceCloakedLinksInHtml } from "./utils"; const log = logger.getSubLogger({ prefix: ["[emailReminderManager]"] }); @@ -197,7 +198,7 @@ const scheduleEmailReminderForForm = async ( const mailData = { subject: emailContent.emailSubject, - html: emailContent.emailBody, + html: replaceCloakedLinksInHtml(emailContent.emailBody), sender, }; diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index 1cf6dbdc82..49179e57e1 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -115,6 +115,48 @@ export const getAttendeeToBeUsedInSMS = ( return attendeeToBeUsedInSMS; }; +/** + * Escapes HTML special characters to prevent XSS when inserting text into HTML. + */ +const escapeHtml = (str: string): string => + str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +/** + * Replaces cloaked links in HTML content with visible URLs. + * This ensures recipients can see the actual destination of links, + * helping them identify potentially malicious URLs. + * + * Transforms: Click here + * Into: https://example.com + * + * Also handles nested HTML tags like: Click here + */ +export const replaceCloakedLinksInHtml = (html: string): string => { + // Match anchor tags with href attribute + // Captures: attributes with href, href value, inner content (including nested HTML) + const anchorRegex = /]*href=["']([^"']+)["'][^>]*)>([\s\S]*?)<\/a>/gi; + + return html.replace(anchorRegex, (match, attributes, href, innerContent) => { + // Strip HTML tags from inner content to get plain text for comparison + const linkText = innerContent.replace(/<[^>]*>/g, ""); + + // If the link text is already the URL (or very similar), keep it as is + const normalizedHref = href.toLowerCase().replace(/\/$/, ""); + const normalizedText = linkText.toLowerCase().trim().replace(/\/$/, ""); + + if (normalizedText === normalizedHref || normalizedText === normalizedHref.replace(/^https?:\/\//, "")) { + return match; + } + + // Replace the link text with the actual URL, escaping HTML to prevent XSS + return `${escapeHtml(href)}`; + }); +}; + export const shouldUseTwilio = (trigger: WorkflowTriggerEvents, scheduledDate: dayjs.Dayjs | null) => { if (IMMEDIATE_WORKFLOW_TRIGGER_EVENTS.includes(trigger)) { return true; diff --git a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts index 549043f186..1f199bfa08 100644 --- a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts @@ -26,6 +26,7 @@ import type { VariablesType } from "../reminders/templates/customTemplate"; import customTemplate, { transformBookingResponsesToVariableFormat, } from "../reminders/templates/customTemplate"; +import { replaceCloakedLinksInHtml } from "../reminders/utils"; import emailRatingTemplate from "../reminders/templates/emailRatingTemplate"; import emailReminderTemplate from "../reminders/templates/emailReminderTemplate"; import type { @@ -474,7 +475,7 @@ export class EmailWorkflowService { return { subject: emailContent.emailSubject, - html: emailContent.emailBody, + html: replaceCloakedLinksInHtml(emailContent.emailBody), ...(!evt.hideOrganizerEmail && { replyTo: customReplyToEmail || evt.organizer.email, }), diff --git a/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts b/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts new file mode 100644 index 0000000000..d0ecc98ba6 --- /dev/null +++ b/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "vitest"; + +import { replaceCloakedLinksInHtml } from "../reminders/utils"; + +describe("replaceCloakedLinksInHtml", () => { + describe("basic cloaked links", () => { + test("should replace cloaked link text with the actual URL", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should replace cloaked link with different text", () => { + const html = 'Visit our safe website'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://malicious-site.com'); + }); + + test("should handle links with additional attributes", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + }); + + describe("nested HTML tags", () => { + test("should handle bold text inside link", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should handle strong text inside link", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should handle span inside link", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should handle multiple nested tags", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should handle nested tags with visible URL - should remain unchanged", () => { + const html = 'https://example.com'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + }); + + describe("already-visible URLs", () => { + test("should not modify link when text matches URL exactly", () => { + const html = 'https://example.com'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should not modify link when text matches URL without protocol", () => { + const html = 'example.com'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('example.com'); + }); + + test("should not modify link when text matches URL with trailing slash difference", () => { + const html = 'https://example.com'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should handle case-insensitive URL comparison", () => { + const html = 'https://example.com'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + }); + + describe("empty link text", () => { + test("should replace empty link text with URL", () => { + const html = ''; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should replace whitespace-only link text with URL", () => { + const html = ' '; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + }); + + describe("multiple links in content", () => { + test("should replace all cloaked links in content", () => { + const html = + '

Visit here and there

'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe( + '

Visit https://site1.com and https://site2.com

' + ); + }); + + test("should handle mix of cloaked and visible links", () => { + const html = + '

https://visible.com and Click me

'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe( + '

https://visible.com and https://hidden.com

' + ); + }); + + test("should handle multiple nested HTML links", () => { + const html = + '
Link ALink B
'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe( + '
https://a.comhttps://b.com
' + ); + }); + }); + + describe("edge cases", () => { + test("should handle links with single quotes in href", () => { + const html = "Click here"; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe("https://example.com"); + }); + + test("should handle multiline link content", () => { + const html = ` + Click + here + `; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com'); + }); + + test("should preserve surrounding content", () => { + const html = '

Before

Link

After

'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('

Before

https://example.com

After

'); + }); + + test("should handle content with no links", () => { + const html = "

No links here

"; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe("

No links here

"); + }); + + test("should handle empty string", () => { + const html = ""; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe(""); + }); + + test("should escape HTML special characters in href to prevent XSS", () => { + // Test case for potential XSS vector where href contains HTML special characters + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe('https://example.com?foo=1&bar=2'); + }); + + test("should escape angle brackets in href", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe( + 'https://example.com/<script>alert(1)</script>' + ); + }); + + test("should escape quotes in href", () => { + const html = 'Click here'; + const result = replaceCloakedLinksInHtml(html); + expect(result).toBe( + 'https://example.com?q=&quot;test&quot;' + ); + }); + }); +});