Skip to content

fix(scripts): remove shell and argument injection from git calls (API-511) - #6909

Merged
eric-zaharia merged 11 commits into
mainfrom
fix/api-511-shell-injection
Aug 18, 2026
Merged

fix(scripts): remove shell and argument injection from git calls (API-511)#6909
eric-zaharia merged 11 commits into
mainfrom
fix/api-511-shell-injection

Conversation

@eric-zaharia

@eric-zaharia eric-zaharia commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🧭 What and Why

🎟 JIRA Ticket: API-511

pushGeneratedCode built git commit -m "<message>" as a bash string from attacker-controlled git metadata (commit subject, author name/email, Co-authored-by trailers), escaping only " and ` — so $(...) and ${...} executed on the runner. Two other paths interpolate a branch or tag name into a shell with no escaping at all. Argv form alone is not enough either: git reads a ref beginning with - as an option, and refs/heads/-evil is creatable via git update-ref and pushable — --output= then writes an arbitrary file, --upload-pack= runs a command.

Changes included:

  • New argv-form git() helper. The codegen commit goes through the existing gitCommit(), with subject, author and trailers read as separate values and the message assembled in JS instead of by git.
  • assertSafeRef rejects empty refs and refs starting with -, and --end-of-options precedes untrusted revisions. git pull re-parses its arguments in an internal git fetch, so only the validation protects that path.
  • getNbGitDiff no longer uses a shell. Its path now takes a list, because the shell was word-splitting a joined string into separate pathspecs; isBaseChanged passes its array through.
  • getLastReleasedTag()'s two createReleasePR call sites are validated and shell-free, as is the git pull origin $(git branch --show-current) in prepareGitEnvironment. A tag matching released* could carry $(...), and that path runs in the release job.
  • The prepare-release commit uses gitCommit({ env: { CI: 'true' } }). That variable suppresses the pre-commit hook that unstages generated files, so it has to reach the child process rather than stay a shell prefix.

Shell-form git calls that only interpolate values from this repo's own config (language and repository names) — in pushToRepository, spreadGeneration and the clone helpers — are unchanged.

Two behaviour changes worth a look:

  • A failing git diff now throws instead of returning 0. The old pipeline took its exit status from tr, so git failures read as "no changes" and jobs were skipped silently — including on the zero SHA github.event.before reports after a force-push.
  • A dash-leading branch or tag now fails the job. Such names cannot be created by git branch/git tag, only by plumbing.

🧪 Test

  • 28 new tests asserting the argv arrays. Each guard mutation-checked: dropping assertSafeRef fails 1 test, bypassing gitCommit fails 4, always passing the revision fails 3, joining the pathspecs fails 1.
  • Old and new implementations run against the same repo: the commit message is byte-identical for benign input on both main and generated branches. With a payload, the old path executed 3 of 4 injections and blanked the author name; the new one stores all four verbatim.
  • Checked by hand against temp repositories, since the unit tests are mock-based: git does not execute an argv payload, --end-of-options neutralises --output=, and CI=true keeps generated files in the commit.

Runs git through execa without a shell, so callers pass untrusted values as
argv elements rather than interpolating them into a command string.

Returns stdout only; run() returns stdout and stderr merged, which would
splice git diagnostics into any value read through it.
The codegen commit built `git commit -m "<message>"` as a shell string from
attacker-controlled git metadata (commit subject, author name/email and
Co-authored-by trailers). It escaped only " and `, leaving $(...) and ${...}
live, and a backslash before a backtick defeated the backtick escape.

Read the subject, author and trailers as separate values via git(), assemble
the message in JS, and commit through gitCommit(). Branch-name interpolations
in the same function are converted at the same time.

The resulting commit message is byte-identical for benign input, on both main
and generated PR branches.
Argv form stops bash from reinterpreting untrusted git metadata, but git
itself still reads a leading dash as an option. A branch named -evil is
creatable via git update-ref and pushable to a remote, and passing it bare
to a git command turns it into a flag: --output=<file> writes an arbitrary
file, --upload-pack=<cmd> executes a command.

Validate the checked-out branch once, and pass --end-of-options ahead of the
revision in each git show. --end-of-options does not cover git pull, which
re-parses its arguments in an internal git fetch, so the validation is what
protects that path.

A dash-leading branch now fails the job rather than being processed. Such a
name cannot be created by git branch, only by plumbing.
The diff check interpolated an attacker-controlled branch name into a shell
pipeline with no escaping at all, and it runs before the commit path.

Compose the command in JS instead. Four behaviour notes:

- the revision is omitted rather than passed empty. Argv form makes an empty
  string a hard error ("bad revision"), where the shell collapsed it to
  nothing; three call sites pass { head: null } with no branch.

