Skip to content
Closed
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
71 changes: 70 additions & 1 deletion actions/setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,57 @@ const { getMessages } = require("./messages_core.cjs");
/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "add_comment";

/**
* Maximum body length for GitHub comments (GitHub's API limit)
* Reference: https://github.com/dead-claudia/github-limits
*/
const MAX_COMMENT_LENGTH = 65536;

/**
* Maximum number of @mentions allowed in a single comment
* Prevents abuse and excessive notifications
*/
const MAX_MENTIONS = 10;

/**
* Maximum number of links allowed in a single comment
* Prevents spam and resource exhaustion
*/
const MAX_LINKS = 50;

/**
* Enforce comment limits to prevent resource exhaustion attacks
* @param {string} body - Comment body to validate
* @throws {Error} If any limit is exceeded (with E002 error code)
*/
function enforceCommentLimits(body) {
if (!body || typeof body !== "string") {
// Empty or non-string bodies are allowed (will be handled elsewhere)
return;
}

// Check maximum body length
if (body.length > MAX_COMMENT_LENGTH) {
throw new Error(`E002: Comment body exceeds maximum length of ${MAX_COMMENT_LENGTH} characters (got ${body.length})`);
}

// Count @mentions (username mentions)
const mentions = (body.match(/@\w+/g) || []).length;
if (mentions > MAX_MENTIONS) {
throw new Error(`E002: Comment contains ${mentions} @mentions, maximum exceeded (max: ${MAX_MENTIONS})`);
}

// Count links (markdown links and bare URLs)
// Match markdown links: [text](url) and bare URLs: http(s)://...
const markdownLinks = (body.match(/\[([^\]]+)\]\(([^)]+)\)/g) || []).length;
const bareUrls = (body.match(/https?:\/\/[^\s)]+/g) || []).length;
const totalLinks = markdownLinks + bareUrls;

if (totalLinks > MAX_LINKS) {
throw new Error(`E002: Comment contains ${totalLinks} links, maximum exceeded (max: ${MAX_LINKS})`);
}
}

// Copy helper functions from original file
async function minimizeComment(github, nodeId, reason = "outdated") {
const query = /* GraphQL */ `
Expand Down Expand Up @@ -441,6 +492,18 @@ async function main(config = {}) {
processedBody += missingInfoSections;
}

// Enforce comment limits before API call
try {
enforceCommentLimits(processedBody);
} catch (error) {
const errorMessage = getErrorMessage(error);
core.error(`Comment limit enforcement failed: ${errorMessage}`);
return {
success: false,
error: errorMessage,
};
}

core.info(`Adding comment to ${isDiscussion ? "discussion" : "issue/PR"} #${itemNumber} in ${itemRepo}`);

// If in staged mode, preview the comment without creating it
Expand Down Expand Up @@ -632,4 +695,10 @@ async function main(config = {}) {
};
}

module.exports = { main };
module.exports = {
main,
enforceCommentLimits,
MAX_COMMENT_LENGTH,
MAX_MENTIONS,
MAX_LINKS,
};
132 changes: 132 additions & 0 deletions actions/setup/js/add_comment.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,4 +1098,136 @@ describe("add_comment", () => {
expect(capturedBody).not.toContain("aw_test02");
});
});

