fix(ci): stop script injection in the GitHub <-> Forgejo sync workflows #108
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!108
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/ci-workflow-script-injection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The GitHub → Forgejo sync workflows pasted untrusted event fields straight into
run:scripts with${{ }}. GitHub expands those expressions into the script text before bash parses it, so a title like"; curl … $FORGEJO_TOKEN; "or a body containing the heredoc delimiter runs as code. Those steps haveFORGEJO_TOKEN(write access to this repository) in their environment. Anyone who can open an issue, PR or comment on the public GitHub mirror could trigger it..github/workflows/sync-issues.yml.github/workflows/sync-prs.ymlpull_request_target, so secrets are always available.github/workflows/sync-comments.yml.forgejo/workflows/sync-to-github.ymlGH_MIRROR_TOKENas soon as the mirrored issue was closedNothing suggests this was exploited. The GitHub API lists zero runs of the three sync workflows, and the mirror has never had an issue or PR. Both tokens should still be rotated (see below).
Changes
env:only. Every event-controlled value is set in the step'senv:block and used only as a quoted shell variable. JSON payloads are built withjq --arg, as before. The heredocs are gone.permissions: {}on all three GitHub workflows. None of them uses the GitHub token.sync-prs.ymlno longer checks out the PR.actions/checkoutof the PR head is replaced by a fetch into a bare repository under$RUNNER_TEMP, with no working tree and no persisted GitHub credentials. The PR's commits are only ever passed togit push. The Forgejo token is given to that push directly and is no longer saved as a git remote. The Forgejo branch namegh-pr-N/<branch>is reduced to[A-Za-z0-9._/-]and checked withgit check-ref-format.sync-prs.ymlwon't mirror a PR that changes.forgejo/or.gitea/. A mirrored PR is pushed as a branch of this repository, not a fork. Forgejo runs the workflow files of a pushed commit with this repository's secrets. Without this check, a GitHub PR adding a.forgejo/workflows/x.ymlwithon: pushcould readNUGET_ORG_API_KEYand publish to nuget.org. The sync now fails with an error on the GitHub PR whenever the PR's CI-config trees differ from its base branch, and a maintainer has to review and pull that PR by hand.sync-to-github.ymlis fixed the same way (env vars, andprintfinstead ofechofor the title).[Unreleased]→Security.Behaviour is otherwise unchanged. The merged-PR path still pushes
head.shato the base branch, as before.Verification
I extracted each
run:script and ran it locally with stubbedcurl/git pushand a local bare clone standing in for GitHub:sync-issues.ymlscript, with${{ }}expanded the way GitHub does it and a hostile title and body, created the canary file (injection reproduced).$(touch${IFS}…)as a branch name and as a review-comment path), passed through env, created no canary in any ofsync-issues,sync-comments(issue comment, review comment, review),sync-prs, orsync-to-github. Every payload was valid JSON, and titles and bodies arrived byte-for-byte.sync-prspaths:openedwith CI config unchanged: branch pushed asgh-pr-5/a__touch__IFS__CANARY_band PR created.synchronizewith a.forgejo/that differs from the base branch: refused with exit 1, nothing pushed.closed+ merged: head pushed tomain, then close, comment, and branch delete.git fetch https://github.com/Grandiras/ATProto.NET.git +<sha>:refs/pr/head).${{ }}remains inside anyrun:in the four sync workflows.actionlint/shellcheckaren't installed here, so neither was run.Action needed from the owner
FORGEJO_TOKEN(GitHub repo secret). It has write access here and, until this merges, can be taken by anyone who opens a GitHub issue.GH_MIRROR_TOKEN(Forgejo repo secret), for the same reason viasync-to-github.yml.Not fixed here (needs a decision)
pull_request→dotnet build) runs the PR's own code. Since the branch isn't from a fork, that job may get a write-scoped automatic token, depending on the Forgejo version and settings. The robust fix is to push mirrored PRs to a separate fork repo, so Forgejo treats them as fork PRs (read-only token, no secrets, approval gate).GrandirasthroughFORGEJO_TOKEN. That hands contributor-controlled code and text to an agent with a toolchain. It is worth excluding[GH-PR-#…]PRs, or thegh-pr-*branches, in the bot config.head.sha, notmerge_commit_sha, to the base branch (pre-existing behaviour, not a security issue)..forgejo/workflows/release.ymlstill has${{ inputs.version }}/${{ github.ref_name }}inrun:. Only writers can set those (workflow_dispatch, tag push), so I left them alone.🤖 Generated with Claude Code
The sync workflows interpolated untrusted event fields (issue/PR titles and bodies, comment bodies, review-comment paths, PR branch names) into run: scripts with ${{ }}, which is expanded into the script text before bash parses it. Anyone who could open an issue, PR or comment on the public GitHub mirror could run commands with FORGEJO_TOKEN in the environment; the quoted heredocs offered no protection because a body can contain the delimiter. - Every event field now reaches the script only through env: and a quoted variable; JSON payloads keep being built with jq --arg. - permissions: {} on all three GitHub workflows; none uses the GitHub token. - sync-prs.yml (pull_request_target) no longer checks out the PR. Its commits are fetched into a bare repository and only handed to git push, the Forgejo token is no longer saved as a remote, and the Forgejo branch name is reduced to [A-Za-z0-9._/-]. - sync-prs.yml refuses to mirror a PR whose .forgejo/ or .gitea/ differs from its base branch: the mirrored branch lives in the canonical repo, so Forgejo would run its workflow files with repository secrets. - .forgejo/workflows/sync-to-github.yml had the same title injection, and a GitHub issue title reaches it verbatim through the mirror, exposing GH_MIRROR_TOKEN. Fixed the same way. FORGEJO_TOKEN and GH_MIRROR_TOKEN should be rotated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
✅ Verdict: approve — no blocking issues found.
Solid, well-scoped fix for the script-injection hole. GitHub expands
${{ }}into therun:script before bash parses it, so the env-only + quoted-variable pattern used here is the correct remediation, and it is applied consistently. I verified with grep that no${{ }}remains inside anyrun:in the four sync workflows; the only remaining interpolations are insideenv:blocks (safe) and inrelease.yml, which only writers can trigger.The extra hardening in
sync-prs.ymlis sound: droppingactions/checkouteliminates both the injection surface and the persisted GitHub credentials, the bare repo under$RUNNER_TEMPkeeps the PR's commits out of any executed context, thetrwhitelist plusgit check-ref-formatfails closed on hostile branch names, and the anonymous by-SHA fetch is valid on github.com (the same mechanismactions/checkoutrelies on forpull_request_target). The.forgejo/.giteatree comparison is conservative and catches deletions, additions, and file-vs-directory swaps alike, and a later force-push is caught by the nextsynchronizerun.permissions: {}is correct for all three GitHub workflows since none uses the GitHub token.Two non-blocking notes:
The closed-and-merged path (line 72) pushes
head.shato the base branch without applying the same.forgejo/.giteaguard. That's acceptable — it only executes after a maintainer merges on GitHub, which is itself the manual review the guard demands — but running the identical check before that push would be cheap defense in depth.Best-effort branch cleanup (
.../branches/${FORGEJO_BRANCH}with/unescaped in the URL) is pre-existing and swallowed by|| true; Gitea-style wildcard routing usually tolerates it, so no action needed.The disclosed follow-ups (rotating both tokens, and eventually pushing mirrored PRs to a separate fork repo so Forgejo treats them as fork PRs) are the right remaining actions; the token rotation should happen before or promptly after this merges.
⏱ 8m 37s · 10 steps · $0.0303 · tokens: 111.8k in / 21.1k out (+97.3k cache-read) · Go: 5h 9% · wk 90% · mo 78%
📜 Full session transcript