Skip to content

gh-aw: the confused-deputy check does not cover pull_request_target, so a labeled fork PR activates the agent on the labeler's permissions

High
pelikhan published GHSA-r8gh-v7wv-8g7h Aug 16, 2026

Package

actions github/gh-aw (GitHub Actions)

Affected versions

<= 0.86.2

Patched versions

v0.87.0

Description

Summary

isConfusedDeputyAttack exists to catch the case where the actor who triggered a
workflow is not the author of the content the agent will read. It handles four
event names. pull_request_target is not one of them, and the string does not
appear anywhere in the file.

On pull_request_target: types: [labeled] the actor is the maintainer who
applied the label. The membership gate validates that maintainer, the
confused-deputy check returns false because it has no branch for the event, and
the agent then runs in the base-repo context reading a fork PR written by someone
else entirely.

The gate answers "may this person start the agent". Nobody asks "who wrote the
content the agent is about to read".

I want to put the argument plainly, because the easy reply is that the adopter
chose an untrusted trigger.

I am not arguing that actor != author is always suspicious. For pull_request
your own comment gives a good reason to tolerate it, and I agree with it. The
argument is narrower: this mechanism exists specifically to detect the
confused-deputy condition, and it omits the event where the actor/content
principal mismatch intersects with the highest-trust execution context. The check
covers four sibling events and skips that one. An adopter using a documented
trigger with a documented forks: key gets no coverage from a function written
for exactly their situation.

The distinction that matters for a fix is fork versus internal, not labeled
versus not. A PR where
pull_request.head.repo.full_name != pull_request.base.repo.full_name is
authored by someone outside the repository's trust boundary; a same-repo PR is
not, and its author already holds repository permissions. Keying on that boundary
lets you close this without disturbing the ordinary case of a maintainer
labelling an internal PR, which I expect is the false-positive you would worry
about first.

Details

actions/setup/js/check_permissions_utils.cjs, isConfusedDeputyAttack at
:107. The branches are:

  • :120 pull_request — only when action === "synchronize" and the actor ends
    with [bot]
  • :130 pull_request_review — reviewer must match the actor
  • :140 pull_request_review_comment — comment author must match the actor
  • :161 issue_comment — comment author must match the actor

Then return false.

grep -c pull_request_target on that file returns 0 at v0.85.4 and 0 on
main at b2ef1f3.

The comment at :114-116 explains the design for pull_request:

Other pull_request actions (labeled, unlabeled, assigned, review_requested,
etc.) legitimately have actor != pr_author — the actor is whoever performed the
action, not the PR author — so checking those would cause false positives.

That reasoning is sound for pull_request, which does not get repository secrets
on a fork PR. pull_request_target does. The same "actor != author is normal"
assumption is carried into an event where the content is untrusted and the token
is not, and the file never distinguishes them because it never sees the event
name at all.

check_membership.cjs:195 calls the function and, when it returns false, falls
through to checkRepositoryPermission on context.actor — the labeler.

PoC

7 assertions driving the unmodified shipped check_permissions_utils.cjs. No
network, no third-party system. Attached, runs in one command. Environment: node
18.19.1, WSL2 Ubuntu 24.04.

The payload is a labeled event where the PR author is attacker and the actor
is maintainer:

  • T1 At v0.85.4, isConfusedDeputyAttack("maintainer", "pull_request_target", …)
    returns false. The attacker-authored PR is not flagged.
  • T2 Positive controls: the same harness gets true for issue_comment
    and pull_request_review with the same author/actor mismatch. The branches
    that exist do fire, so a false negative in T1 is a real gap and not a broken
    test.
  • T3 pull_request with labeled is also not flagged, matching the
    documented tradeoff at :114-116.
  • T4 Zero occurrences of pull_request_target in the file at v0.85.4.
  • T5 Control: identical behaviour and zero occurrences on main b2ef1f3.

The full path, from event to model input

