fix: handle incomplete href attributes in workflow email links (#27094)

* handle incomplete href

* addrss cubic comment
This commit is contained in:
Anik Dhabal Babu
2026-01-21 17:20:16 +00:00
committed by GitHub
parent 9fa245a03b
commit 186a3e648f
2 changed files with 87 additions and 4 deletions
@@ -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: <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>
* Handles incomplete hrefs: <a href="https://">https://example.com</a>
*/
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 `<a ${attributes}>${escapeHtml(href)}</a>`;
return `<a ${attributes}>${escapeHtml(actualUrl)}</a>`;
});
};
@@ -124,6 +124,63 @@ describe("replaceCloakedLinksInHtml", () => {
});
});
describe("incomplete hrefs", () => {
test("should use URL from link text when href is incomplete (https://)", () => {
const html =
'<a href="https://" rel="noopener" class="editor-link"><span>https://bristolwills.com/prepare-for-will-appointment</span><br></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe(
'<a href="https://bristolwills.com/prepare-for-will-appointment" rel="noopener" class="editor-link">https://bristolwills.com/prepare-for-will-appointment</a>'
);
});
test("should use URL from link text when href is incomplete (http://)", () => {
const html = '<a href="http://">http://example.com/page</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="http://example.com/page">http://example.com/page</a>');
});
test("should handle incomplete href with nested HTML tags", () => {
const html = '<a href="https://"><span>https://example.com</span></a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should handle incomplete href with additional attributes", () => {
const html =
'<a href="https://" target="_blank" rel="noopener">https://example.com/path</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe(
'<a href="https://example.com/path" target="_blank" rel="noopener">https://example.com/path</a>'
);
});
test("should not modify if link text doesn't contain a valid URL", () => {
const html = '<a href="https://">Click here</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://">https://</a>');
});
test("should handle incomplete href when link text already matches", () => {
const html = '<a href="https://">https://example.com</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com">https://example.com</a>');
});
test("should escape quotes in URL when fixing incomplete href to prevent attribute injection", () => {
const html = '<a href="https://">https://example.com?q="test"</a>';
const result = replaceCloakedLinksInHtml(html);
expect(result).toBe('<a href="https://example.com?q=&quot;test&quot;">https://example.com?q=&quot;test&quot;</a>');
});
test("should escape single quotes in URL when fixing incomplete href attribute", () => {
const html = "<a href='https://'>https://example.com?q='test'</a>";
const result = replaceCloakedLinksInHtml(html);
// Single quotes in href attribute are escaped, but in link text they don't need escaping
expect(result).toBe("<a href=\"https://example.com?q=&#39;test&#39;\">https://example.com?q='test'</a>");
});
});
describe("edge cases", () => {
test("should handle links with single quotes in href", () => {
const html = "<a href='https://example.com'>Click here</a>";