- path now accepts a list. The shell was word-splitting a space-joined string
  back into separate pathspecs, so isBaseChanged passes its array through
  directly. Joining it would have silently broken pathspec scoping in both
  directions, depending on whether the entry leads with an exclusion.

- a failing git diff now throws instead of returning 0. The old pipeline took
  its exit status from tr, so any git failure was read as "no changes" and
  the push was skipped quietly.

- the return value is still 0 or 1, matching wc -l; git diff --shortstat
  emits a single summary line regardless of how many files changed.
getLastReleasedTag() returns a tag name read out of the repo, and both of its
call sites interpolated it into a shell string with no escaping at all. Tag
names follow the same rules as branch names, so a tag matching the "released*"
prefix can carry a command substitution:

  git tag 'released-2026$(touch${IFS}/tmp/mark)'

git describe returns that verbatim, and both interpolations then execute it.
This path runs in the release pipeline, which holds the broadest token.

Read the tag through argv-form git(), validate it, and pass --end-of-options
ahead of the revision.

Also routes the prepare-release commit through gitCommit(), which the ticket
asks for. That line was never exploitable, since its message is a constant
plus a date, but it was the last `git commit -m` string interpolation left.
It needs CI=true carried into the child process rather than written as a
shell prefix: that variable is what suppresses the pre-commit hook that
unstages generated files, so dropping it would silently produce release
commits missing every generated file.

Adds errorMessage to git() and env to gitCommit(), each for one call site.
@algolia-api-clients-automation-bot

algolia-api-clients-automation-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

No code generated

If you believe code should've been generated, please, report the issue.

📊 Benchmark results

Benchmarks performed on the method using a mock server, the results might not reflect the real-world performance.

Language Req/s
php 3460
javascript 2500
go 1693
csharp 1344
python 1297
java 1239
ruby 955
swift 843
scala 679

@aikido-pr-checks

aikido-pr-checks Bot commented Aug 17, 2026

Copy link
Copy Markdown

Summary by Aikido

Security Issues: 0 Quality Issues: 0 Resolved Issues: 0

⚡ Enhancements

  • Replaced shell-interpolated Git commands with safe argv-based execution
  • Validated Git references and protected revisions from option injection
  • Preserved diff pathspecs and surfaced Git failures correctly

🔧 Refactors

  • Assembled commit metadata safely and preserved release commit behavior

More info

Comment thread scripts/common.ts Outdated
@eric-zaharia eric-zaharia self-assigned this Aug 17, 2026
@eric-zaharia
eric-zaharia marked this pull request as ready for review August 17, 2026 12:13
@eric-zaharia
eric-zaharia requested a review from a team as a code owner August 17, 2026 12:13
These assert on the argv arrays rather than on output, because where each
value lands is the whole point of the fix.

- git() calls execa with no shell option, and returns stdout rather than the
  merged stdout+stderr stream
- assertSafeRef returns ordinary refs unchanged and rejects option-shaped ones
- pushGeneratedCode commits through gitCommit() with subject, author and
  trailers as separate values, so a $(...) payload survives verbatim
- getNbGitDiff omits an empty revision rather than passing '', and spreads a
  pathspec list into separate argv elements

Mutation-checked, each against the restored tree: dropping the assertSafeRef
guard fails 1 test, bypassing gitCommit fails 4, always passing the revision
fails 3, joining the pathspecs fails 1.
…g (API-511)

The pushGeneratedCode tests ran the real configureGitHubAuthor, writing
the bot identity into .git/config on every run; it is now mocked, and
the obsolete run mock is dropped.

Also from review:

- assertSafeRef rejects empty refs, as returned for a detached HEAD
- git() logs the failure output under verbose even with allowFailure,
  matching the old `|| true` behaviour
- prepareGitEnvironment pulls via the git() helper instead of a shell
  substitution
@eric-zaharia
eric-zaharia force-pushed the fix/api-511-shell-injection branch from aef8b9f to 340d51c Compare August 17, 2026 13:20
eric-zaharia and others added 2 commits August 17, 2026 16:24
…PI-511)

From review:

- git() with errorMessage logs the failure output under verbose before
  throwing, like the allowFailure path
- the co-author trailers are read with unfold and filtered on the
  Co-authored-by: prefix, so a folded trailer cannot become a malformed
  entry in the commit message
- tests cover the pushGeneratedCode early returns (no changes, behind
  origin), the release-commit message override, cwd forwarding in
  getNbGitDiff, and the add-before-diff ordering
@eric-zaharia
eric-zaharia enabled auto-merge (squash) August 18, 2026 06:51
@eric-zaharia
eric-zaharia merged commit 83e3777 into main Aug 18, 2026
37 checks passed
@eric-zaharia
eric-zaharia deleted the fix/api-511-shell-injection branch August 18, 2026 07:08
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.

2 participants