Skip to content

Security hardening: input validation and resource limits - #6

Merged
nodeselector merged 12 commits into
mainfrom
nodeselector/lockfile-security-hardening
Jun 22, 2026
Merged

Security hardening: input validation and resource limits#6
nodeselector merged 12 commits into
mainfrom
nodeselector/lockfile-security-hardening

Conversation

@nodeselector

@nodeselector nodeselector commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Motivation

The lockfile parser accepts untrusted input -- lockfiles can be committed by any contributor or generated by automation. Without validation beyond YAML syntax, a crafted lockfile could exploit downstream consumers through injection payloads in ref/branch/tag fields, path traversal via workflow keys, memory exhaustion via oversized inputs, or trust confusion via conflicting digest values.

Approach

10 rounds of find-and-fix, each targeting a real exploitable gap confirmed with a proof-of-concept before writing the fix:

# Vulnerability Fix
1 commit: "" bypassed required-field check Added to nonEmptyStringKeys
2 commit: accepted any non-empty string Added isValidAlgoHex() validation
3 commit: could disagree with pin-key digest Cross-validate in canonicalizeActions
4 ParsePin ref accepted shell injection chars Added isValidRef() check
5 Workflow keys accepted ../../../etc/passwd Added validateWorkflowPaths()
6 branch:/tag: accepted injection chars Applied isValidRef() denylist
7 Parse had no input size limit MaxParseSize = 1 MiB
8 Circular uses: references accepted 3-color DFS cycle detection
9 ParseActionMeta had no size limit MaxActionMetaSize = 1 MiB
10 ParseActionMeta accepted YAML anchors rejectYAMLAnchors() on Node tree

