Skip to content

A release script pastes the git tag name straight into a Python program #150

Description

@macanderson

The problem

.github/scripts/wait-for-crate.sh builds a Python program by pasting a git tag
name into its source text:

for attempt in $(seq 1 "$max_attempts"); do
  if curl -fsSL "$url" 2>/dev/null | python3 -c "
import json, sys

target = '$version'
for line in sys.stdin:

"; then

$version is unquoted, inside a single-quoted Python string literal, inside a
double-quoted shell string. The shell expands it before Python ever sees it.

.github/workflows/release.yml calls it with a tag:

        run: ./.github/scripts/wait-for-crate.sh contextgraph-types "${GITHUB_REF_NAME#contextgraph-v}"

so $version is whatever follows contextgraph-v in a pushed tag name. Git tag
names accept apostrophes, newlines, and almost every other character.

Two outcomes, depending on the tag:

  • A tag like contextgraph-v1.0'x closes the Python literal and the script dies
    with a SyntaxError — a confusing release failure with no hint of the cause.
  • A tag crafted with a newline and a statement runs arbitrary Python in the
    job that holds CARGO_REGISTRY_TOKEN
    , which is exported into the same job's
    environment for the four cargo publish steps.

The same script is also called for contextgraph-host, so the pattern repeats.

Pushing a tag needs write access to the repository, so this is not
attacker-from-the-internet. It is still the wrong shape in the one job that
holds a publishing credential, and it is a two-line fix.

The fix shape

Pass the value through the environment instead of the source text:

if VERSION="$version" curl -fsSL "$url" 2>/dev/null | python3 -c "
import json, os, sys
target = os.environ['VERSION']

"; then

The script already uses $crate and $version safely everywhere else — they
are quoted shell expansions in printf, [[ ]] and echo. This one site is
the only place either value crosses into another language.

A second, unrelated defect in the same release path

release.yml's preflight job runs:

      - run: cargo publish --dry-run -p contextgraph-types

and the publish job runs:

        run: cargo publish -p contextgraph-types --locked

The preflight is allowed to update Cargo.lock; the real publish is not. So a
lockfile out of sync with Cargo.toml gives a green preflight and then fails at
the first step of the publish job — after a human has spent the approval click
the workflow header describes. Adding --locked to the dry run makes the
preflight test what the publish will actually do.

What I verified vs. inferred

Verified by reading, on origin/main at a01ca64:

  • .github/scripts/wait-for-crate.sh's python3 -c "…target = '$version'…".
  • release.yml's two call sites passing "${GITHUB_REF_NAME#contextgraph-v}".
  • CARGO_REGISTRY_TOKEN being set on the four cargo publish steps in the same
    job as the two wait-for-crate.sh steps.
  • The --dry-run / --locked asymmetry between the two jobs.

Inferred, not verified: the exact tag string that would achieve execution
rather than a syntax error. I did not construct one — the quoting is
unambiguous, and building a working exploit is not needed to justify the fix.

What "done" looks like

  • wait-for-crate.sh passes the crate name and version to Python through
    os.environ, or through jq --arg, rather than through string
    interpolation. Nothing in the script's behaviour changes for a well-formed
    tag.
  • Witness: run
    ./.github/scripts/wait-for-crate.sh contextgraph-types "1.0'x" 1 0
    and confirm it fails cleanly with "not visible on the sparse index" rather
    than with a Python SyntaxError.
  • release.yml's preflight dry run uses --locked, matching the publish.

Constraints

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1This cycle

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions