GitHub Actions / Reusable automation and scoped authority / Lesson 9
Untrusted input and script injection boundaries
Keep event text as data through expression evaluation, shell execution, and application validation.
On this page
Lesson goals
- Recognize unsafe interpolation into a run script.
- Use environment arguments and domain validation without claiming universal sanitization.
Event text can be attacker-controlled
Pull-request titles, branch names, issue bodies, and other event fields can contain unexpected punctuation and multiline text. A workflow author may think of them as harmless labels, yet an expression inserted into a run script becomes part of the program the shell parses. Parcel’s title-reporting step should not give a contributor the ability to add shell syntax. Identify every input’s origin and the interpreter that will consume it. A trusted workflow file can still execute untrusted text if it constructs a script carelessly.
Do not construct source code from event text
The unsafe pattern places an expression directly inside quoted shell source and assumes the surrounding quotes will always contain the result. Expression substitution happens before shell parsing, so a value containing quote characters can change the program. This course does not provide a harmful payload or execute an exploit. The safe design principle is concrete: keep the script fixed and pass the value through a data channel with correct argument quoting. Then validate it for the downstream domain rather than treating quoted text as universally safe.
Pass a value as a quoted argument
The fragment below assigns a pull-request title to an environment variable and passes it to a fixed printf format. Shell expansion of the quoted variable does not reparse its contents as new shell syntax. This protects that specific reporting boundary, not every possible use. A value used as an option, path, regular expression, SQL fragment, or command name still needs an appropriate contract. Never later pass the value to eval or another interpreter and assume the first boundary’s protection survives.
steps:
- name: Report proposal title as data
env:
PARCEL_TITLE: ${{ github.event.pull_request.title }}
shell: bash
run: printf "%s\n" "$PARCEL_TITLE"
This is an event-specific step fragment. It is not run, contains no malicious payload, and does not claim that title content is suitable for filenames, commands, or privileged decisions.
Validate the intended domain
Suppose a later step accepts a fixture name, not arbitrary text. Use an allowlist of supported fixture identifiers and reject anything outside it. The complete local program below illustrates a domain check without invoking a shell. It does not make a generic string sanitizer and should not be expanded into a growing list of forbidden characters. Defining the permitted language is usually easier to review than guessing every dangerous representation. Keep authorization separate: a valid fixture identifier does not grant permission to access production.
allowed = {"parcel-a", "parcel-b"}
def fixture(value):
if value not in allowed:
raise ValueError("unsupported fixture")
return value
for value in ["parcel-a", "unknown"]:
try:
print(fixture(value))
except ValueError as error:
print(error)
parcel-a
unsupported fixture
All values are synthetic and no command is executed. The allowlist validates this domain only; it is not a universal escaping or authorization mechanism.
Follow data through later consumers
Review action inputs and helper scripts as well as visible run blocks. A third-party action might interpolate its input into a shell internally, and a locally safe report can become unsafe when copied into another command. Inspect implementation and pin a reviewed dependency identity. Also consider output and environment-file injection when arbitrary text is written as control records. Retain a clear distinction between free text, structured metadata, and executable content. The review should end at the final interpreter or API, not at the first quoted variable.
Common errors and fixes
A pull-request title is inserted directly into a shell script.
- Why it happens
- Control-plane substitution turns untrusted text into program source.
- How to fix it
- Pass the value as data through an environment variable or structured API.
- Prevent it next time
- Trace every interpreter boundary and review fixed-script patterns.
Quoted input is later passed to eval.
- Why it happens
- A later interpreter reparses previously safe data as code.
- How to fix it
- Remove dynamic evaluation and use a fixed operation with validated arguments.
- Prevent it next time
- Review the whole data path, including helper actions.
A valid fixture name is treated as production authorization.
- Why it happens
- Domain validity was confused with access authority.
- How to fix it
- Apply repository, environment, and target authorization independently.
- Prevent it next time
- Keep validation and permission checks explicit.
Check your understanding
1. Does printf "%s" "$VALUE" execute shell syntax contained inside VALUE?
Show worked solution
Not by itself in the declared Bash pattern: the expansion is a quoted argument to a fixed command and is not reparsed as shell source. That does not justify feeding the same value to eval or using it unvalidated as an option or path in another program.
2. An action input is safely passed from YAML, but the action internally concatenates it into a script. Where is the defect?
Show worked solution
The action’s internal interpreter boundary is unsafe. Review or replace that dependency and use a fixed script with data arguments and domain validation. Safe YAML at the caller cannot repair an unsafe implementation downstream.
Key takeaways
- Keep event text out of executable source construction.
- Validate the domain and follow data to its final interpreter.