Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/patch-sanitize-safe-output-handlers.md

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

4 changes: 4 additions & 0 deletions actions/setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { resolveTarget } = require("./safe_output_helpers.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { getMissingInfoSections } = require("./missing_messages_helper.cjs");
const { getMessages } = require("./messages_core.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "add_comment";
Expand Down Expand Up @@ -463,6 +464,9 @@ async function main(config = {}) {
// Replace temporary ID references in body
let processedBody = replaceTemporaryIdReferences(item.body || "", temporaryIdMap, itemRepo);

// Sanitize content to prevent injection attacks
processedBody = sanitizeContent(processedBody);

// Enforce max limits before processing (validates user-provided content)
try {
enforceCommentLimits(processedBody);
Expand Down
116 changes: 116 additions & 0 deletions actions/setup/js/add_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,122 @@ describe("add_comment", () => {
expect(capturedBody).not.toContain("aw_test02");
});
});

describe("sanitization preserves markers", () => {
it("should preserve tracker ID markers after sanitization", async () => {
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");

// Setup environment
process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
process.env.GH_AW_TRACKER_ID = "test-tracker-123";

let capturedBody = null;
mockGithub.rest.issues.createComment = async params => {
capturedBody = params.body;
return {
data: {
id: 12345,
html_url: "https://github.com/owner/repo/issues/42#issuecomment-12345",
},
};
};

// Execute the handler
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);

const message = {
type: "add_comment",
body: "User content with <script>alert('xss')</script> attempt",
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(capturedBody).toBeDefined();
// Verify tracker ID is present (not removed by sanitization)
expect(capturedBody).toContain("<!-- gh-aw-tracker-id: test-tracker-123 -->");
// Verify script tags were sanitized (converted to safe format)
expect(capturedBody).not.toContain("<script>");
expect(capturedBody).toContain("(script)"); // Tags converted to parentheses

delete process.env.GH_AW_WORKFLOW_NAME;
delete process.env.GH_AW_TRACKER_ID;
});

it("should preserve workflow footer after sanitization", async () => {
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");

// Setup environment
process.env.GH_AW_WORKFLOW_NAME = "Security Test Workflow";

let capturedBody = null;
mockGithub.rest.issues.createComment = async params => {
capturedBody = params.body;
return {
data: {
id: 12345,
html_url: "https://github.com/owner/repo/issues/42#issuecomment-12345",
},
};
};

// Execute the handler
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);

const message = {
type: "add_comment",
body: "User content <!-- malicious comment -->",
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(capturedBody).toBeDefined();
// Verify AI footer is present (not removed by sanitization)
expect(capturedBody).toContain("AI generated by");
expect(capturedBody).toContain("Security Test Workflow");
// Verify malicious comment in user content was removed by sanitization
expect(capturedBody).not.toContain("<!-- malicious comment -->");

delete process.env.GH_AW_WORKFLOW_NAME;
});

it("should sanitize user content but preserve system markers", async () => {
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");

let capturedBody = null;
mockGithub.rest.issues.createComment = async params => {
capturedBody = params.body;
return {
data: {
id: 12345,
html_url: "https://github.com/owner/repo/issues/42#issuecomment-12345",
},
};
};

// Execute the handler
const handler = await eval(`(async () => { ${addCommentScript}; return await main({}); })()`);

const message = {
type: "add_comment",
body: "User says: @badactor please <!-- inject this --> [phishing](http://evil.com)",
};

const result = await handler(message, {});

expect(result.success).toBe(true);
expect(capturedBody).toBeDefined();

// User content should be sanitized
expect(capturedBody).toContain("`@badactor`"); // Mention neutralized
expect(capturedBody).not.toContain("<!-- inject this -->"); // Comment removed
expect(capturedBody).toContain("(evil.com/redacted)"); // HTTP URL redacted

// But footer should still be present with proper markdown
expect(capturedBody).toContain("> AI generated by");
});
});
});

