Skip to content

fix(cli): run git without a shell when installing plugins - #2498

Open
gl0bal01 wants to merge 1 commit into
jackyzha0:v5from
gl0bal01:fix/plugin-installer-command-injection
Open

gl0bal01 wants to merge 1 commit into
jackyzha0:v5from
gl0bal01:fix/plugin-installer-command-injection

Conversation

@gl0bal01

Copy link
Copy Markdown

Problem

quartz/cli/plugin-git-handlers.js builds shell command strings from values that come out of quartz.config.yaml and quartz.lock.json:

await execAsync(`git clone --depth 1${branchArg} "${entry.resolved}" "${pluginDir}"`)
await execAsync(`git checkout ${entry.commit}`, { cwd: pluginDir })
await execAsync(`git reset --hard ${resetTarget}`, { cwd: pluginDir })
await execAsync(`git ls-remote "${row.entry.resolved}" ${lsRemoteRef}`)

The repository URL is double-quoted, but $(...) and backticks still expand inside double quotes, and the ref, commit, and --branch argument are not quoted at all. A crafted entry therefore runs arbitrary commands on whatever machine performs the install — a contributor's laptop, or CI.

Reproduction (on v5, before this change)

Install any plugin, then set a ref in quartz.lock.json:

"spacer": { "ref": "$(touch /tmp/quartz-pwned)", ... }
$ npx quartz plugin install
  → spacer: cloning...
  ✗ spacer: failed to clone
$ ls /tmp/quartz-pwned
/tmp/quartz-pwned

The clone reports failure and the injected command runs anyway, so nothing in the output signals what happened.

I hit this while upgrading a fork to v5; happy to adjust the approach if you'd prefer it shaped differently.

Fix

  • every git call goes through execFile with an argv array, so no shell is involved
  • values still passed to git are validated: commits must be hex (/^[0-9a-f]{7,40}$/i), refs must look like refnames and may not contain .., and a repository may not begin with -
  • clones pass -- before <repository>, so a URL can never be parsed as a flag such as --upload-pack=<cmd>
  • npm install <pkg> for npm-sourced plugins gets the same argv treatment

With the patch, the reproduction above fails the clone and creates no file.

Testing

  • npm test — 109/109 pass
  • npx prettier --check — clean
  • exercised against a real repository: plugin add, plugin install, plugin update, plugin check all still clone, build, and report correctly

Plugin sources, refs, and commits from quartz.config.yaml and
quartz.lock.json were interpolated into shell command strings. Quoting
the URL is not sufficient — $(...) and backticks still expand inside
double quotes — and refs, commits, and the --branch argument were not
quoted at all, so a crafted lockfile entry ran arbitrary commands on the
machine doing the build.

Reproduction on v5 before this change, with a lockfile ref of
"$(touch /tmp/quartz-pwned)": `quartz plugin install` reports the clone
as failed and creates /tmp/quartz-pwned anyway.

Every git invocation now goes through execFile with an argv array, so no
shell is involved. Values that still reach git as arguments are
validated: commits must be hex, refs must look like refnames, and a
repository may not begin with "-". Clones also pass "--" before the
repository so a URL cannot be parsed as a flag (--upload-pack=<cmd>).
`npm install <pkg>` for npm-sourced plugins gets the same treatment.

Verified: prettier clean, 109/109 tests, and plugin add/install/update/
check all still work against a real repository.
@github-actions

github-actions Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
quartz ✅ Ready (View Log) Visit Preview 2f62ab3

This branch was successfully deployed

1 active deployment
Branch Preview — 2f62ab37 Deployed Jul 26, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant