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";