fix(prd-7): M3 security review — self-trigger guard, actor permissions, trigger bypass, platform validation

- Add self-mention/bot loop prevention in actor.ts (blocks actor matching bot_name)
- Switch Forgejo permission check to actor-level via getCollaboratorPermissionLevel
  with fallback to token-level repos.get() when collaborator API returns 403
- Close empty-body PR review comment trigger bypass (now returns false instead of true)
- Add platform detection cross-validation (reject PLATFORM=forgejo on github.com)
- Document URL builder defense-in-depth rationale in comment-logic.ts
- Update/add tests across 6 test files (actor, permissions, trigger, platform, mcp, m7)

Progress: 50% complete (3/6 milestones) — next: M4 test coverage review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad MocanuandClaude Opus 4.6 committed 2026-02-26 12:31:12 +02:00
1 parent cefc229199
commit 6b0302d226
12 files changed
+270 -68

No files matched your search

+6
View File
@@ -51,6 +51,12 @@ export const FORGEJO_SPINNER_HTML = "⏳";
// URL Builders
// ---------------------------------------------------------------------------
// Note: owner, repo, runId, and branchName are NOT URI-encoded in path
// segments because: (1) owner/repo are validated by Forgejo (alphanumeric +
// .-_), (2) branchName is validated by validateBranchName() which restricts
// to [a-zA-Z0-9._/-], and (3) encoding "/" in branch names breaks Forgejo
// URLs (e.g., "claude/fix-bug" → "claude%2Ffix-bug" is invalid).
export function createForgejoJobRunLink(
owner: string,
repo: string,
+12 -2
View File
@@ -14,10 +14,20 @@ export async function checkHumanActor(
githubContext: GitHubContext,
) {
// Forgejo doesn't have GitHub-style bot/app accounts, and the user API
// requires read:user scope which may not be available. Skip entirely.
// requires read:user scope which may not be available. Skip the bot-type
// check but still guard against self-triggering (infinite loop prevention).
if (detectPlatform() === "forgejo") {
const botName = githubContext.inputs.botName.replace(/\[bot\]$/, "");
if (
githubContext.actor === botName ||
githubContext.actor === githubContext.inputs.botName
) {
throw new Error(
`Blocking self-trigger on Forgejo: actor '${githubContext.actor}' matches bot_name '${githubContext.inputs.botName}'. This prevents infinite loops.`,
);
}
console.log(
`Skipping human actor check on Forgejo for: ${githubContext.actor}`,
`Forgejo actor check passed for: ${githubContext.actor} (bot_name: ${githubContext.inputs.botName})`,
);
return;
}
+40 -5
View File
@@ -83,16 +83,51 @@ export async function checkWritePermissions(
}
/**
* Forgejo permission check using the repo endpoint.
* GET /repos/{owner}/{repo} returns a `permissions` object for the
* authenticated token with { admin, push, pull } booleans. We check
* that the token has push (write) access.
* Forgejo permission check using the collaborator permission endpoint.
*
* Primary: GET /repos/{owner}/{repo}/collaborators/{username}/permission
* Returns { "permission": "admin"|"write"|"read"|"none" } — checks the
* actor's permission, not the token's.
*
* Fallback: GET /repos/{owner}/{repo} — checks the token's permission
* (less precise but works if the collaborator endpoint returns 403).
*/
async function checkForgejoPermissions(
octokit: Octokit,
repository: { owner: string; repo: string },
actor: string,
): Promise<boolean> {
// Try actor-level permission check first (requires repo admin or token with
// sufficient scope to read collaborators).
try {
const response = await octokit.repos.getCollaboratorPermissionLevel({
owner: repository.owner,
repo: repository.repo,
username: actor,
});
const permissionLevel = response.data.permission;
core.info(`Forgejo actor '${actor}' permission level: ${permissionLevel}`);
if (permissionLevel === "admin" || permissionLevel === "write") {
return true;
}
core.warning(
`Forgejo actor '${actor}' has insufficient permissions: ${permissionLevel}`,
);
return false;
} catch (error) {
// Forgejo may return 403 if the token lacks admin scope. Fall back to
// token-level check and log a warning about the reduced security.
core.warning(
`Forgejo collaborator permission check failed for '${actor}' (${error instanceof Error ? error.message : String(error)}). ` +
`Falling back to token-level permission check. ` +
`For actor-level checks, use a token with read:organization scope.`,
);
}
// Fallback: check the token's own permission on the repo
const response = await octokit.repos.get({
owner: repository.owner,
repo: repository.repo,
@@ -111,7 +146,7 @@ async function checkForgejoPermissions(
if (permissions.admin || permissions.push) {
core.info(
`Forgejo token has write access (admin=${permissions.admin}, push=${permissions.push}) for actor: ${actor}`,
`Forgejo token has write access (admin=${permissions.admin}, push=${permissions.push}) for actor: ${actor} (token-level fallback)`,
);
return true;
}
+7 -5
View File
@@ -145,18 +145,20 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
return true;
}
// On Forgejo, if no body was found in the payload at all, still trigger —
// the actual comment data will be fetched via REST in the data fetcher.
// This handles edge cases where Forgejo strips body from the webhook payload.
// On Forgejo, pull_request_review_comment payloads may arrive with an
// empty body. Rather than triggering unconditionally (which bypasses the
// trigger phrase check), we skip. The user can re-trigger with a comment
// that includes the trigger phrase. This prevents Claude from responding
// to comments not directed at it.
if (
isPullRequestReviewCommentEvent(context) &&
detectPlatform() === "forgejo" &&
!commentBody
) {
console.log(
"Forgejo pull_request_review_comment: no body in payload, triggering (data fetched via REST)",
"Forgejo pull_request_review_comment: no body in payload, skipping (cannot verify trigger phrase)",
);
return true;
return false;
}
}
+11 -1
View File
@@ -23,7 +23,17 @@ export function detectPlatform(): Platform {
// 1. Explicit platform from action.yml detect step or user input
const platformEnv = process.env.PLATFORM;
if (platformEnv === "forgejo") {
result = "forgejo";
// Cross-validate: prevent forcing Forgejo mode on GitHub.com, which would
// skip actor validation and use a weaker permission model.
const serverUrl = process.env.GITHUB_SERVER_URL || "";
if (serverUrl.includes("github.com")) {
console.warn(
`Platform override 'forgejo' ignored: GITHUB_SERVER_URL (${serverUrl}) indicates GitHub.com. ` +
`Falling through to standard detection.`,
);
} else {
result = "forgejo";
}
} else if (platformEnv === "github") {
result = "github";
}