From 186a3e648f8dece08fa07399a1bd3f2ff28b5e1b Mon Sep 17 00:00:00 2001
From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Date: Wed, 21 Jan 2026 22:50:16 +0530
Subject: [PATCH] fix: handle incomplete href attributes in workflow email
links (#27094)
* handle incomplete href
* addrss cubic comment
---
.../ee/workflows/lib/reminders/utils.ts | 34 +++++++++--
.../test/replaceCloakedLinksInHtml.test.ts | 57 +++++++++++++++++++
2 files changed, 87 insertions(+), 4 deletions(-)
diff --git a/packages/features/ee/workflows/lib/reminders/utils.ts b/packages/features/ee/workflows/lib/reminders/utils.ts
index 49179e57e1..cb51bad553 100644
--- a/packages/features/ee/workflows/lib/reminders/utils.ts
+++ b/packages/features/ee/workflows/lib/reminders/utils.ts
@@ -125,6 +125,15 @@ const escapeHtml = (str: string): string =>
.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,
@@ -134,6 +143,7 @@ const escapeHtml = (str: string): string =>
* 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
@@ -142,18 +152,34 @@ export const replaceCloakedLinksInHtml = (html: string): string => {
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, "");
+ 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 = href.toLowerCase().replace(/\/$/, "");
+ const normalizedHref = actualUrl.toLowerCase().replace(/\/$/, "");
const normalizedText = linkText.toLowerCase().trim().replace(/\/$/, "");
- if (normalizedText === normalizedHref || normalizedText === normalizedHref.replace(/^https?:\/\//, "")) {
+ 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(href)}`;
+ return `${escapeHtml(actualUrl)}`;
});
};
diff --git a/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts b/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts
index d0ecc98ba6..8b131f67e6 100644
--- a/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts
+++ b/packages/features/ee/workflows/lib/test/replaceCloakedLinksInHtml.test.ts
@@ -124,6 +124,63 @@ describe("replaceCloakedLinksInHtml", () => {
});
});
+ describe("incomplete hrefs", () => {
+ test("should use URL from link text when href is incomplete (https://)", () => {
+ const html =
+ 'https://bristolwills.com/prepare-for-will-appointment
';
+ const result = replaceCloakedLinksInHtml(html);
+ expect(result).toBe(
+ 'https://bristolwills.com/prepare-for-will-appointment'
+ );
+ });
+
+ test("should use URL from link text when href is incomplete (http://)", () => {
+ const html = 'http://example.com/page';
+ const result = replaceCloakedLinksInHtml(html);
+ expect(result).toBe('http://example.com/page');
+ });
+
+ test("should handle incomplete href with nested HTML tags", () => {
+ const html = 'https://example.com';
+ const result = replaceCloakedLinksInHtml(html);
+ expect(result).toBe('https://example.com');
+ });
+
+ test("should handle incomplete href with additional attributes", () => {
+ const html =
+ 'https://example.com/path';
+ const result = replaceCloakedLinksInHtml(html);
+ expect(result).toBe(
+ 'https://example.com/path'
+ );
+ });
+
+ test("should not modify if link text doesn't contain a valid URL", () => {
+ const html = 'Click here';
+ const result = replaceCloakedLinksInHtml(html);
+ expect(result).toBe('https://');
+ });
+
+ test("should handle incomplete href when link text already matches", () => {
+ const html = 'https://example.com';
+ const result = replaceCloakedLinksInHtml(html);
+ expect(result).toBe('https://example.com');
+ });
+
+ test("should escape quotes in URL when fixing incomplete href to prevent attribute injection", () => {
+ const html = 'https://example.com?q="test"';
+ const result = replaceCloakedLinksInHtml(html);
+ expect(result).toBe('https://example.com?q="test"');
+ });
+
+ test("should escape single quotes in URL when fixing incomplete href attribute", () => {
+ const html = "https://example.com?q='test'";
+ const result = replaceCloakedLinksInHtml(html);
+ // Single quotes in href attribute are escaped, but in link text they don't need escaping
+ expect(result).toBe("https://example.com?q='test'");
+ });
+ });
+
describe("edge cases", () => {
test("should handle links with single quotes in href", () => {
const html = "Click here";