Skip to content

Commit a5f0dd3

Browse files
lpcoxgithub-actions[bot]Copilotgh-aw-bot
authored
Fix safe-output inline-backtick neutralization (#54103)
* Fix safe-output backtick neutralization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b591adab-6031-4a22-ac49-0755015a5014 * Bound safe-output code-span fallback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> * Align mention boundaries and fence regression Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> Copilot-Session: b591adab-6031-4a22-ac49-0755015a5014
1 parent 8d294d1 commit a5f0dd3

7 files changed

Lines changed: 257 additions & 86 deletions

.changeset/patch-fix-safe-output-inline-backticks.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/markdown_code_region_balancer.cjs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@
3636
* @module markdown_code_region_balancer
3737
*/
3838

39+
/**
40+
* Matches a CommonMark fenced-code delimiter line.
41+
* @param {string} line
42+
* @returns {RegExpMatchArray | null}
43+
*/
44+
function matchFenceLine(line) {
45+
const match = line.match(/^( {0,3})(`{3,}|~{3,})([^`~\s]*)?(.*)$/);
46+
if (!match) {
47+
return null;
48+
}
49+
const infoString = `${match[3] || ""}${match[4] || ""}`;
50+
if (match[2][0] === "`" && infoString.includes("`")) {
51+
return null;
52+
}
53+
return match;
54+
}
55+
3956
/**
4057
* Balance markdown code regions by attempting to fix mismatched fences.
4158
*
@@ -106,7 +123,7 @@ function balanceCodeRegions(markdown) {
106123
for (let i = 0; i < lines.length; i++) {
107124
if (isInXmlComment(i)) continue;
108125

109-
const fenceMatch = lines[i].match(/^(\s*)(`{3,}|~{3,})([^`~\s]*)?(.*)$/);
126+
const fenceMatch = matchFenceLine(lines[i]);
110127
if (fenceMatch) {
111128
fences.push({
112129
lineIndex: i,
@@ -340,7 +357,7 @@ function isBalanced(markdown) {
340357
let openingFence = null;
341358

342359
for (const line of lines) {
343-
const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})([^`~\s]*)?(.*)$/);
360+
const fenceMatch = matchFenceLine(line);
344361

345362
if (fenceMatch) {
346363
const fence = fenceMatch[2];
@@ -392,7 +409,7 @@ function countCodeRegions(markdown) {
392409
let openingFence = null;
393410

394411
for (const line of lines) {
395-
const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})([^`~\s]*)?(.*)$/);
412+
const fenceMatch = matchFenceLine(line);
396413

397414
if (fenceMatch) {
398415
const fence = fenceMatch[2];

actions/setup/js/markdown_code_region_balancer.test.cjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ More content.`;
2929
expect(balancer.balanceCodeRegions(input)).toBe(input);
3030
});
3131

32+
it("should not treat backticks in an info string as a fenced block", () => {
33+
const input = "```x```@octocat";
34+
expect(balancer.balanceCodeRegions(input)).toBe(input);
35+
expect(balancer.isBalanced(input)).toBe(true);
36+
});
37+
38+
it("should not treat a four-space-indented fence as a fenced block", () => {
39+
const input = " ```\n@octocat\n ```";
40+
expect(balancer.balanceCodeRegions(input)).toBe(input);
41+
expect(balancer.isBalanced(input)).toBe(true);
42+
});
43+
3244
it("should not modify properly balanced code blocks", () => {
3345
const input = `# Title
3446

actions/setup/js/sanitize_content.cjs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
buildAllowedGitHubReferences,
1515
getCurrentRepoSlug,
1616
applyURLSanitizationPolicy,
17+
createRenderSafeCodeSpanWrapper,
1718
neutralizeCommands,
1819
neutralizeGitHubReferences,
1920
removeXmlComments,
@@ -112,9 +113,6 @@ function sanitizeContent(content, maxLengthOrOptions) {
112113
// removeXmlComments.
113114
sanitized = applyToNonCodeRegions(sanitized, neutralizeMarkdownLinkTitles);
114115

115-
// Neutralize @mentions with selective filtering (custom logic for allowed aliases)
116-
sanitized = neutralizeMentions(sanitized, allowedAliasesLowercase);
117-
118116
// Convert XML tags – skip code blocks and inline code
119117
sanitized = applyToNonCodeRegions(sanitized, convertXmlTags);
120118

@@ -124,6 +122,10 @@ function sanitizeContent(content, maxLengthOrOptions) {
124122
// Apply truncation limits (shared with core)
125123
sanitized = applyTruncation(sanitized, maxLength);
126124

125+
// Neutralize mentions after truncation so the length boundary cannot split an
126+
// inserted code-span delimiter and reactivate a mention.
127+
sanitized = neutralizeMentions(sanitized, allowedAliasesLowercase);
128+
127129
// Neutralize GitHub references if restrictions are configured
128130
sanitized = neutralizeGitHubReferences(sanitized, allowedGitHubRefs);
129131

@@ -186,17 +188,20 @@ function sanitizeContent(content, maxLengthOrOptions) {
186188
* @returns {string} Processed string
187189
*/
188190
function neutralizeMentions(s, allowedLowercase) {
189-
return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => {
190-
// Check if this mention is in the allowed aliases list (case-insensitive)
191-
const isAllowed = allowedLowercase.includes(p2.toLowerCase());
192-
if (isAllowed) {
193-
return `${p1}@${p2}`; // Keep the original mention
194-
}
195-
// Log when a mention is escaped
196-
if (typeof core !== "undefined" && core.info) {
197-
core.info(`Escaped mention: @${p2} (not in allowed list)`);
198-
}
199-
return `${p1}\`@${p2}\``; // Neutralize the mention
191+
const wrapInCodeSpan = createRenderSafeCodeSpanWrapper(s);
192+
return applyToNonCodeRegions(s, (segment, regionBefore = "", regionAfter = "") => {
193+
return segment.replace(/(^|[^A-Za-z0-9])@([A-Za-z0-9](?:[A-Za-z0-9_-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (match, prefix, alias, offset) => {
194+
const isAllowed = allowedLowercase.includes(alias.toLowerCase());
195+
if (isAllowed) {
196+
return `${prefix}@${alias}`;
197+
}
198+
if (typeof core !== "undefined" && core.info) {
199+
core.info(`Escaped mention: @${alias} (not in allowed list)`);
200+
}
201+
const before = prefix || (offset === 0 ? regionBefore : "");
202+
const after = segment[offset + match.length] || (offset + match.length === segment.length ? regionAfter : "");
203+
return `${prefix}${wrapInCodeSpan(`@${alias}`, before, after)}`;
204+
});
200205
});
201206
}
202207
}

actions/setup/js/sanitize_content.test.cjs

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ describe("sanitize_content.cjs", () => {
101101
const result = sanitizeContent("/smoke-copilot-sdk run tests");
102102
expect(result).toBe("`/smoke-copilot-sdk` run tests");
103103
});
104+
105+
it("should keep commands neutralized when the body contains attacker backticks", () => {
106+
const result = sanitizeContent("/bot run ` later");
107+
expect(result).toBe("``/bot`` run ` later");
108+
});
104109
});
105110

106111
describe("@mention neutralization", () => {
@@ -139,6 +144,42 @@ describe("sanitize_content.cjs", () => {
139144
expect(result).toBe("Hello `@user_name_test`");
140145
});
141146

147+
it("should use a distinct delimiter for mentions after unmatched backticks", () => {
148+
expect(sanitizeContent("note ` @octocat done")).toBe("note ` ``@octocat`` done");
149+
});
150+
151+
it("should preserve mentions already contained by matched attacker backticks", () => {
152+
expect(sanitizeContent("start ` mid @octocat end ` tail")).toBe("start ` mid @octocat end ` tail");
153+
});
154+
155+
it("should choose a delimiter length absent from the complete input", () => {
156+
expect(sanitizeContent("one ` two `` @octocat done")).toBe("one ` two `` ```@octocat``` done");
157+
});
158+
159+
it("should neutralize mentions adjacent to unmatched backticks", () => {
160+
expect(sanitizeContent("`@octocat")).toBe("` ``@octocat``");
161+
expect(sanitizeContent("``@octocat`")).toBe("`` ```@octocat``` `");
162+
expect(sanitizeContent("@octocat`")).toBe("``@octocat`` `");
163+
});
164+
165+
it("should preserve mentions inside matched code spans", () => {
166+
expect(sanitizeContent("`@octocat`")).toBe("`@octocat`");
167+
});
168+
169+
it("should separate neutralized mentions from adjacent matched code spans", () => {
170+
expect(sanitizeContent("`x`@octocat @other")).toBe("`x` ``@octocat`` ``@other``");
171+
expect(sanitizeContent("@octocat`x`")).toBe("``@octocat`` `x`");
172+
});
173+
174+
it("should not treat inline code with trailing prose as a fenced block", () => {
175+
expect(sanitizeContent("```x```@octocat")).toBe("```x``` `@octocat`");
176+
});
177+
178+
it("should neutralize a mention after truncating its alias", () => {
179+
expect(sanitizeContent("123456 @octocat", 10)).toBe("123456 `@oc`\n[Content truncated due to length]");
180+
expect(sanitizeContent("123456 @octocat", { maxLength: 10, allowedAliases: ["author"] })).toBe("123456 `@oc`\n[Content truncated due to length]");
181+
});
182+
142183
it("should neutralize @mentions with underscores and hyphens", () => {
143184
const result = sanitizeContent("Hello @user-name_test");
144185
expect(result).toBe("Hello `@user-name_test`");
@@ -1755,6 +1796,21 @@ describe("sanitize_content.cjs", () => {
17551796
// The 12th entry (11th unquoted) is wrapped
17561797
expect(result).toContain("`fixes #12`");
17571798
});
1799+
1800+
it("should keep excess bot triggers neutralized when preceded by attacker backticks", () => {
1801+
const result = sanitizeContent("prefix ` fixes #1", { maxBotMentions: 0 });
1802+
expect(result).toBe("prefix ` ``fixes #1``");
1803+
});
1804+
1805+
it("should neutralize excess bot triggers adjacent to unmatched backticks", () => {
1806+
expect(sanitizeContent("`fixes #1", { maxBotMentions: 0 })).toBe("` ``fixes #1``");
1807+
expect(sanitizeContent("fixes #1`", { maxBotMentions: 0 })).toBe("``fixes #1`` `");
1808+
});
1809+
1810+
it("should separate excess bot triggers from adjacent matched code spans", () => {
1811+
expect(sanitizeContent("`x`fixes #1", { maxBotMentions: 0 })).toBe("`x` ``fixes #1``");
1812+
expect(sanitizeContent("```x```fixes #1", { maxBotMentions: 0 })).toBe("```x``` `fixes #1`");
1813+
});
17581814
});
17591815

17601816
describe("GitHub reference neutralization", () => {
@@ -1782,6 +1838,30 @@ describe("sanitize_content.cjs", () => {
17821838
expect(result).toBe("See issue #123 and `other/repo#456`");
17831839
});
17841840

1841+
it("should keep restricted references neutralized when preceded by attacker backticks", () => {
1842+
process.env.GITHUB_REPOSITORY = "myorg/myrepo";
1843+
process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo";
1844+
1845+
const result = sanitizeContent("see ` other/repo#1337 now");
1846+
expect(result).toBe("see ` ``other/repo#1337`` now");
1847+
});
1848+
1849+
it("should neutralize restricted references adjacent to unmatched backticks", () => {
1850+
process.env.GITHUB_REPOSITORY = "myorg/myrepo";
1851+
process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo";
1852+
1853+
expect(sanitizeContent("`other/repo#1337")).toBe("` ``other/repo#1337``");
1854+
expect(sanitizeContent("other/repo#1337`")).toBe("``other/repo#1337`` `");
1855+
});
1856+
1857+
it("should separate restricted references from adjacent matched code spans", () => {
1858+
process.env.GITHUB_REPOSITORY = "myorg/myrepo";
1859+
process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo";
1860+
1861+
expect(sanitizeContent("`x`other/repo#1337")).toBe("`x` ``other/repo#1337``");
1862+
expect(sanitizeContent("```x```other/repo#1337")).toBe("```x``` `other/repo#1337`");
1863+
});
1864+
17851865
it("should allow current repo references with 'repo' keyword", () => {
17861866
process.env.GITHUB_REPOSITORY = "myorg/myrepo";
17871867
process.env.GH_AW_ALLOWED_GITHUB_REFS = "repo";
@@ -2111,7 +2191,7 @@ describe("sanitize_content.cjs", () => {
21112191

21122192
it("should handle nested backticks", () => {
21132193
const result = sanitizeContent("Already `@user` and @other");
2114-
expect(result).toBe("Already `@user` and `@other`");
2194+
expect(result).toBe("Already `@user` and ``@other``");
21152195
});
21162196
});
21172197

0 commit comments

Comments
 (0)