The predicate above is the defect. This is how it becomes attacker-controlled
agent input. Line numbers are from
Azure/azure-sdk-for-js/.github/workflows/mgmt-review.lock.yml at v0.85.4, which
is a compiled gh-aw workflow, plus gh-aw's own scripts.

  1. Trigger. :60-64pull_request_target, forks: ["*"],
    types: [labeled]. A fork PR is in scope by configuration.

  2. Activation gate. :113-116 — the activation job runs when
    pre_activation.outputs.activated == 'true' and
    github.event.label.name == 'mgmt-review-needed'. The condition tests the
    label, not who wrote the PR.

  3. Actor selection. check_membership.cjs:72const actor = context.actor.
    On a labeled event GitHub sets that to the account that applied the label,
    so it is the maintainer.

  4. The defective check. check_membership.cjs:195 calls
    isConfusedDeputyAttack(actorToValidate, eventName, context.payload). With
    eventName === "pull_request_target" none of its four branches match and it
    returns false.

  5. Authorization. :216 falls through to
    checkRepositoryPermission(actorToValidate, …), which validates the
    maintainer against admin,maintainer,write. It passes.
    :1723 sets activated: steps.check_membership.outputs.is_team_member == 'true'.

  6. Agent starts. :465 — the agent job declares needs: activation and
    runs. Its permissions are actions: read, contents: read,
    copilot-requests: write, pull-requests: read.

  7. The PR is named to the model. :349-351 writes
    - **pull-request-number**: #… into a <github-context> block in the prompt,
    from GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER (:307).

  8. The model is given the means to fetch it. :361 appends
    github_mcp_tools_with_safeoutputs_prompt.md, and the workflow runs
    ghcr.io/github/github-mcp-server:v1.8.0 with GITHUB_PERSONAL_ACCESS_TOKEN
    set from ${GITHUB_MCP_SERVER_TOKEN}, itself
    secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN (:241, :578).

  9. The retrieval capability is configured, not assumed. The same MCP server
    block at :874-883 sets:

    "GITHUB_READ_ONLY": "1",
    "GITHUB_TOOLSETS": "context,repos,pull_requests,actions"

    The pull_requests and repos toolsets are what expose pull request
    retrieval — the PR itself, its diff, its files, its comments, and repository
    contents. So the agent is not merely told a PR number; it is given, by
    configuration, the tools to fetch that PR's contents. Every one of those
    fields is authored by the fork author.

Steps 1-9 are all present in the compiled file and the shipped scripts. What I
have not observed is the model actually issuing those tool calls at runtime,
because observing that means running the workflow. That step is a design
expectation rather than a measurement: the workflow is a PR reviewer, the prompt
injects the PR number for that purpose, and the toolsets to read it are enabled.

The attacker does not need the labeler. They do not obtain the membership
permission, impersonate the maintainer, or persuade the maintainer to run
anything they supplied. The maintainer's ordinary act of applying the documented
triage label supplies the authorization signal; the attacker's fork supplies the
content the privileged agent then reads. That is why "a maintainer has to label
it" is not a mitigation — labelling is what triage is.

The privilege difference is the point of pull_request_target. On a fork PR
under plain pull_request, GitHub withholds repository secrets and issues a
read-only token, so the same agent would have neither
GH_AW_GITHUB_MCP_SERVER_TOKEN nor COPILOT_GITHUB_TOKEN (both listed in the
manifest at :2). Under pull_request_target the job runs in the base
repository's context and those secrets are present. The attacker's content is
therefore processed by an agent holding credentials their pull request could not
otherwise reach.

Where this is reachable

The clearest real-world instance is
Azure/azure-sdk-for-js/.github/workflows/mgmt-review.lock.yml, which is the
configuration the previous section walks through line by line. In one file it
combines pull_request_target, forks: ["*"], types: [labeled], a membership
gate, base-repository credentials, and a PR-review agent with the
pull_requests toolset enabled. Six sibling workflows in the same repository are
identical apart from the integrity level.

For prevalence rather than proof: I enumerated gh-aw workflows using
pull_request_target through public code search, read-only. 64 compiled
workflows match that trigger overall. The table below is a subset — the 36 I
checked in well-known orgs that also have a trigger type where the actor is not
the PR author. I did not triage the remaining 28, which are mostly small or
personal repositories, and I am not asserting they are all affected.

Repository Workflows Notes
Azure/azure-sdk-for-js 7 forks: ["*"], cache-memory on, v0.85.4
Azure/azure-rest-api-specs 3 forks: ["*"], cache-memory on, v0.86.2
Azure/azure-sdk-for-go, azure-sdk-for-python 2 v0.81.6 / v0.85.4
dotnet/runtime 1 closed/labeled
elastic/kibana 4 v0.81.6
elastic/terraform-provider-elasticstack 1 v0.84.3
drasi-project/{core,platform,server} 18 v0.71.5

