diff --git a/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx b/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx index 6d05654d5c..3042bd2a10 100644 --- a/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx +++ b/apps/web/modules/ee/workflows/components/WorkflowStepContainer.tsx @@ -1646,11 +1646,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { (hasActiveTeamPlan || isSMSAction(step.action)) } excludedToolbarItems={ - isSMSAction(step.action) - ? ["blockType", "bold", "italic", "link"] - : isOrganization - ? [] - : ["link"] + !isSMSAction(step.action) ? [] : ["blockType", "bold", "italic", "link"] } plainText={isSMSAction(step.action)} /> diff --git a/packages/features/ee/workflows/api/scheduleEmailReminders.ts b/packages/features/ee/workflows/api/scheduleEmailReminders.ts index be6f892ea5..f7c6ffb5ef 100644 --- a/packages/features/ee/workflows/api/scheduleEmailReminders.ts +++ b/packages/features/ee/workflows/api/scheduleEmailReminders.ts @@ -35,7 +35,6 @@ import type { VariablesType } from "../lib/reminders/templates/customTemplate"; import customTemplate from "../lib/reminders/templates/customTemplate"; import emailRatingTemplate from "../lib/reminders/templates/emailRatingTemplate"; import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate"; -import { replaceCloakedLinksInHtml } from "../lib/reminders/utils"; export async function handler(req: NextRequest) { const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey"); @@ -355,17 +354,10 @@ export async function handler(req: NextRequest) { title: booking.title || booking.eventType?.title || "", }; - // Organization accounts are allowed to use cloaked links (URL behind text) - // since they are paid accounts with lower spam/scam risk - const isOrganization = reminder.workflowStep?.workflow?.team?.isOrganization ?? false; - const processedEmailBody = isOrganization - ? emailContent.emailBody - : replaceCloakedLinksInHtml(emailContent.emailBody); - const mailData = { subject: emailContent.emailSubject, to: Array.isArray(sendTo) ? sendTo : [sendTo], - html: processedEmailBody, + html: emailContent.emailBody, attachments: reminder.workflowStep.includeCalendarEvent ? [ { @@ -461,17 +453,10 @@ export async function handler(req: NextRequest) { if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) { const batchId = isSendgridEnabled ? await getBatchId() : undefined; - // Organization accounts are allowed to use cloaked links (URL behind text) - // since they are paid accounts with lower spam/scam risk - const isOrganization = reminder.workflowStep?.workflow?.team?.isOrganization ?? false; - const processedEmailBody = isOrganization - ? emailContent.emailBody - : replaceCloakedLinksInHtml(emailContent.emailBody); - const mailData = { subject: emailContent.emailSubject, to: [sendTo], - html: processedEmailBody, + html: emailContent.emailBody, sender: reminder.workflowStep?.sender, ...(!reminder.booking?.eventType?.hideOrganizerEmail && { replyTo: diff --git a/packages/features/ee/workflows/lib/getWorkflowReminders.ts b/packages/features/ee/workflows/lib/getWorkflowReminders.ts index b277b067d5..5a967f278b 100644 --- a/packages/features/ee/workflows/lib/getWorkflowReminders.ts +++ b/packages/features/ee/workflows/lib/getWorkflowReminders.ts @@ -8,9 +8,6 @@ type PartialWorkflowStep = workflow: { userId?: number; teamId?: number; - team?: { - isOrganization: boolean; - } | null; }; }) | null; @@ -150,11 +147,6 @@ export const select = { select: { userId: true, teamId: true, - team: { - select: { - isOrganization: true, - }, - }, }, }, }, diff --git a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts index e4ac6b78ba..5764ba23f1 100644 --- a/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/emailReminderManager.ts @@ -8,14 +8,13 @@ import logger from "@calcom/lib/logger"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import prisma from "@calcom/prisma"; import type { TimeUnit } from "@calcom/prisma/enums"; -import { WorkflowMethods, WorkflowTriggerEvents, type WorkflowTemplates } from "@calcom/prisma/enums"; +import { WorkflowMethods, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums"; -import type { BookingInfo, FormSubmissionData, ScheduleEmailReminderAction } from "../types"; +import type { BookingInfo, ScheduleEmailReminderAction, FormSubmissionData } from "../types"; 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]"] }); @@ -39,7 +38,6 @@ type scheduleEmailReminderArgs = ScheduleReminderArgs & { hideBranding?: boolean; includeCalendarEvent?: boolean; verifiedAt: Date | null; - isOrganization?: boolean; }; type SendEmailReminderParams = { @@ -117,7 +115,6 @@ const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { e hideBranding, includeCalendarEvent, action, - isOrganization, } = args; const uid = evt.uid as string; @@ -155,7 +152,6 @@ const scheduleEmailReminderForEvt = async (args: scheduleEmailReminderArgs & { e template, includeCalendarEvent, triggerEvent, - isOrganization, }); await sendOrScheduleWorkflowEmailWithReminder({ @@ -184,7 +180,6 @@ const scheduleEmailReminderForForm = async ( emailSubject = "", emailBody = "", hideBranding, - isOrganization, } = args; const emailContent = { @@ -213,15 +208,9 @@ const scheduleEmailReminderForForm = async ( // Allows debugging generated email content without waiting for sendgrid to send emails log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent)); - // Organization accounts are allowed to use cloaked links (URL behind text) - // since they are paid accounts with lower spam/scam risk - const processedEmailBody = isOrganization - ? emailContent.emailBody - : replaceCloakedLinksInHtml(emailContent.emailBody); - const mailData = { subject: emailContent.emailSubject, - html: processedEmailBody, + html: emailContent.emailBody, sender, }; diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts index cb51bad553..1cf6dbdc82 100644 --- a/packages/features/ee/workflows/lib/reminders/utils.ts +++ b/packages/features/ee/workflows/lib/reminders/utils.ts @@ -115,74 +115,6 @@ 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, """); - -/** - * Escapes HTML attribute values to prevent attribute injection. - */ -const escapeHtmlAttribute = (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 - * Handles incomplete hrefs: https://example.com - */ -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, "").trim(); - - // Check if href is incomplete (just protocol like "https://" or "http://") - const isIncompleteHref = /^https?:\/\/?$/.test(href); - - // If href is incomplete and link text contains a valid URL, use the link text URL - let actualUrl = href; - let hrefWasUpdated = false; - if (isIncompleteHref && linkText) { - const urlPattern = /^https?:\/\/[^\s]+/i; - const urlMatch = linkText.match(urlPattern); - if (urlMatch) { - actualUrl = urlMatch[0]; - attributes = attributes.replace(/href=["'][^"']+["']/, `href="${escapeHtmlAttribute(actualUrl)}"`); - hrefWasUpdated = true; - } - } - - // If the link text is already the URL (or very similar), keep it as is - const normalizedHref = actualUrl.toLowerCase().replace(/\/$/, ""); - const normalizedText = linkText.toLowerCase().trim().replace(/\/$/, ""); - - if (!hrefWasUpdated && (normalizedText === normalizedHref || normalizedText === normalizedHref.replace(/^https?:\/\//, ""))) { - return match; - } - - // Replace the link text with the actual URL, escaping HTML to prevent XSS - return `${escapeHtml(actualUrl)}`; - }); -}; - 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 de6f77daca..4070f77325 100644 --- a/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts +++ b/packages/features/ee/workflows/lib/service/EmailWorkflowService.ts @@ -28,7 +28,6 @@ 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 { @@ -69,7 +68,6 @@ export class EmailWorkflowService { } const workflow = workflowReminder.workflowStep.workflow; - const isOrganization = workflow.team?.isOrganization ?? false; let emailAttendeeSendToOverride: string | null = null; if (workflowReminder.seatReferenceId) { @@ -111,7 +109,6 @@ export class EmailWorkflowService { action: workflowReminder.workflowStep.action as ScheduleEmailReminderAction, template: workflowReminder.workflowStep.template, includeCalendarEvent: workflowReminder.workflowStep.includeCalendarEvent, - isOrganization, }); const results = await Promise.allSettled( @@ -252,7 +249,6 @@ export class EmailWorkflowService { template, includeCalendarEvent, triggerEvent, - isOrganization, }: { evt: BookingInfo; sendTo: string[]; @@ -265,7 +261,6 @@ export class EmailWorkflowService { template?: WorkflowTemplates; includeCalendarEvent?: boolean; triggerEvent: WorkflowTriggerEvents; - isOrganization?: boolean; }) { const log = logger.getSubLogger({ prefix: [`[generateEmailPayloadForEvtWorkflow]: bookingUid: ${evt?.uid}`], @@ -549,15 +544,9 @@ export class EmailWorkflowService { const customReplyToEmail = evt?.eventType?.customReplyToEmail || (evt as CalendarEvent).customReplyToEmail; - // Organization accounts are allowed to use cloaked links (URL behind text) - // since they are paid accounts with lower spam/scam risk - const processedEmailBody = isOrganization - ? emailContent.emailBody - : replaceCloakedLinksInHtml(emailContent.emailBody); - return { subject: emailContent.emailSubject, - html: processedEmailBody, + html: 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 deleted file mode 100644 index 8b131f67e6..0000000000 --- a/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -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 = - '
'; - 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 = - ''; - const result = replaceCloakedLinksInHtml(html); - expect(result).toBe( - '' - ); - }); - }); - - describe("incomplete hrefs", () => { - test("should use URL from link text when href is incomplete (https://)", () => { - const html = - 'https://bristolwills.com/prepare-for-will-appointmentBefore
LinkAfter
'; - const result = replaceCloakedLinksInHtml(html); - expect(result).toBe('Before
https://example.comAfter
'); - }); - - 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="test"' - ); - }); - }); -}); diff --git a/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts b/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts index beab47c2f0..4a6965e14f 100644 --- a/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts +++ b/packages/features/ee/workflows/repositories/WorkflowReminderRepository.ts @@ -134,11 +134,6 @@ export class WorkflowReminderRepository { timeUnit: true, userId: true, teamId: true, - team: { - select: { - isOrganization: true, - }, - }, }, }, },