Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/dependabot-campaign.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions .github/workflows/dependabot-worker.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 75 additions & 0 deletions actions/setup/js/sanitize_content.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2425,6 +2425,81 @@ 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 ­ (double-encoded soft hyphen) and neutralize @mention", () => {
expect(sanitizeContent("@­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 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("@­victim say hi")).toBe("`@victim` say hi");
expect(sanitizeContent("@‌victim say hi")).toBe("`@victim` say hi");
expect(sanitizeContent("@‍victim say hi")).toBe("`@victim` say hi");
expect(sanitizeContent("@‎victim say hi")).toBe("`@victim` say hi");
expect(sanitizeContent("@‏victim say hi")).toBe("`@victim` say hi");
});
});
});

describe("template delimiter neutralization (T24)", () => {
Expand Down
32 changes: 30 additions & 2 deletions actions/setup/js/sanitize_content_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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., @).
* Handles named entities (e.g., @, >, <, &, ­, ‌, ‍,
* ‎, ‏, ​, ⁠, ⁡/⁡, ⁢/⁢,
* ⁣/⁣), decimal entities (e.g., @), and hex entities (e.g., @),
* including double-encoded variants (e.g., @).
*
* @param {string} text - Input text that may contain HTML entities
* @returns {string} Text with HTML entities decoded
Expand All @@ -993,6 +995,32 @@ function decodeHtmlEntities(text) {
// & and & → & (decoded after gt/lt so > 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 ­ → U+00AD (soft hyphen)
result = result.replace(/&(?:amp;)?shy;/gi, "\u00AD");
// ‌ and ‌ → U+200C (zero-width non-joiner)
result = result.replace(/&(?:amp;)?zwnj;/gi, "\u200C");
// ‍ and ‍ → U+200D (zero-width joiner)
result = result.replace(/&(?:amp;)?zwj;/gi, "\u200D");
// ‎ and ‎ → U+200E (left-to-right mark)
result = result.replace(/&(?:amp;)?lrm;/gi, "\u200E");
// ‏ and ‏ → U+200F (right-to-left mark)
result = result.replace(/&(?:amp;)?rlm;/gi, "\u200F");
// ​ and ​ → U+200B (zero-width space)
result = result.replace(/&(?:amp;)?ZeroWidthSpace;/gi, "\u200B");
// ⁠ and ⁠ → 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");

// Decode decimal entities (including double-encoded variants)
// @ and @ → @
// &#NNN; and &#NNN; → corresponding character
Expand Down
Loading