diff --git a/actions/setup/js/sanitize_content.test.cjs b/actions/setup/js/sanitize_content.test.cjs index f139f840f69..5e3bc4d7636 100644 --- a/actions/setup/js/sanitize_content.test.cjs +++ b/actions/setup/js/sanitize_content.test.cjs @@ -2425,6 +2425,89 @@ describe("sanitize_content.cjs", () => { // Idempotency: a second pass on the decoded result should not re-introduce > expect(twice).toBe(once); }); + + describe("named invisible-character entities — @mention bypass prevention", () => { + // These tests cover the bypass described in gh-aw#24154 / gh-aw-security#2086. + // Named entity forms of invisible characters must be decoded before Step 3 + // strips the resulting code points, otherwise the "&" character prevents + // neutralizeAllMentions from matching the @ sign. + + it("should decode ­ (soft hyphen U+00AD) and neutralize @mention", () => { + expect(sanitizeContent("@­victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode &shy; (double-encoded soft hyphen) and neutralize @mention", () => { + expect(sanitizeContent("@&shy;victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ‌ (zero-width non-joiner U+200C) and neutralize @mention", () => { + expect(sanitizeContent("@‌victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ‍ (zero-width joiner U+200D) and neutralize @mention", () => { + expect(sanitizeContent("@‍victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ‎ (left-to-right mark U+200E) and neutralize @mention", () => { + expect(sanitizeContent("@‎victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ‏ (right-to-left mark U+200F) and neutralize @mention", () => { + expect(sanitizeContent("@‏victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ​ (U+200B) and neutralize @mention", () => { + expect(sanitizeContent("@​victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ⁠ (word joiner U+2060) and neutralize @mention", () => { + expect(sanitizeContent("@⁠victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ⁡ (invisible function application U+2061) and neutralize @mention", () => { + expect(sanitizeContent("@⁡victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ⁡ (U+2061) and neutralize @mention", () => { + expect(sanitizeContent("@⁡victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ⁢ (invisible times U+2062) and neutralize @mention", () => { + expect(sanitizeContent("@⁢victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ⁢ (U+2062) and neutralize @mention", () => { + expect(sanitizeContent("@⁢victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ⁣ (invisible separator U+2063) and neutralize @mention", () => { + expect(sanitizeContent("@⁣victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode ⁣ (U+2063) and neutralize @mention", () => { + expect(sanitizeContent("@⁣victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode &ip; (invisible plus U+2064) and neutralize @mention", () => { + expect(sanitizeContent("@&ip;victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode &InvisiblePlus; (U+2064) and neutralize @mention", () => { + expect(sanitizeContent("@&InvisiblePlus;victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode multiple named invisible entities between @ and username", () => { + expect(sanitizeContent("@­‌‎victim say hi")).toBe("`@victim` say hi"); + }); + + it("should decode case-insensitive named invisible entities", () => { + expect(sanitizeContent("@&SHY;victim say hi")).toBe("`@victim` say hi"); + expect(sanitizeContent("@&ZWNJ;victim say hi")).toBe("`@victim` say hi"); + expect(sanitizeContent("@&ZWJ;victim say hi")).toBe("`@victim` say hi"); + expect(sanitizeContent("@&LRM;victim say hi")).toBe("`@victim` say hi"); + expect(sanitizeContent("@&RLM;victim say hi")).toBe("`@victim` say hi"); + }); + }); }); describe("template delimiter neutralization (T24)", () => { diff --git a/actions/setup/js/sanitize_content_core.cjs b/actions/setup/js/sanitize_content_core.cjs index dc2bacdd199..e66256bcdc4 100644 --- a/actions/setup/js/sanitize_content_core.cjs +++ b/actions/setup/js/sanitize_content_core.cjs @@ -966,8 +966,10 @@ function applyTruncation(content, maxLength) { /** * Decodes HTML entities to prevent bypass of @mention detection and to ensure * HTML-encoded characters do not persist in sanitized output (e.g. > in titles). - * Handles named entities (e.g., @, >, <, &), decimal entities (e.g., @), - * and hex entities (e.g., @), including double-encoded variants (e.g., &commat;). + * Handles named entities (e.g., @, >, <, &, ­, ‌, ‍, + * ‎, ‏, ​, ⁠, ⁡/⁡, ⁢/⁢, + * ⁣/⁣), decimal entities (e.g., @), and hex entities (e.g., @), + * including double-encoded variants (e.g., &commat;). * * @param {string} text - Input text that may contain HTML entities * @returns {string} Text with HTML entities decoded @@ -993,6 +995,35 @@ function decodeHtmlEntities(text) { // & and &amp; → & (decoded after gt/lt so &gt; is already handled above) result = result.replace(/&(?:amp;)?amp;/gi, "&"); + // Decode named entities for invisible/formatting characters that are stripped in + // hardenUnicodeText Step 3. Without this, the named-entity forms survive entity + // decoding and defeat neutralizeAllMentions (e.g. @­user passes the mention + // regex because "&" is not in [A-Za-z0-9], then renders as @user in GitHub). + // Each entity is decoded to its actual Unicode code point so Step 3 can strip it. + // ­ and &shy; → U+00AD (soft hyphen) + result = result.replace(/&(?:amp;)?shy;/gi, "\u00AD"); + // ‌ and &zwnj; → U+200C (zero-width non-joiner) + result = result.replace(/&(?:amp;)?zwnj;/gi, "\u200C"); + // ‍ and &zwj; → U+200D (zero-width joiner) + result = result.replace(/&(?:amp;)?zwj;/gi, "\u200D"); + // ‎ and &lrm; → U+200E (left-to-right mark) + result = result.replace(/&(?:amp;)?lrm;/gi, "\u200E"); + // ‏ and &rlm; → U+200F (right-to-left mark) + result = result.replace(/&(?:amp;)?rlm;/gi, "\u200F"); + // ​ and &ZeroWidthSpace; → U+200B (zero-width space) + result = result.replace(/&(?:amp;)?ZeroWidthSpace;/gi, "\u200B"); + // ⁠ and &NoBreak; → U+2060 (word joiner) + result = result.replace(/&(?:amp;)?NoBreak;/gi, "\u2060"); + // ⁡ / ⁡ and double-encoded variants → U+2061 (invisible function application) + result = result.replace(/&(?:amp;)?(?:af|ApplyFunction);/gi, "\u2061"); + // ⁢ / ⁢ and double-encoded variants → U+2062 (invisible times) + result = result.replace(/&(?:amp;)?(?:it|InvisibleTimes);/gi, "\u2062"); + // ⁣ / ⁣ and double-encoded variants → U+2063 (invisible separator) + result = result.replace(/&(?:amp;)?(?:ic|InvisibleComma);/gi, "\u2063"); + // &ip; / &InvisiblePlus; and double-encoded variants → U+2064 (invisible plus) + // Note: U+2064 is the upper bound of the \u2060-\u2064 range stripped in Step 3. + result = result.replace(/&(?:amp;)?(?:ip|InvisiblePlus);/gi, "\u2064"); + // Decode decimal entities (including double-encoded variants) // @ and &#64; → @ // &#NNN; and &#NNN; → corresponding character