Azure/azure-sdk-for-js/.github/workflows/mgmt-review.lock.yml is the clearest
instance: pull_request_target: types: [labeled], forks: ["*"], cache-memory
enabled with key memory-none-nopolicy-…, and GH_AW_MIN_INTEGRITY: none. Six
sibling workflows in the same repo are identical at unapproved.

What each class of evidence in this report is:

  • T1-T5 — run locally against your shipped implementation at v0.85.4 and
    main. Demonstrated.
  • The path from event to model input — static reading of a compiled workflow
    and your scripts, with line references. Steps 1-8 are present in the files;
    step 9, the model actually fetching the PR, I have not observed.
  • The reachability table — static analysis of public workflow files via code
    search.
  • Impact — the consequence of the authorization and data-flow behaviour
    above.
  • No exploitation was performed against any third-party repository. I did not
    open a pull request, apply a label, or trigger a workflow anywhere.

Impact

An attacker opens a fork PR. A maintainer applies the triage label the workflow
watches for. The agent then starts in the base repository's context with
credentials the fork PR could not otherwise obtain, holding the PR number and the
pull_requests/repos toolsets needed to retrieve that PR's content.

Stated as an evidence hierarchy, so the line is unambiguous:

  1. Established. The authorization decision is made on the labeler while the
    content principal is the PR author, and the check meant to catch that
    mismatch does not model this event.
  2. Established. The resulting agent runs with base-repository credentials
    (GH_AW_GITHUB_MCP_SERVER_TOKEN, COPILOT_GITHUB_TOKEN) that a fork PR does
    not receive under plain pull_request, and is configured with the toolsets to
    read the pull request.
  3. Consequence. Attacker-authored PR content can therefore influence an agent
    operating with those credentials — prompt injection against a privileged
    consumer.
  4. Not demonstrated here. I have not observed the model issuing the retrieval
    calls, and I have not shown a concrete credential-abuse or code-execution
    chain. Possessing credentials is not the same as an attacker directing their
    use, and I am not claiming the latter.

This finding stands on its own. Separately, where cache-memory is enabled it may
compose with the cache-memory git filter-driver issue I reported earlier, but
that chain is not part of this report and nothing here depends on it.

The label is applied during ordinary triage. Nobody involved is making a security
decision, which is what makes this a confused deputy rather than a social
engineering problem.

Suggested fix

The invariant I would ask for, rather than a specific patch: on
pull_request_target, the authorization decision must take the pull request
author's identity into account, not only the event actor.
How you enforce that
is yours to choose. Three options, in case they are useful:

  1. Add a pull_request_target branch to isConfusedDeputyAttack comparing
    payload.pull_request.user.login to the actor. Smallest change. If you are
    concerned about legitimate cases where a maintainer deliberately labels
    someone else's PR to invoke the agent — which is a real workflow — this alone
    may be too blunt; gating on the fork boundary above avoids that. Two edge
    cases worth handling either way: a deleted author account can surface as
    ghost or as a missing user object, and the existing branches already guard
    with !== undefined for that reason, so the new one should too.
  2. Treat the PR author as the subject for integrity purposes on this event, so a
    fork-authored PR is handled at the integrity level its author warrants rather
    than the labeler's. This fits the model you already have and does not require
    refusing the workflow.
  3. Refuse activation on pull_request_target at min-integrity: none or
    unapproved when actor != PR author, and require an explicit opt-in for
    adopters who want the current behaviour.

Whatever the mechanism, the gap to close is that the security decision currently
depends on one principal while the content comes from another.

Coordinated disclosure

No public issue or PR, and no third-party repository was tested. The suite runs
locally against your shipped scripts at the v0.85.4 and main refs. The adopter
list above came from read-only public code search. Standard 90-day window unless
we agree otherwise. No bounty requested; credit welcome if appropriate.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Unintended Proxy or Intermediary ('Confused Deputy')

The product receives a request, message, or directive from an upstream component, but the product does not sufficiently preserve the original source of the request before forwarding the request to an external actor that is outside of the product's control sphere. This causes the product to appear to be the source of the request, leading it to act as a proxy or other intermediary between the upstream component and the external actor. Learn more on MITRE.

Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. Learn more on MITRE.

Credits