Coordinated Disclosure Timeline

Summary

The marimo bot workflow lets an authorized maintainer start a test release from a pull request comment. It tries to reject pull request updates made after that comment by comparing the head repository’s pushed_at timestamp with the comment’s created_at timestamp. Both timestamps have one-second precision, and the check rejects only a push that compares strictly later, so a push made in the same second as the authorizing comment compares equal and is accepted. The workflow then checks out that attacker-controlled commit and executes a local composite action and build scripts from it. The resulting build outputs are uploaded as artifacts and consumed, without rebuilding, by downstream jobs that publish to TestPyPI and npm using OIDC. A pull request author who wins the same-second race can therefore execute arbitrary code in the base repository’s workflow and place attacker-controlled content into packages published from it.

Project

marimo-team/marimo repository

Tested Version

Observed on the default branch at commit b4181abaf6bd896b6b0dac5490c9cf08269b590c.

Details

Same-second TOCTOU in marimo-bot.yml permits attacker-controlled artifacts to reach privileged package-publishing jobs (GHSL-2026-226)

The marimo bot workflow runs on created issue comments and gates the create-test-release job on the commenter’s association and on a command phrase:

on:
  issue_comment:
    types: [created]

jobs:
  create-test-release:
    if: >
      (
        github.event.comment.author_association == 'OWNER' ||
        github.event.comment.author_association == 'COLLABORATOR' ||
        github.event.comment.author_association == 'MEMBER'
      ) &&
      github.event.issue.pull_request &&
      contains(github.event.comment.body, '/marimo create-test-release')

The association check authenticates the person invoking the command, but it does not bind that authorization to a particular pull request commit.

To reject a push made after the authorizing comment, the job fetches the pull request’s current head SHA and the head repository’s pushed_at timestamp and compares it against the comment’s created_at timestamp:

- name: 📝 Get PR Info
  id: pr
  env:
    PR_NUMBER: ${{ github.event.issue.number }}
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    GH_REPO: ${{ github.repository }}
    COMMENT_AT: ${{ github.event.comment.created_at }}
  run: |
    pr="$(gh api /repos/${GH_REPO}/pulls/${PR_NUMBER})"
    head_sha="$(echo "$pr" | jq -r .head.sha)"
    pushed_at="$(echo "$pr" | jq -r .head.repo.pushed_at)"

    # NB mitigate potential race condition.
    if [[ $(date -d "$pushed_at" +%s) -gt $(date -d "$COMMENT_AT" +%s) ]]; then
        echo "Updating is not allowed because the PR was pushed to (at $pushed_at) after the triggering comment was issued (at $COMMENT_AT)"
        exit 1
    fi

    echo "head_sha=$head_sha" >> $GITHUB_OUTPUT

Both values are GitHub API timestamps with one-second precision. If the pull request author pushes in the same second as the authorizing comment, both date conversions to epoch seconds produce the same value. Because the comparison uses strict greater-than (-gt) rather than rejecting equality, the push is not treated as later, and the attacker’s new commit is recorded in head_sha.

The workflow then checks out exactly that SHA:

- name: ⬇️ Checkout repo
  uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
  with:
    fetch-depth: 0
    ref: ${{ steps.pr.outputs.head_sha }}

Because head_sha was read after the race, the checkout faithfully reproduces the attacker’s commit rather than the commit the maintainer intended to release.

The job then executes code from that untrusted checkout. It loads a local composite action from the checked-out tree and runs repository build scripts:

- name: 📦 Build frontend
  uses: ./.github/actions/build-frontend

- name: Adapt pyproject.toml to build marimo-base
  run: uv run ./scripts/modify_pyproject_for_marimo_base.py

- name: 📦 Build marimo
  run: uv build

- name: 📦 Validate wheel under 2mb
  run: ./scripts/validate_base_wheel_size.sh

./.github/actions/build-frontend is resolved from the workspace, so its definition comes from the pull request head and the author can make it run arbitrary commands. ./scripts/modify_pyproject_for_marimo_base.py, ./scripts/validate_base_wheel_size.sh, and the build backend invoked by uv build are likewise attacker-controlled.