Design decisions

  • Aligned with actions/runner: cycle detection shifts the runner's CompositeActionsMaxDepth rejection left to parse time. Per-action step count limits were intentionally omitted because the runner doesn't enforce them.
  • YAML anchor rejection is specific to ParseActionMeta (action.yml files don't use anchors). The lockfile's Parse path relies on yaml.v3's built-in alias bomb protection.
  • Shared validation primitives: isValidRef, isValidAlgoHex, and isValidDigest are reused across ParsePin, ParseActionRef, and lockfile field validation so the denylist never drifts.

Testing

Every fix includes targeted test coverage. go test -race ./... passes after each commit.

commit:"" passes requiredActionKeys presence check but bypasses
rejectZeroValues because nonEmptyStringKeys only listed "branch".
An attacker-controlled lockfile could set commit:"" on every action,
silently converting a required integrity field into a no-op for any
consumer that reads Action.Commit without separately checking for
the empty-string case.

Fix: add "commit" to nonEmptyStringKeys so the same zero-value
rejection that guards "branch" now guards "commit" too.
commit:"notadigest" or commit:"HEAD" passed validation because
rejectZeroValues only checked for the empty string. A crafted lockfile
could supply a plausible-looking but structurally invalid commit,
making downstream integrity checks produce false results.

Fix: after the empty-string gate, validate the commit value via
isValidAlgoHex, which calls the same isValidDigest path used by
ParsePin so the two never drift apart.
Parse accepted action entries where commit:"sha1-AAAA" lived under
a key ending in :sha1-BBBB. A consumer checking action.Commit trusts
a different hash than the pin key they used for the lookup — the
lockfile's two representations of the same digest point at different
commits, enabling a bait-and-switch on consumers that only check one.

Fix: in canonicalizeActions, compare action.Commit against the
pin key's algo+hex; return an error (locatable in the YAML tree)
when they disagree.
ParsePin extracted the ref from a pin string but only checked for
an embedded colon — it never validated the ref against the same
denylist that ParseActionRef applies via isValidRef. A crafted pin
key with a ref containing spaces, quotes, backtick, or backslash
returned ok=true and handed the caller a Pin.Ref loaded with shell
metacharacters. Any consumer that passes Pin.Ref to a shell command,
URL builder, or GraphQL literal is directly exploitable.

Fix: apply isValidRef to the extracted ref before accepting the
parse; the function is the single denylist definition shared with
ParseActionRef, so the two parsers cannot drift apart.
Workflow keys (e.g. ".github/workflows/ci.yml") are used by consumers
as repo-relative file paths. Parse never validated them, so a crafted
lockfile could include "../../../etc/passwd" or "/etc/shadow" as a key.
Any consumer that calls os.Open(key) or feeds the key to filepath.Join
without sanitizing gets an arbitrary-file-read primitive.

Fix: validateWorkflowPaths checks every key in f.Workflows:
- no leading "/" (no absolute paths)
- no ".." segment (no traversal)
- no control characters
Errors are anchored to the offending YAML key node when available.
branch and tag values in action metadata are used in GraphQL queries,
log output, and sometimes shell commands by consumers. Parse accepted
any non-empty string — "main\ninjected-header: value" or
"main\\evil" were both valid. An attacker-controlled lockfile could
arm a downstream injection through either field.

Fix: rejectZeroValues now applies isValidRef to branch and tag values
when present, using the same denylist as ParseActionRef (rejects
whitespace, quotes, backslash, backtick, and ".." sequences). This
is the single shared denylist definition so the two parsers cannot
drift apart.
Parse accepted inputs of unlimited size. A crafted multi-gigabyte
YAML document — or one using YAML alias expansion — would cause
memory exhaustion in yaml.Unmarshal / Decode before any validation
runs. This is a trivially exploitable remote DoS for any service
that feeds untrusted lockfile bytes to Parse.

Fix: reject inputs larger than MaxParseSize (1 MiB) before calling
yaml.Unmarshal. Legitimate lockfiles are orders of magnitude smaller
than this cap. Export the constant so consumers can describe the
limit in their own error messages.
Parse accepted circular uses references (A uses B, B uses A; or
self-loops A uses A). The lockfile is meant to record a DAG of
action dependencies. Any consumer that walks Action.Uses naively
— build dependency trees, topological sort, transitive closure
computation — will loop infinitely on a crafted lockfile.

Fix: run a three-colour DFS (white/grey/black) over the uses graph
after canonicalization. Any back-edge (grey node revisited) triggers
a ParseError locatable to the offending dependency key in the YAML
tree.
With a 1 MiB input cap, a single dependency entry can still pack
~20,000 entries into its uses: sequence (each ~50-byte pin string).
canonicalizeActions allocates a new slice of the same length for
every action — a 20× in-memory amplification on a maximum-size
input. The legitimate upper bound for a real composite action's
uses list is tens of entries.

Fix: add MaxUsesPerAction (500) constant and rejectOverlongUses,
which checks the raw YAML sequence length during validateKnownFields
before any allocation occurs.
ParseActionMeta accepted unbounded string input and would collect an
unbounded NestedUses slice from a crafted composite action.yml:

  - A 3.5 MB action.yml with 100,000 composite steps parsed cleanly,
    allocating a 100K-element []string with no back-pressure.
  - No size check ran before yaml.Unmarshal, so yaml.v3 itself had to
    absorb the full document before any limit could be applied.

Fix: add two new exported constants checked at the start of the function:

  MaxActionMetaSize = 64 KiB  (size check before any YAML parsing)
  MaxNestedUses     = 500     (cap on NestedUses slice growth)

64 KiB is generous for action.yml — the largest in the wild is under
20 KiB. 500 composite steps is far beyond any real action; GitHub's
own largest composite action has fewer than 30 steps.

Tests added: OversizedInputRejected, ExactMaxSizeAccepted,
OverlongUsesListRejected, UsesListAtMaxAccepted.
Review feedback applied:

- ParseActionMeta now rejects YAML anchors/aliases explicitly (action.yml
  doesn't use them; their presence is either a mistake or an exploit).
- Removed MaxUsesPerAction (500) and MaxNestedUses (500) — the runner has
  no per-action step count limit, so we can't be stricter.
- Updated detectUsesCycle comment to cite where the runner rejects cycles
  (CompositeActionsMaxDepth in src/Runner.Common/Constants.cs) and frame
  our check as shifting the failure left.
Copilot AI review requested due to automatic review settings June 22, 2026 21:58
GitHub Advanced Security started work on behalf of nodeselector June 22, 2026 21:58 View session
GitHub Advanced Security finished work on behalf of nodeselector June 22, 2026 21:58

Copilot AI 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.

⚠️ Not ready to approve

There are security/robustness gaps in the new workflow-path validation on Windows-style paths and the cycle detection implementation is recursive despite being documented as iterative, risking stack overflow on deep untrusted graphs.

Pull request overview

This PR hardens the Go lockfile parsing and action metadata parsing against untrusted inputs by adding size limits, stricter field/ref validation, workflow-key path validation, digest cross-checking, cycle detection, and YAML-anchor rejection for action.yml.

Changes:

  • Add MaxParseSize / MaxActionMetaSize limits and reject oversized inputs early.
  • Tighten validation for commit (non-empty + algo-hex format) and ref-like fields (branch/tag + ParsePin ref denylist) and cross-check commit vs pin-key digest.
  • Validate workflow map keys as safe repo-relative paths and reject uses: cycles; reject YAML anchors in ParseActionMeta.
File summaries
File Description
go/pkg/lockfile/pin.go Adds ref denylist validation to ParsePin.
go/pkg/lockfile/pin_test.go Adds regression tests for ref injection in pin keys.
go/pkg/lockfile/lockfile.go Adds parse size limit, workflow key validation, commit format validation + pin/body digest cross-check, and uses cycle detection.
go/pkg/lockfile/lockfile_test.go Adds security-focused tests for the new validation and limits.
go/pkg/lockfile/action_meta.go Adds action.yml size limit, YAML anchor/alias rejection, and decodes via a parsed YAML node.
go/pkg/lockfile/action_meta_test.go Adds tests for anchor rejection and size limits.

Copilot's findings

  • Files reviewed: 6/6 changed files
  • Comments generated: 3

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread go/pkg/lockfile/pin.go Outdated
Comment on lines +101 to +105
// Validate the ref with the same denylist used by ParseActionRef so that
// a parsed Pin is safe to pass to URL builders and GraphQL string
// literals without per-call escaping. Without this check a crafted pin
// key like "owner/repo@v1 ; malicious:sha1-..." parses successfully and
// the caller receives a Pin.Ref containing shell metacharacters.
Comment on lines +419 to +431
if strings.HasPrefix(p, "/") {
return fmt.Errorf("workflow path key must be repo-relative, not absolute: %q", p)
}
for _, c := range p {
if c <= 0x1F || c == 0x7F {
return fmt.Errorf("workflow path key contains control characters: %q", p)
}
}
for _, seg := range strings.Split(p, "/") {
if seg == ".." {
return fmt.Errorf("workflow path key contains path traversal: %q", p)
}
}
Comment on lines +336 to +344
// detectUsesCycle reports a cycle in the action uses graph using
// iterative DFS with three-colour marking. It returns the key of the node
// that forms the back-edge, or ("", nil) when the graph is acyclic.
//
// The runner rejects cycles at execution time via CompositeActionsMaxDepth
// (actions/runner: src/Runner.Common/Constants.cs). Detecting them at parse
// time shifts the failure left so consumers never receive a File whose uses
// graph is not a DAG.
func detectUsesCycle(f *File) (cycleKey string, err error) {
- checkWorkflowPathKey: reject backslash and colon characters to prevent
  Windows-style absolute paths (C:\...) and UNC paths (\server\share)
  from bypassing the forward-slash-only traversal checks.
- detectUsesCycle: fix comment to say 'recursive DFS' (not iterative) and
  document why recursion depth is bounded (MaxParseSize limits dependency
  count to ~5,000; Go stacks grow to 1 GB).
- ParsePin ref comment: remove misleading claim about escaping being
  unnecessary. The denylist rejects obviously-malicious refs but callers
  must still escape for their target context.
GitHub Advanced Security started work on behalf of nodeselector June 22, 2026 22:06 View session
GitHub Advanced Security finished work on behalf of nodeselector June 22, 2026 22:06
@nodeselector
nodeselector merged commit 29aa371 into main Jun 22, 2026
8 checks passed
nodeselector added a commit that referenced this pull request Jul 8, 2026
…ening

Security hardening: input validation and resource limits
@nodeselector
nodeselector deleted the nodeselector/lockfile-security-hardening branch July 8, 2026 16:35
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