Motivation
When reviewing an open-source project, it is easy to focus entirely on the application code. Build workflows tend to look like supporting infrastructure: check out the repository, compile the project, run some tests, and upload the results.
But these workflows also process input. Sometimes that input comes directly from a pull request.
This post examines a command-injection vulnerability in QuestPDF’s package-testing workflow, published as GHSA-p89g-fcr7-p99r. The interesting part is where the payload lived: not in a shell script or a workflow change, but inside an XML version field. [1]
Scope: This issue affected only the CI workflow used for package compatibility testing. According to the advisory, it did not affect the QuestPDF library, its release workflow, or any published NuGet package. No action is required by QuestPDF users.
Investigating the root cause
The workflow extracted <Version> from the pull-request-controlled project using grep and sed, appended a -ci.<run_id>.<attempt> suffix, and exported the result as package-version. It did not validate the version.
The build step then inserted this output directly into Bash. Relevant excerpt, with unrelated flags omitted:
run: |
dotnet build QuestPDF/QuestPDF.csproj \
-p:Version="${{ steps.version.outputs.package-version }}"
The double quotes look reassuring, but two interpreters are involved: Actions substitutes the expression first; Bash parses the resulting script second. Input containing shell syntax therefore becomes executable source. Double quotes do not prevent Bash command substitution. [4]
Both downstream test jobs reused the same output through direct interpolation, so the fix needed to cover all three consumers.
Proof of concept
The advisory supplies this benign payload:
<Version>2026.7.1$(touch /tmp/pwn)</Version>
Extraction preserves it as text. When Actions inserts it into the build script, the argument becomes:
-p:Version="2026.7.1$(touch /tmp/pwn)-ci.12345.1"
The run identifiers are illustrative. Bash executes touch before launching dotnet. Since touch normally produces no output, the resulting version is still 2026.7.1-ci.12345.1. A later build failure would not undo the command execution.
This explanation reconstructs the published PoC from the advisory and patch; it does not claim a new end-to-end test against QuestPDF’s CI.
Impact and limitations
An external contributor could supply the payload through a pull request, with execution occurring before merge if CI was permitted to run. A maintainer approval gate could apply; the repository’s exact fork-approval policy was not confirmed.
The trigger was pull_request, not pull_request_target. Normal fork runs receive no repository secrets and a read-only GITHUB_TOKEN, despite the workflow’s original request for actions: write. [5]
The reported impact was runner-level command execution and potential CI artifact tampering, not compromise of released packages. Since PR builds already execute untrusted project code, this injection alone does not establish additional privilege escalation.
The advisory rates the issue High: CVSS 4.0 7.0, CWE-78. No CVE was requested because the defect was confined to CI.
The fix
The maintainers addressed the issue in three places:
- Validate before exporting: reject versions outside
^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$before writing to$GITHUB_OUTPUT. - Separate data from shell source: pass the version through environment variables in the build and both test jobs. [2]
- Reduce token permissions: replace the workflow’s requested permissions with
permissions: {}. [3]
The corrected build pattern, with unrelated flags omitted:
env:
PACKAGE_VERSION: ${{ steps.version.outputs.package-version }}
run: |
dotnet build QuestPDF/QuestPDF.csproj \
-p:Version="$PACKAGE_VERSION"
Ordinary quoted variable expansion does not reinterpret a literal $(...) inside the variable as shell syntax. Validation enforces the expected version format; environment-variable handling keeps the input as data.
Closing thoughts
The payload moved from XML to a workflow output without becoming any more trustworthy. The mistake happened when that data became part of a shell script.
For workflow reviews, the useful question is not just “Is this quoted?” but “Will the next interpreter receive data or executable source?”