Skip to content

docs(ci): pass untrusted PR fields via env to prevent script injection#16

Open
chethanuk wants to merge 1 commit into
mainfrom
docs/ci-script-injection
Open

docs(ci): pass untrusted PR fields via env to prevent script injection#16
chethanuk wants to merge 1 commit into
mainfrom
docs/ci-script-injection

Conversation

@chethanuk

@chethanuk chethanuk commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Description

The CI integration docs teach a workflow snippet that interpolates three
attacker-controlled PR fields directly inside a shell run: block:

- name: Run OCR review
  run: |
    ocr review \
      --background "${{ github.event.pull_request.title }}" \
      --from "origin/${{ github.base_ref }}" \
      --to "origin/${{ github.head_ref }}" \
      --format json --audience agent

${{ }} is textual substitution performed before the shell parses the line, so the
quotes in the rendered command belong to the attacker, not to the template. Anyone who
copies this snippet — which is exactly what the page tells them to do — gets arbitrary
command execution on their runner, triggered by opening a PR.

This moves all three fields into an env: mapping and references them as ordinary shell
variables. Three code blocks per locale, three locales, no prose beyond one added
sentence explaining the rule.

This is the repo's own rule

internal/config/rules/rule_docs/github_workflows.md:6 — the review rule OpenCodeReview
ships and applies to other people's workflows:

Script injection: Expressions like ${{ github.event.issue.title }} used directly
in run: blocks enable code injection. These must be passed through environment
variables instead

And action.yml already complies, uniformly — all ten inputs are hoisted at :228-238
and the run: block at :240-244 touches only shell variables:

      env:
        OCR_BACKGROUND: ${{ inputs.background }}
      run: |
        [ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND")

action.yml:169-175 does the same for the branch refs — EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} in env:, then BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}"
in the run: block.

And the GitLab half of this same page already gets it right (en/integrations/ci.md:352-355):

    ocr review \
      --background "$CI_MERGE_REQUEST_TITLE" \
      --from "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \

So the shipped action, the shipped rule, and this page's own GitLab recipe all agree; the
GitHub Actions section was the lone outlier — within a single file. This PR makes the page
internally consistent rather than introducing a new opinion.

Verification — I ran the attack

A docs claim about a security bug is worth very little without the log line, so I
reproduced it on a hosted runner in a fork. One workflow, two jobs, differing only in how
the title reaches the shell; one PR titled:

demo"; echo PWNED_INJECTION_SUCCEEDED; #

Run 29827744839,
job logs verbatim:

Job Step source Log output
vulnerable run: echo "background=${{ github.event.pull_request.title }}" background=demo
PWNED_INJECTION_SUCCEEDED
safe env: PR_TITLE: + run: echo "background=$PR_TITLE" background=demo"; echo PWNED_INJECTION_SUCCEEDED; #

The vulnerable job's rendered command line, as GitHub logged it:

##[group]Run echo "background=demo"; echo PWNED_INJECTION_SUCCEEDED; #"

The payload became a second statement and executed. In the safe job the identical title
arrives as data and prints as literal text. The payload is a bare echo; nothing touched
the network, filesystem, or any secret, and the branch and test PR are deleted.

graph TD
  T["attacker-chosen PR title<br/>contains a quote, a semicolon and a comment marker"]
  T --> V["docs pattern<br/>interpolated into the run: block"]
  T --> S["this PR<br/>hoisted into env:"]
  V --> V2["GitHub substitutes the text<br/>BEFORE the shell parses the line"]
  V2 --> V3["quote closes early, semicolon starts<br/>a second command"]
  V3 --> V4["payload EXECUTES on the runner"]
  S --> S2["shell parses the line first;<br/>the variable expands to a value"]
  S2 --> S3["payload printed as literal text"]
Loading

base_ref / head_ref are injectable too, not just the title

The first two fields are obvious; branch names are the ones people assume are safe. git
disagrees:

$ for n in 'a";id;"b' 'a$(id)b' 'a;id;b' 'a`id`b' 'a|id|b'; do
    git check-ref-format --branch "$n" && echo "VALID: $n"; done
VALID: a";id;"b
VALID: a$(id)b
VALID: a;id;b
VALID: a`id`b
VALID: a|id|b

All five are legal branch names, and a fork PR lets an attacker choose the branch name.
GitHub's own hardening guidance lists github.head_ref as attacker-controlled.

There is also a non-malicious case: a$HOMEb is a legal branch name, and under the old
form the shell expands $HOMEb — so the snippet silently builds the wrong ref. The
env: form treats it as data and gets it right. This is a correctness fix as well as a
security one.

Gates run

