Skip to content

Incomplete remediation of fork-status bypass: PullApprove authority is not bound to the trusted GitHub App installation #3987

Description

@blocksifrdev

Incomplete remediation of fork-status bypass: PullApprove authority is not bound to the trusted GitHub App installation

Summary

This report describes a potential incomplete remediation of the status-check spoofing vulnerability addressed by #3746.

PR #3746 added origin validation to unified-status-check after identifying that status checks associated with fork repositories could influence the unified status calculation.

The remediation establishes a strong repository-origin check for CheckRun objects.

For legacy StatusContext objects, however, the trust decision is different. The implementation relies on the status creator login being in a hardcoded trusted list, together with a targetUrl check intended to reject statuses pointing to a fork.

For PullApprove, this means a status can be accepted based on the global bot identity pullapprove[bot] without establishing that the status originated from the specific trusted PullApprove GitHub App installation authorized for the upstream repository.

The security distinction is:

Recognizing the integration identity is not equivalent to proving the authority of the installation that produced the status.

I reported this to Google under reference 559906863. Google has authorized publication of the technical details and reproduction evidence in the public GitHub issue and indicated that classification depends on OSS maintainer assessment.

Relationship to #3746

PR #3746 is titled:

fix(github-actions): prevent status check bypass via fork spoofing

Its description states that the previous unified-status-check behavior constituted a security vulnerability.

The remediation added:

  • headRepository
  • checkSuite.repository for CheckRun
  • creator.login and targetUrl for StatusContext

The resulting security model is materially different between the two GitHub result types.

CheckRun

The remediation validates repository provenance through:

checkSuite.repository

This binds the check to repository origin.

StatusContext

The legacy status path instead determines trust using the creator identity and, for fork PRs, characteristics of targetUrl.

Trusted status creators include identities such as:

  • github-actions
  • angular-robot
  • ngbot
  • pullapprove

and corresponding [bot] variants.

For PullApprove, this establishes that the status appears to have been created by the PullApprove integration identity.

It does not establish which PullApprove installation had authority to produce the status.

That distinction is the subject of this report.

Code reviewed

The analysis was performed against:

angular/dev-infra@183403ae13b785698eaf13c819dda55b9fed430b

Relevant implementation:

github-actions/unified-status-check/lib/pull-request.ts

The downstream consumer examined was Angular's Merge Ready workflow in:

angular/angular/.github/workflows/merge-ready-status.yml

The purpose of this report is not to claim that every status accepted by this path is attacker-controlled.

The narrower question is whether the trust predicate itself proves the authority that #3746 intended to establish.

Trust invariant

For a status that contributes to a security-sensitive aggregate decision, the required invariant should be equivalent to:

A successful status is accepted only when its provenance is bound to an integration installation authorized to produce that status for the protected repository.

The current legacy StatusContext predicate establishes something weaker:

A successful status is accepted when its creator login matches a trusted integration identity and the applicable target URL heuristic does not identify it as originating from the fork.

These are not equivalent predicates.

A GitHub App identity is global.

A GitHub App installation represents scoped authority.

Trusting the former does not, by itself, establish the latter.

Deterministic reproduction

I constructed a local deterministic model of the relevant trust predicate.

No Angular production state was modified.

The model contains two distinct GitHub App installations:

Trusted upstream installation:
installation_id = 111111
creator.login   = pullapprove[bot]

Separate installation:
installation_id = 222222
creator.login   = pullapprove[bot]

Both installations legitimately share the same global GitHub App bot identity:

pullapprove[bot]

The test status from the second installation uses:

context = pullapprove
state   = success
creator = pullapprove[bot]

and a provider-hosted target URL that does not identify the attacker's fork repository.

Existing trust predicate

Under the modeled StatusContext predicate derived from the implementation, the second-installation fixture is accepted.

Result:

other installation status: ACCEPT

Installation-bound predicate

The same fixture is then evaluated with installation identity included as an authority requirement.

Expected trusted installation:

111111

Observed installation:

222222

Result:

other installation status: REJECT

The deterministic harness exited successfully and did not interact with or modify Angular production infrastructure.

The reproduction demonstrates the predicate gap:

same bot identity != same installation authority

Why creator.login is insufficient

The value:

pullapprove[bot]

answers:

Which GitHub App identity created this status?

It does not necessarily answer:

Was this produced under the installation authorized to act for the protected Angular repository?

Those are different authorization questions.

If multiple installations of the same GitHub App can produce legacy commit statuses carrying the same creator identity, then a creator allowlist authenticates the application identity but does not independently establish repository-scoped authority.

Why the targetUrl check does not close the same boundary

