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>
This commit is contained in:
Peer RichelsenandDevin AI authored and GitHub committed 2025-12-30 01:53:04 -03:00
1 parent ccc8c2b566
commit ec90c1ae24
6 files changed
+238 -8

No files matched your search

@@ -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)}
/>
@@ -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:
@@ -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,
};
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/**
* 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: <a href="https://example.com">Click here</a>
* Into: <a href="https://example.com">https://example.com</a>
*
* Also handles nested HTML tags like: <a href="https://example.com"><b>Click here</b></a>
*/
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 = /<a\s+([^>]*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 `<a ${attributes}>${escapeHtml(href)}</a>`;
});
};
export const shouldUseTwilio = (trigger: WorkflowTriggerEvents, scheduledDate: dayjs.Dayjs | null) => {
if (IMMEDIATE_WORKFLOW_TRIGGER_EVENTS.includes(trigger)) {
return true;
@@ -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,
}),
@@ -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 = '<a href="https://example.com">Click here</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should replace cloaked link with different text", () => {
const html = '<a href="https://malicious-site.com">Visit our safe website</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://malicious-site.com">https://malicious-site.com</a>');
});
test("should handle links with additional attributes", () => {
const html = '<a href="https://example.com" target="_blank" rel="noopener">Click here</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com" target="_blank" rel="noopener">https://example.com</a>');
});
});
describe("nested HTML tags", () => {
test("should handle bold text inside link", () => {
const html = '<a href="https://example.com"><b>Click here</b></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should handle strong text inside link", () => {
const html = '<a href="https://example.com"><strong>Click here</strong></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should handle span inside link", () => {
const html = '<a href="https://example.com"><span class="link-text">Click here</span></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should handle multiple nested tags", () => {
const html = '<a href="https://example.com"><span><strong>Click here</strong></span></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should handle nested tags with visible URL - should remain unchanged", () => {
const html = '<a href="https://example.com"><strong>https://example.com</strong></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com"><strong>https://example.com</strong></a>');
});
});
describe("already-visible URLs", () => {
test("should not modify link when text matches URL exactly", () => {
const html = '<a href="https://example.com">https://example.com</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should not modify link when text matches URL without protocol", () => {
const html = '<a href="https://example.com">example.com</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">example.com</a>');
});
test("should not modify link when text matches URL with trailing slash difference", () => {
const html = '<a href="https://example.com/">https://example.com</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com/">https://example.com</a>');
});
test("should handle case-insensitive URL comparison", () => {
const html = '<a href="https://Example.COM">https://example.com</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://Example.COM">https://example.com</a>');
});
});
describe("empty link text", () => {
test("should replace empty link text with URL", () => {
const html = '<a href="https://example.com"></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should replace whitespace-only link text with URL", () => {
const html = '<a href="https://example.com"> </a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
});
describe("multiple links in content", () => {
test("should replace all cloaked links in content", () => {
const html =
'<p>Visit <a href="https://site1.com">here</a> and <a href="https://site2.com">there</a></p>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe(
'<p>Visit <a href="https://site1.com">https://site1.com</a> and <a href="https://site2.com">https://site2.com</a></p>'
);
});
test("should handle mix of cloaked and visible links", () => {
const html =
'<p><a href="https://visible.com">https://visible.com</a> and <a href="https://hidden.com">Click me</a></p>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe(
'<p><a href="https://visible.com">https://visible.com</a> and <a href="https://hidden.com">https://hidden.com</a></p>'
);
});
test("should handle multiple nested HTML links", () => {
const html =
'<div><a href="https://a.com"><b>Link A</b></a><a href="https://b.com"><i>Link B</i></a></div>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe(
'<div><a href="https://a.com">https://a.com</a><a href="https://b.com">https://b.com</a></div>'
);
});
});
describe("edge cases", () => {
test("should handle links with single quotes in href", () => {
const html = "<a href='https://example.com'>Click here</a>";
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe("<a href='https://example.com'>https://example.com</a>");
});
test("should handle multiline link content", () => {
const html = `<a href="https://example.com">
Click
here
</a>`;
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should preserve surrounding content", () => {
const html = '<p>Before</p><a href="https://example.com">Link</a><p>After</p>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<p>Before</p><a href="https://example.com">https://example.com</a><p>After</p>');
});
test("should handle content with no links", () => {
const html = "<p>No links here</p>";
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe("<p>No links here</p>");
});
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 = '<a href="https://example.com?foo=1&bar=2">Click here</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com?foo=1&bar=2">https://example.com?foo=1&amp;bar=2</a>');
});
test("should escape angle brackets in href", () => {
const html = '<a href="https://example.com/<script>alert(1)</script>">Click here</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe(
'<a href="https://example.com/<script>alert(1)</script>">https://example.com/&lt;script&gt;alert(1)&lt;/script&gt;</a>'
);
});
test("should escape quotes in href", () => {
const html = '<a href="https://example.com?q=&quot;test&quot;">Click here</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe(
'<a href="https://example.com?q=&quot;test&quot;">https://example.com?q=&amp;quot;test&amp;quot;</a>'
);
});
});
});