describe("enforceCommentLimits", () => {
Expand Down
13 changes: 8 additions & 5 deletions actions/setup/js/add_reaction_and_edit_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
const { getRunStartedMessage } = require("./messages_run_status.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

async function main() {
// Read inputs from environment variables
Expand Down Expand Up @@ -328,18 +329,20 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) {
eventType: eventTypeDescription,
});

// Add workflow-id and tracker-id markers for hide-older-comments feature
const workflowId = process.env.GITHUB_WORKFLOW || "";
const trackerId = process.env.GH_AW_TRACKER_ID || "";

let commentBody = workflowLinkText;
// Sanitize the workflow link text to prevent injection attacks (defense in depth for custom message templates)
// This must happen BEFORE adding workflow markers to preserve them
let commentBody = sanitizeContent(workflowLinkText);

// Add lock notice if lock-for-agent is enabled for issues or issue_comment
const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true";
if (lockForAgent && (eventName === "issues" || eventName === "issue_comment")) {
commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications.";
}

// Add workflow-id and tracker-id markers for hide-older-comments feature
const workflowId = process.env.GITHUB_WORKFLOW || "";
const trackerId = process.env.GH_AW_TRACKER_ID || "";

// Add workflow-id marker if available
if (workflowId) {
commentBody += `\n\n${generateWorkflowIdMarker(workflowId)}`;
Expand Down
13 changes: 8 additions & 5 deletions actions/setup/js/add_workflow_run_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
const { getRunStartedMessage } = require("./messages_run_status.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { generateWorkflowIdMarker } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/**
* Add a comment with a workflow run link to the triggering item.
Expand Down Expand Up @@ -140,18 +141,20 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName) {
eventType: eventTypeDescription,
});

// Add workflow-id and tracker-id markers for hide-older-comments feature
const workflowId = process.env.GITHUB_WORKFLOW || "";
const trackerId = process.env.GH_AW_TRACKER_ID || "";

let commentBody = workflowLinkText;
// Sanitize the workflow link text to prevent injection attacks (defense in depth for custom message templates)
// This must happen BEFORE adding workflow markers to preserve them
let commentBody = sanitizeContent(workflowLinkText);

// Add lock notice if lock-for-agent is enabled for issues or issue_comment
const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true";
if (lockForAgent && (eventName === "issues" || eventName === "issue_comment")) {
commentBody += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications.";
}

// Add workflow-id and tracker-id markers for hide-older-comments feature
const workflowId = process.env.GITHUB_WORKFLOW || "";
const trackerId = process.env.GH_AW_TRACKER_ID || "";

// Add workflow-id marker if available
if (workflowId) {
commentBody += `\n\n${generateWorkflowIdMarker(workflowId)}`;
Expand Down
2 changes: 2 additions & 0 deletions actions/setup/js/check_workflow_recompile_needed.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ async function main() {
const footer = getFooterWorkflowRecompileCommentMessage(ctx);
const xmlMarker = generateXMLMarker(workflowName, runUrl);

// Sanitize the message text but not the footer/marker which are system-generated
const commentBody = `Workflows are still out of sync.\n\n---\n${footer}\n\n${xmlMarker}`;

await github.rest.issues.createComment({
Expand Down Expand Up @@ -157,6 +158,7 @@ async function main() {
// Use custom footer template if configured, with XML marker for traceability
const footer = getFooterWorkflowRecompileMessage(ctx);
const xmlMarker = generateXMLMarker(workflowName, runUrl);
// Note: issueBody is built from a template render, no user content to sanitize
issueBody += "\n\n---\n" + footer + "\n\n" + xmlMarker + "\n";

try {
Expand Down
4 changes: 3 additions & 1 deletion actions/setup/js/close_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

const { getErrorMessage } = require("./error_helpers.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/**
* Get discussion details using GraphQL with pagination for labels
Expand Down Expand Up @@ -264,7 +265,8 @@ async function main(config = {}) {
// Add comment if body is provided
let commentUrl;
if (item.body) {
const comment = await addDiscussionComment(github, discussion.id, item.body);
const sanitizedBody = sanitizeContent(item.body);
const comment = await addDiscussionComment(github, discussion.id, sanitizedBody);
core.info(`Added comment to discussion #${discussionNumber}: ${comment.url}`);
commentUrl = comment.url;
}
Expand Down
6 changes: 5 additions & 1 deletion actions/setup/js/close_entity_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { generateFooterWithMessages } = require("./messages_footer.cjs");
const { getTrackerID } = require("./get_tracker_id.cjs");
const { getRepositoryUrl } = require("./get_repository_url.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/**
* @typedef {'issue' | 'pull_request'} EntityType
Expand Down Expand Up @@ -57,7 +58,10 @@ function buildCommentBody(body, triggeringIssueNumber, triggeringPRNumber) {
const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
const runUrl = buildRunUrl();

return body.trim() + getTrackerID("markdown") + generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, undefined);
// Sanitize the body content to prevent injection attacks
const sanitizedBody = sanitizeContent(body);

return sanitizedBody.trim() + getTrackerID("markdown") + generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, undefined);
}

/**
Expand Down
54 changes: 54 additions & 0 deletions actions/setup/js/close_entity_helpers.test.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import fs from "fs";
import path from "path";
const mockCore = { debug: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() } },
mockContext = { eventName: "issues", runId: 12345, repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 42 }, pull_request: { number: 100 }, repository: { html_url: "https://github.com/testowner/testrepo" } } };
((global.core = mockCore), (global.context = mockContext));
Expand Down Expand Up @@ -183,5 +185,57 @@ describe("close_entity_helpers", () => {
it("should have correct URL path", () => {
expect(PULL_REQUEST_CONFIG.urlPath).toBe("pull");
}));
}),
describe("sanitization preserves markers", () => {
it("should sanitize user content while preserving system markers in buildCommentBody", () => {
// Import sanitizeContent to test the actual behavior
const { sanitizeContent } = require("./sanitize_content.cjs");
const { getTrackerID } = require("./get_tracker_id.cjs");
const { generateFooterWithMessages } = require("./messages_footer.cjs");

// Set up environment for tracker ID and footer
process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
process.env.GH_AW_WORKFLOW_SOURCE = "test.md";
process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/test/repo";
process.env.GH_AW_TRACKER_ID = "test-tracker-456";

// Simulate what buildCommentBody does
const userContent = "User comment with <script>xss</script> and <!-- evil comment -->";
const sanitizedBody = sanitizeContent(userContent);
const trackerID = getTrackerID("markdown");
const footer = generateFooterWithMessages("Test Workflow", "https://github.com/test/repo/actions/runs/123", "test.md", "https://github.com/test/repo", 42, undefined, undefined);
const result = sanitizedBody.trim() + trackerID + footer;

// User content should be sanitized (tags converted)
expect(result).not.toContain("<script>");
expect(result).toContain("(script)"); // Tags converted to parentheses
expect(result).not.toContain("<!-- evil comment -->");

// System markers should be preserved
expect(result).toContain("<!-- gh-aw-tracker-id: test-tracker-456 -->");
expect(result).toContain("Generated by"); // Footer contains this text

// Clean up
delete process.env.GH_AW_WORKFLOW_NAME;
delete process.env.GH_AW_WORKFLOW_SOURCE;
delete process.env.GH_AW_WORKFLOW_SOURCE_URL;
delete process.env.GH_AW_TRACKER_ID;
});

it("should preserve footer markers when user content has malicious XML comments", () => {
const { sanitizeContent } = require("./sanitize_content.cjs");

const userContent = "Text with <!-- malicious --> comment";
const sanitized = sanitizeContent(userContent);

// User's malicious comment should be removed
expect(sanitized).not.toContain("<!-- malicious -->");

// Now add system marker after sanitization
const withMarker = sanitized + "\n\n<!-- gh-aw-workflow-id: test -->";

// System marker should still be present
expect(withMarker).toContain("<!-- gh-aw-workflow-id: test -->");
});
}));
});
3 changes: 2 additions & 1 deletion actions/setup/js/close_expired_discussions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs");
const { generateExpiredEntityFooter } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/**
* Add comment to a GitHub Discussion using GraphQL
Expand All @@ -22,7 +23,7 @@ async function addDiscussionComment(github, discussionId, message) {
}
}
}`,
{ dId: discussionId, body: message }
{ dId: discussionId, body: sanitizeContent(message) }
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanitization is applied to a message that contains system-generated markers including <!-- gh-aw-closed -->. Since sanitizeContent() calls removeXmlComments(), this will strip the <!-- gh-aw-closed --> marker that is added on line 143 and checked by hasExpirationComment(). The sanitization should either be removed (since the message is entirely system-generated with no user content), or the marker should be added AFTER the sanitization call.

Copilot uses AI. Check for mistakes.
);

return result.addDiscussionComment.comment;
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/close_expired_issues.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs");
const { generateExpiredEntityFooter } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/**
* Add comment to a GitHub Issue using REST API
Expand All @@ -18,7 +19,7 @@ async function addIssueComment(github, owner, repo, issueNumber, message) {
owner: owner,
repo: repo,
issue_number: issueNumber,
body: message,
body: sanitizeContent(message),
});

return result.data;
Expand Down
3 changes: 2 additions & 1 deletion actions/setup/js/close_expired_pull_requests.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { executeExpiredEntityCleanup } = require("./expired_entity_main_flow.cjs");
const { generateExpiredEntityFooter } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");

/**
* Add comment to a GitHub Pull Request using REST API
Expand All @@ -18,7 +19,7 @@ async function addPullRequestComment(github, owner, repo, prNumber, message) {
owner: owner,
repo: repo,
issue_number: prNumber,
body: message,
body: sanitizeContent(message),
});

return result.data;
Expand Down
Loading