The targetUrl mitigation introduced in #3746 addresses the previously demonstrated fork-origin condition by identifying statuses whose target URL points toward the fork repository.

That is useful provenance information.

It is not equivalent to installation authentication.

A provider-hosted status URL can identify the service that produced a result without cryptographically or structurally establishing that the particular service installation was authorized for the upstream repository.

The questions are different:

Does this URL appear to originate from the fork?

versus:

Was the principal producing this status authorized by the upstream repository?

The first does not necessarily prove the second.

Expected behavior

A security-sensitive status consumed by unified-status-check should have provenance sufficient to establish its authority for the protected repository.

Where GitHub exposes repository or installation provenance, the decision should bind to that provenance rather than relying solely on a globally shared bot login.

Conceptually:

trusted app identity
AND
trusted installation/repository authority
AND
expected status context
AND
expected commit

rather than:

trusted creator login
AND
target URL does not look like the fork

Actual behavior demonstrated by the model

For the evaluated legacy StatusContext path, the trust decision does not identify the installation that conferred authority.

Consequently, two modeled statuses can differ in installation authority while presenting the same attributes used by the existing trust predicate.

The existing predicate accepts the second-installation fixture.

An installation-bound predicate rejects it.

Security consequence

The demonstrated issue is an authorization/provenance gap in the legacy status trust predicate.

If a status produced through another installation of a trusted integration is reachable on a commit evaluated by the Angular workflow, the current predicate does not itself distinguish that status from one produced under the intended installation based on installation authority.

Because the status participates in unified-status-check, that creates a potential path for an improperly authorized status to influence Angular's aggregate Merge Ready decision.

This report does not claim that I demonstrated:

  • an unauthorized merge into Angular
  • arbitrary code execution
  • access to Angular secrets
  • production compromise
  • release compromise
  • cross-installation production reachability

Those consequences have not been established by this reproduction.

The demonstrated result is narrower and precedes those consequences: the authorization predicate accepts modeled provenance that differs in the authority dimension the predicate does not inspect.

Why this appears to be incomplete remediation rather than an unrelated issue

#3746 explicitly addressed trust in status-check provenance.

Its remediation correctly introduced repository-origin validation for CheckRun.

For legacy StatusContext, the remediation instead uses an identity allowlist and URL heuristic.

The original vulnerability class was therefore addressed at one provenance layer, but the legacy status path appears to retain a weaker authority model.

The residual question is not whether the creator claims to be PullApprove.

The residual question is:

Which PullApprove authority produced the status, and was that authority authorized for the protected repository?

Requested maintainer assessment

I would appreciate maintainer guidance on the following:

  1. Is a legacy StatusContext created by pullapprove[bot] guaranteed by GitHub or PullApprove to have originated only from the PullApprove installation authorized for the upstream Angular repository?

  2. If so, what platform or application invariant provides that guarantee?

  3. Does the GraphQL representation consumed by unified-status-check expose sufficient repository, application, or installation provenance to bind the status to the intended authority?

  4. If installation provenance is unavailable for legacy commit statuses, should PullApprove's legacy StatusContext be considered sufficiently authoritative for a security-sensitive Merge Ready aggregation?

  5. Would migration to an installation-bound CheckRun or another repository-bound signal be the preferred remediation?

Suggested remediation direction

I am intentionally not prescribing an implementation before maintainers confirm the platform guarantees.

The security property should be:

status identity + repository-scoped authority

not merely:

status identity

Possible remediation classes include:

  • consume a result type carrying repository-bound provenance
  • validate GitHub App installation/repository authority where that information is available
  • stop treating legacy statuses lacking sufficient provenance as authoritative for the aggregate decision
  • migrate the PullApprove contribution to a provenance model equivalent to the CheckRun validation introduced in fix(github-actions): prevent status check bypass via fork spoofing #3746

The appropriate implementation depends on GitHub and PullApprove's actual guarantees.

Verification criterion

A regression test should contain two status producers sharing the same trusted application identity but possessing different repository/install authority.

The test should establish:

authorized installation + expected status -> ACCEPT
different installation + same bot identity + same context -> REJECT

Changing only the provider-hosted URL should not substitute for proving repository-scoped authority.

Disclosure

This issue corresponds to Google report:

559906863: Incomplete remediation of Angular fork-status bypass: PullApprove authority is not bound to the trusted GitHub App installation

Google has explicitly permitted the technical details and reproduction evidence to be shared in the public GitHub issue and has indicated that status and classification will depend on assessment and feedback from the OSS maintainers.

I am filing this publicly pursuant to that direction and would appreciate maintainer technical assessment of the authority invariant described above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions