Skip to content

fix(security): remove self-certify review bypass; fix hidden commits false positive#148

Merged
coopernetes merged 2 commits intomainfrom
fix/self-certify-bypass-bug
Apr 13, 2026
Merged

fix(security): remove self-certify review bypass; fix hidden commits false positive#148
coopernetes merged 2 commits intomainfrom
fix/self-certify-bypass-bug

Conversation

@coopernetes
Copy link
Copy Markdown
Owner

Summary

  • Critical security fix: PushFinalizerFilter and ApprovalPreReceiveHook were auto-approving pushes from users with the SELF_CERTIFY repo permission, bypassing review entirely. Self-certify is a dashboard-only flow — pushes must always enter the review queue. Introduced in 3ed0d4a, shipped in v1.0.0-beta.2.
  • Hidden commits false positive: CheckHiddenCommitsFilter was rejecting legitimate incremental pushes because the local clone's refs were stale after forwarded pushes. Fix: mark commitFrom as an explicit RevWalk boundary — the git client's assertion of the current remote tip is correct regardless of clone staleness.
  • Dashboard gate: PushController.getById now computes canCurrentUserSelfCertify server-side (pusher == current user AND ROLE_SELF_CERTIFY AND per-repo permission). PushDetail.tsx replaced the role-only check with this field so the UI banner and button match what the API enforces.
  • Defense in depth: ApprovalPreReceiveHook re-verifies the per-repo SELF_CERTIFY permission after waitForApproval returns when approver == pusher.

Test plan

  • Push with SELF_CERTIFY role + permission — push lands in PENDING, not auto-approved
  • Self-approve in dashboard — approval accepted, re-push forwarded
  • Push with SELF_CERTIFY role but no per-repo permission — dashboard 403s on approve
  • Force push (amended commit) followed by normal incremental push — no hidden commits false positive
  • CI: ApprovalPreReceiveHookTest, PushFinalizerFilterTest, PushControllerTest

coopernetes and others added 2 commits April 13, 2026 00:58
…false positive

Discovered while testing live config reload: force-pushing amended commits
was going through the proxy unchallenged. Root cause was a self-certify
bypass introduced in 3ed0d4a (feat: add SELF_CERTIFY operation, Apr 10)
and shipped in v1.0.0-beta.2. The bypass was a secondary effect: with
review skipped entirely, each force push advanced the local clone's cached
ref without a fetch, causing the next legitimate incremental push to be
falsely rejected by checkHiddenCommits (commitFrom was reachable from the
new tip but sat above the stale ref boundary, so RevWalk included it in
allNew but not in introduced).

Self-certify bypass (PushFinalizerFilter + ApprovalPreReceiveHook):
The filter chain was calling isBypassReviewAllowed() and immediately
setting result=ALLOWED, forwarding the push without review. This is wrong:
self-certify means a user may approve their own pushes in the dashboard,
not that they skip the queue. The filter has no Spring Security context
to check ROLE_SELF_CERTIFY anyway. Both the filter bypass block and the
equivalent hook block are removed. Pushes always land in REVIEW. The hook
adds a verifySelfApprovalEntitled() defense-in-depth check: after
waitForApproval returns APPROVED, if approver == pusher it re-verifies
the per-repo SELF_CERTIFY permission (role was already enforced by
PushController at approval time; hook can only verify the perm row).

Hidden commits false positive (CheckHiddenCommitsFilter):
collectAllNewCommits() built its RevWalk boundary from local clone refs,
which were stale after a forwarded push advanced the upstream. Fix: always
mark commitFrom as uninteresting before walking. commitFrom is the git
client's assertion of the current remote tip — correct by definition
regardless of clone staleness. JGit propagates the UNINTERESTING flag to
all ancestors of commitFrom, covering force-push parent chains too.
LocalRepositoryCache: always call refreshIfStale() on cache hits, not
only when credentials are supplied.

Dashboard / API:
- PushController.getById: add transient canCurrentUserSelfCertify flag
  (pusher == current user AND ROLE_SELF_CERTIFY AND per-repo permission)
- PushDetail.tsx: replace role-only isSelfCertifier check with
  record.canCurrentUserSelfCertify so banner and button match the gate
  the API actually enforces
- SELF_CERTIFY_USER_ATTR constant and PushStoreAuditFilter reference
  removed (dead after bypass removal)

Tests: drop four bypass tests in PushFinalizerFilterTest and two
SELF_CERTIFY_USER_ATTR tests in PushStoreAuditFilterTest. Add four
ApprovalPreReceiveHookTest cases for the re-fetch path and the
approver-not-pusher fast path. Add five PushControllerTest$GetById cases
for canCurrentUserSelfCertify.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Clarifies that holding the role alone is not sufficient — an admin must
also grant the per-repo Self-certify permission before self-approval works.
@coopernetes coopernetes merged commit a77adf7 into main Apr 13, 2026
11 checks passed
@coopernetes coopernetes deleted the fix/self-certify-bypass-bug branch April 13, 2026 05:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant