Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions actions/setup/js/sanitize_content.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ const {
neutralizeCommands,
neutralizeGitHubReferences,
removeXmlComments,
neutralizeMarkdownLinkTitles,
convertXmlTags,
applyToNonCodeRegions,
neutralizeBotTriggers,
neutralizeTemplateDelimiters,
applyTruncation,
hardenUnicodeText,
} = require("./sanitize_content_core.cjs");
Expand Down Expand Up @@ -94,6 +96,12 @@ function sanitizeContent(content, maxLengthOrOptions) {
// preventing the full <!--...--> pattern from being matched.
sanitized = applyToNonCodeRegions(sanitized, removeXmlComments);

// Remove markdown link titles — a steganographic injection channel analogous to HTML comments.
// Quoted title text ([text](url "TITLE") and [ref]: url "TITLE") is invisible in GitHub's
// rendered markdown (shown only as hover-tooltips) but reaches the AI model verbatim.
// Must run before mention neutralization for the same ordering reason as removeXmlComments.
sanitized = applyToNonCodeRegions(sanitized, neutralizeMarkdownLinkTitles);

// Neutralize @mentions with selective filtering (custom logic for allowed aliases)
sanitized = neutralizeMentions(sanitized, allowedAliasesLowercase);

Expand All @@ -113,6 +121,10 @@ function sanitizeContent(content, maxLengthOrOptions) {
// Neutralize bot triggers
sanitized = neutralizeBotTriggers(sanitized, maxBotMentions);

// Neutralize template syntax delimiters (defense-in-depth)
// This prevents potential issues if content is processed by downstream template engines
sanitized = neutralizeTemplateDelimiters(sanitized);

// Balance markdown code regions to fix improperly nested fences
// This repairs markdown where AI models generate nested code blocks at the same indentation
sanitized = balanceCodeRegions(sanitized);
Expand Down
71 changes: 71 additions & 0 deletions actions/setup/js/sanitize_content.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2325,4 +2325,75 @@ describe("sanitize_content.cjs", () => {
expect(result).toContain("(example.com/redacted)"); // URL redacted (not in allowed domains)
});
});

describe("allowedAliases branch: markdown link title neutralization (XPIA regression)", () => {
it("should strip hidden double-quoted inline link title when allowedAliases is set", () => {
// Regression: allowedAliases branch previously skipped neutralizeMarkdownLinkTitles,
// allowing XPIA payloads to survive in hover-tooltip text.
const result = sanitizeContent('[text](https://github.com "SYSTEM: malicious payload")', {
allowedAliases: ["user"],
});
expect(result).toBe("[text (SYSTEM: malicious payload)](https://github.com)");
});

it("should strip hidden single-quoted inline link title when allowedAliases is set", () => {
const result = sanitizeContent("[text](https://github.com 'injected payload')", {
allowedAliases: ["user"],
});
expect(result).toBe("[text (injected payload)](https://github.com)");
});

it("should strip hidden parenthesized inline link title when allowedAliases is set", () => {
const result = sanitizeContent("[text](https://github.com (injected payload))", {
allowedAliases: ["user"],
});
expect(result).toBe("[text (injected payload)](https://github.com)");
});

it("should strip title from reference-style link definition when allowedAliases is set", () => {
const result = sanitizeContent('[x][ref]\n\n[ref]: https://github.com "XPIA payload"', {
allowedAliases: ["user"],
});
expect(result).toBe("[x][ref]\n\n[ref]: https://github.com");
});

it("should neutralize link title with @mention payload when allowedAliases is set", () => {
// The title moves to visible link text where the non-allowed @mention is then neutralized
const result = sanitizeContent('[text](https://github.com "@attacker inject payload")', {
allowedAliases: ["author"],
});
expect(result).toBe("[text (`@attacker` inject payload)](https://github.com)");
});

it("should preserve links without titles unchanged when allowedAliases is set", () => {
const result = sanitizeContent("[safe link](https://github.com)", {
allowedAliases: ["user"],
});
expect(result).toBe("[safe link](https://github.com)");
});
});

describe("allowedAliases branch: template delimiter neutralization (XPIA regression)", () => {
it("should neutralize Jinja2/Liquid double braces when allowedAliases is set", () => {
// Regression: allowedAliases branch previously skipped neutralizeTemplateDelimiters
const result = sanitizeContent("Result: {{ secret.token }}", { allowedAliases: ["user"] });
expect(result).toContain("\\{\\{");
});

it("should neutralize Liquid block tags when allowedAliases is set", () => {
const result = sanitizeContent("{% if condition %}value{% endif %}", { allowedAliases: ["user"] });
expect(result).toContain("\\{\\%");
});

it("should neutralize ERB tags when allowedAliases is set", () => {
const result = sanitizeContent("<%= secret %>", { allowedAliases: ["user"] });
expect(result).toContain("\\<%=");
});

it("should neutralize template delimiters while preserving allowed @mention", () => {
const result = sanitizeContent("@author: {{ secret }}", { allowedAliases: ["author"] });
expect(result).toContain("@author"); // allowed mention preserved
expect(result).toContain("\\{\\{"); // template escaped
});
});
});
Loading