The outputs of that build are uploaded as artifacts:

- name: 📦 Upload wheel artifact for TestPyPI publish
  uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
  with:
    name: wheel-test-release
    path: dist/

- name: 📦 Upload frontend artifact for npm publish
  uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
  with:
    name: frontend-test-branch
    path: frontend/

Two downstream jobs consume those artifacts without rebuilding them from a trusted revision. The TestPyPI job downloads wheel-test-release and publishes it with id-token: write:

publish-test-release:
  needs: create-test-release
  environment: testpypi
  permissions:
    contents: read
    id-token: write
  steps:
    - name: ⬇️ Download dist artifact
      uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
      with:
        name: wheel-test-release
        path: dist/

    - name: 📤 Upload to TestPyPI
      uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1

The npm job passes frontend-test-branch to a reusable workflow:

publish_wasm_test:
  needs: create-test-release
  uses: ./.github/workflows/publish-npm.yml
  with:
    package-artifact-name: frontend-test-branch
    working-directory: '.'
    npm-tag: 'dev'
  permissions:
    id-token: write  # Required for OIDC
    contents: read

That reusable workflow downloads the artifact and publishes it:

- name: 📥 Download built package artifact
  uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
  with:
    name: ${{ inputs.package-artifact-name }}
    path: ${{ inputs.working-directory }}

- name: 📤 Publish to npm
  run: |
    ...
    npm publish --access public --provenance $TAG_FLAG && break || {

Workflow definitions for issue_comment runs are read from the default branch, so the publishing job definitions themselves are not attacker-modifiable. The artifact content they consume is, however, produced entirely by attacker-controlled build code and is neither rebuilt nor verified before publication.

For the npm path the exposure is not limited to publishing attacker-chosen content: the artifact is unpacked into the publish step’s working directory and npm publish runs without --ignore-scripts, so lifecycle scripts from the attacker-controlled package.json execute in a job holding id-token: write.

A representative exploit sequence is:

  1. An external contributor opens a pull request whose current head looks appropriate for a test release.
  2. An OWNER, COLLABORATOR, or MEMBER comments /marimo create-test-release.
  3. The attacker polls for the comment and pushes a malicious commit in the same second it is created.
  4. The job reads the pull request after that push. The timestamps compare equal, the -gt guard accepts the update, and the malicious SHA is recorded and checked out.
  5. The attacker’s local action and build scripts run in the base repository’s job, and the poisoned wheel and frontend artifacts flow into the TestPyPI and npm publishing jobs.

The same-second race is practical, not theoretical: the identical pushed_at versus comment created_at comparison in the jupyterlab/maintainer-tools update-snapshots-checkout action was exploited in several successful proof-of-concept runs and fixed in GHSA-wwhg-p79f-vfvm (CVE-2026-84973). Winning the race is not guaranteed on any single attempt, because a comment created near the end of a second leaves too little time to push, but the attacker can retry across pull requests and command invocations.

Impact

An external pull request author who wins the race gains arbitrary code execution in the create-test-release job, whose GITHUB_TOKEN is limited to contents: read and pull-requests: write.

The attacker also controls the wheel-test-release and frontend-test-branch artifacts, which are consumed unmodified by jobs holding id-token: write. Attacker-chosen content can therefore be published to TestPyPI and to npm, and because the npm publish uses --provenance the release carries a provenance attestation generated by marimo’s own workflow. On the npm path the attacker additionally gains code execution in the publishing job itself, since npm publish runs lifecycle scripts from the artifact.

The npm publishing path has no environment: and is therefore not subject to environment protection rules. The TestPyPI job declares environment: testpypi; if that environment is configured with required reviewers, publication there would require an additional approval, which we cannot determine from public repository data.

CWEs

Resources

Credit

This issue was discovered and reported by GHSL team member @JarLob (Jaroslav Lobačevski).

Contact

You can contact the GHSL team at securitylab@github.com. Please include a reference to GHSL-2026-226 in any communication regarding this issue.