Fail closed approval gate authorization #1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/fail-closed-approver-permissions"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Security fix: fail-open -> fail-closed change to a production deploy gate.
Confirmed finding before fixing:
src/lib/command.jspreviously said empty approvers meant anyone except the run actor, and implementedif (approvers.length) return approvers.includes(login); if (!allowSelf && login === actor) return false; return true;.src/main.jsaccepted comments with onlyisAllowed(login, approvers, ctx.actor, allowSelf), so a matching login string was enough and repo permission was never verified.Change:
approversnow fails closed: no comment or relay approval is accepted without an explicit allowlist.GET /repos/{owner}/{repo}/collaborators/{login}/permission; onlywrite,admin, orownerpermissions may approve/deny.allow-self-approvalis enabled.approversand that listed approvers also need write/admin repo permission.Tests:
make testcovers empty approvers reject, listed non-collaborator/read-only login reject, listed write collaborator accept, admin deny, and run actor self-approval rejection.make allpassed (node --check,shellcheck, offline tests; shfmt/yq not installed so skipped as before).Caller impact:
.forgejo/workflows/test.ymldoes not call the action, and the README example already setsapprovers. I found no in-repo caller relying on the empty-approvers default.approversmust add an explicit allowlist; that is intentional for the fail-closed security change.Adversarial review: APPROVE (merge)
I did not author this. Reviewed the branch
fix/fail-closed-approver-permissionsvsmain, readsrc/lib/command.jsandsrc/main.js, and ran the suite (14/14 green locally, including the two new cases). The fail-open is closed and the permission check is real. All five verification points hold.1. Empty approvers now fails closed — confirmed
command.jsisAllowedgainedif (!approvers.length) return false;as the first list check, so an empty allowlist admits nobody. Withfail-on-timeoutdefaulting true, an unconfigured gate simply times out and fails the step. The integration testempty approvers fail closed(a validalice:write/approvewith noINPUT_APPROVERS) exits non-zero. The old "anyone except the run actor" branch is gone.2. Real repo permission, not a login-string match — confirmed
Both decision paths now go through
isAuthorizedApprover = isAllowed(...) && hasDeployPermission(...).hasDeployPermissioncallsGET /repos/{repo}/collaborators/{login}/permissionand accepts onlywrite/admin/owner(permissionAllowsDeploy), rejectingread/none. Allowlist and permission are ANDed: a listed login without write/admin is rejected (testlisted non-collaborator is rejected,mallory:read), and a login with permission but not on the list is rejected byisAllowedbefore any API call (and with an empty list, rejected outright).loginisencodeURIComponent-ed;repois the run's own trusted repo.3. Not-the-run-actor guard intact — confirmed
if (!allowSelf && login === actor) return false;is preserved. Testself-approval blocked by default(releaser:admin, listed) exits non-zero.4. No new bypass — checked
api.getis_throwing(throws on any non-2xx), andhasDeployPermission'scatchreturnsfalse. A 404 (non-collaborator), 403 (token can't read collaborators), or 5xx all reject. Every error path is fail-closed.if (d) decision = d); it is now gated by the sameisAuthorizedApprover, so a relay-suppliedapprovermust also be a permitted collaborator. This closes a second bypass beyond the reported finding.permissionAllowsDeployhandles both the Forgejo{permission: "..."}object and a bare string, and treats missing/unknown as not-allowed.5. Tests — adequate
command.test.jscovers empty-approvers → false (with and withoutallowSelf) and listed-self gating.run.shaddsempty approvers fail closedandlisted non-collaborator is rejected, and updates the existing approve/deny/self cases to seedMOCK_PERMISSIONS. 14/14 pass.Minor, non-blocking follow-ups (not merge blockers)
approvers.includes(login)is case-sensitive, but Forgejo logins are case-insensitive. A case-mismatched legitimate approver is silently rejected (fail-closed, so safe, but a config footgun) — consider normalizing case on both sides.Verdict: APPROVE for merge. Correct fail-open→fail-closed conversion, real permission enforcement, fail-closed on every error path, self-approval guard intact, relay bypass also closed, tests green. The three notes above are optional hardening.