// Test enforceCommentLimits function
describe("enforceCommentLimits", () => {
let enforceCommentLimits, MAX_COMMENT_LENGTH, MAX_MENTIONS, MAX_LINKS;

beforeEach(async () => {
const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8");
const exports = await eval(`(async () => { ${addCommentScript}; return { enforceCommentLimits, MAX_COMMENT_LENGTH, MAX_MENTIONS, MAX_LINKS }; })()`);
enforceCommentLimits = exports.enforceCommentLimits;
MAX_COMMENT_LENGTH = exports.MAX_COMMENT_LENGTH;
MAX_MENTIONS = exports.MAX_MENTIONS;
MAX_LINKS = exports.MAX_LINKS;
});

describe("body length validation", () => {
it("should allow comments within length limit", () => {
const body = "This is a normal comment";
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should allow empty comments", () => {
expect(() => enforceCommentLimits("")).not.toThrow();
});

it("should allow null/undefined comments", () => {
expect(() => enforceCommentLimits(null)).not.toThrow();
expect(() => enforceCommentLimits(undefined)).not.toThrow();
});

it("should reject comments exceeding maximum length", () => {
const body = "a".repeat(MAX_COMMENT_LENGTH + 1);
expect(() => enforceCommentLimits(body)).toThrow(`E002: Comment body exceeds maximum length of ${MAX_COMMENT_LENGTH} characters (got ${MAX_COMMENT_LENGTH + 1})`);
});

it("should allow comments exactly at maximum length", () => {
const body = "a".repeat(MAX_COMMENT_LENGTH);
expect(() => enforceCommentLimits(body)).not.toThrow();
});
});

describe("mentions validation", () => {
it("should allow comments with no mentions", () => {
const body = "This comment has no mentions";
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should allow comments within mention limit", () => {
const body = "Hello @user1 @user2 @user3";
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should allow exactly MAX_MENTIONS mentions", () => {
const mentions = Array.from({ length: MAX_MENTIONS }, (_, i) => `@user${i}`).join(" ");
const body = `Comment with ${mentions}`;
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should reject comments exceeding mention limit", () => {
const mentions = Array.from({ length: MAX_MENTIONS + 1 }, (_, i) => `@user${i}`).join(" ");
const body = `Comment with ${mentions}`;
expect(() => enforceCommentLimits(body)).toThrow(`E002: Comment contains ${MAX_MENTIONS + 1} @mentions, maximum exceeded`);
});
});

describe("links validation", () => {
it("should allow comments with no links", () => {
const body = "This comment has no links";
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should allow comments within link limit", () => {
const body = "Check [link1](https://example.com) and [link2](https://github.com)";
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should count markdown links", () => {
const links = Array.from({ length: 5 }, (_, i) => `[link${i}](https://example${i}.com)`).join(" ");
const body = `Comment with ${links}`;
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should count bare URLs", () => {
const body = "Visit https://github.com and https://example.com for more info";
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should allow exactly MAX_LINKS links", () => {
const links = Array.from({ length: MAX_LINKS }, (_, i) => `https://example${i}.com`).join(" ");
const body = `Comment with ${links}`;
expect(() => enforceCommentLimits(body)).not.toThrow();
});

it("should reject comments exceeding link limit", () => {
const links = Array.from({ length: MAX_LINKS + 1 }, (_, i) => `https://example${i}.com`).join(" ");
const body = `Comment with ${links}`;
expect(() => enforceCommentLimits(body)).toThrow(`E002: Comment contains ${MAX_LINKS + 1} links, maximum exceeded`);
});
});

describe("error code format", () => {
it("should use E002 error code for length limit", () => {
const body = "a".repeat(MAX_COMMENT_LENGTH + 1);
expect(() => enforceCommentLimits(body)).toThrow(/^E002:/);
});

it("should use E002 error code for mention limit", () => {
const mentions = Array.from({ length: MAX_MENTIONS + 1 }, (_, i) => `@user${i}`).join(" ");
const body = `Comment with ${mentions}`;
expect(() => enforceCommentLimits(body)).toThrow(/^E002:/);
});

it("should use E002 error code for link limit", () => {
const links = Array.from({ length: MAX_LINKS + 1 }, (_, i) => `https://example${i}.com`).join(" ");
const body = `Comment with ${links}`;
expect(() => enforceCommentLimits(body)).toThrow(/^E002:/);
});
});

describe("constants", () => {
it("should export MAX_COMMENT_LENGTH as 65536", () => {
expect(MAX_COMMENT_LENGTH).toBe(65536);
});

it("should export MAX_MENTIONS as 10", () => {
expect(MAX_MENTIONS).toBe(10);
});

it("should export MAX_LINKS as 50", () => {
expect(MAX_LINKS).toBe(50);
});
});
});
});