$ for f in en ja zh; do
    awk '/^  run: \|$/{inrun=1;next} /^```$/{inrun=0}
         inrun && /\$\{\{ *github\./{print FILENAME":"FNR": "$0; rc=1}
         END{exit rc}' "pages/src/content/docs/$f/integrations/ci.md" || exit 1
  done; echo "PASS"
PASS: no github.* expressions inside run: blocks

$ diff /tmp/blk_en.txt /tmp/blk_ja.txt && diff /tmp/blk_en.txt /tmp/blk_zh.txt
PASS: 3 locales byte-identical (30 lines each)

$ actionlint <the three fixed snippets, reassembled into a real workflow>
0

Before the change there were 21 ${{ github.* }} expressions inside run: blocks —
7 per locale (1 × title, 3 × base_ref, 3 × head_ref). After it there are 0, and
all 21 surviving occurrences are env: assignments. The three YAML blocks were
byte-identical across the three locales before this change and still are, so no locale
was missed.

Scope

Only the three code blocks and one added sentence per file. Specifically not touched:

Location Why
if: | block (en:192, ja:148, zh:168) Uses bare github.* in an expression context, never handed to a shell. Correct as written.
examples/github_actions/README.md:222 background: ${{ ... }} is a with: input to the composite action, which action.yml:237 hoists to env: itself. Safe.
.github/workflows/* The two ${{ github.* }} uses are concurrency: group expressions, not shell.

I deliberately did not add a # zizmor: ignore[...] or actionlint suppression comment —
those markers exist to silence warnings, and annotating the correct pattern would read
as cargo cult.

One pre-existing issue this PR does not fix

--to "origin/$HEAD_REF" is unreliable for fork PRs regardless of quoting:
origin/<fork-branch> does not exist on the base remote. That is exactly why
action.yml:191-199 — the step literally named "Fetch PR head (fork-safe)" — fetches
pull/${PR_NUM}/head and uses the SHA instead. It predates
this change and is a different fix, so I left it alone rather than widening a security
patch — but I would rather name it than have you find it. Happy to file it separately, or
fold it in here if you would prefer one pass over the section.

Limitations

  • Docs-only. Zero runtime impact, and nothing in CI renders or lints these fenced
    snippets, so the test check on this PR does not exercise the change. The evidence
    above is the verification.
  • The reproduction ran on a GitHub-hosted runner in my fork, not on this repo's
    self-hosted pool. The mechanism is GitHub's expression substitution, which happens
    before any runner-specific behaviour, so I do not believe the runner type matters — but
    I did not test it on self-hosted and am not claiming I did.
  • I did not render the Astro site to confirm the pages still build; the change is inside
    fenced code blocks and one paragraph, with fences balanced and frontmatter untouched.
  • The ja and zh sentences are my translations of the en one, reviewed for terminology
    against the surrounding text in each file. A native reviewer's correction is welcome and
    I will take it verbatim.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Refactoring (no functional changes)
  • Documentation update
  • CI / Build / Tooling

How Has This Been Tested?

  • Live script-injection reproduction on a hosted runner — payload executed in the
    vulnerable job, printed as literal text in the safe job (run 29827744839,
    both log lines quoted above)
  • git check-ref-format --branch on five metacharacter branch names — all five legal
  • actionlint v1.7.12 exit 0 on the three fixed snippets reassembled into a real
    workflow, and exit 0 repo-wide on all four existing workflows
  • awk gate: zero ${{ github.* }} remaining inside any run: block in all three
    locales
  • Byte-identity diff across en/ja/zh code blocks — identical before and after
  • grep -rn '\${{ github\.' across the whole repo to confirm no other file teaches
    the same pattern (classification table above)

Checklist

  • My code follows the project's coding style
  • I have performed a self-review of my code
  • I have added tests that prove my fix is effective — n/a, documentation change; no
    test harness renders or lints these fenced snippets. Verified by the live
    reproduction and the awk/diff gates above instead.
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly (this PR is the documentation change)
  • I have signed the CLA

AI assistance: Claude Code helped research and verify this change. I reviewed the full
diff and take responsibility for it.

Summary by CodeRabbit

  • Documentation
    • Updated GitHub Actions CI/CD examples in English, Japanese, and Chinese documentation.
    • Workflow examples now pass pull request titles and branch references through environment variables before invoking reviews.
    • Improved examples for background context, branch-range reviews, and concurrency controls.
    • Existing JSON output, audience settings, rule options, and concurrency limits remain supported.

The CI integration docs interpolated github.event.pull_request.title,
github.base_ref and github.head_ref directly inside shell run: blocks.
GitHub substitutes ${{ }} textually before the shell parses the line, so a
PR title or branch name containing shell metacharacters executes on the
runner of anyone who copies the snippet.

Hoist all three into env: mappings and reference them as shell variables,
matching action.yml:228-244 and the repo's own review rule at
internal/config/rules/rule_docs/github_workflows.md:6.

Applied identically to en, ja and zh; the three code blocks were
byte-identical before and remain so.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@chethanuk, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 24869502-0814-4e96-91e5-533e386fcbfb

📥 Commits

Reviewing files that changed from the base of the PR and between c60e886 and ec099c3.

📒 Files selected for processing (3)
  • pages/src/content/docs/en/integrations/ci.md
  • pages/src/content/docs/ja/integrations/ci.md
  • pages/src/content/docs/zh/integrations/ci.md
📝 Walkthrough

Walkthrough

GitHub Actions CI documentation examples in English, Japanese, and Chinese now pass pull request titles and branch references through env variables instead of interpolating GitHub expressions directly in shell commands.

Changes

CI documentation updates

Layer / File(s) Summary
Environment-based review examples
pages/src/content/docs/{en,ja,zh}/integrations/ci.md
Custom rules and concurrency examples define PR_TITLE, BASE_REF, and HEAD_REF through env and reference them in ocr review, while retaining JSON output, audience suppression, and concurrency settings.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: pollychen-lab

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: moving untrusted PR fields through env to prevent script injection.
Description check ✅ Passed The description is mostly complete and includes the required sections with substantial details on the change and verification.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/ci-script-injection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the CI integration documentation in English, Japanese, and Chinese to recommend passing PR-controlled values (such as the PR title and branch references) through environment variables (env:) rather than interpolating them directly into the run: shell script. This is a critical security improvement to prevent potential shell injection vulnerabilities on GitHub Actions runners. There are no review comments, and I have no additional feedback to provide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@chethanuk

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chethanuk

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 25 minutes.

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