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:
1 parent
cefc229199
commit
6b0302d226
12 files changed
+270
-68
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user