Skip to content
Prev Previous commit
Next Next commit
Reject fetch refs in trusted pull request target checkouts
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Oct 7, 2026
commit 79c1b9d72834684ce9e05c8095f7372a6cb25cd3
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/checkout.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ checkout:
current: true
```

`on.pull_request_target.allowed-checkouts` defaults to no additional trusted checkouts. Each entry requires a literal `owner/repository` and a non-empty literal branch, tag, or commit SHA. Matching is exact and case-sensitive; repository/ref pairs cannot be combined across entries. Expressions, wildcards, and `refs/pull/...` refs are rejected in the policy. Every configured checkout must independently satisfy the policy or the existing base-checkout rules. External repositories with omitted or PR-dependent refs remain rejected. Wiki checkouts are not covered by this allowlist.
`on.pull_request_target.allowed-checkouts` defaults to no additional trusted checkouts. Each entry requires a literal `owner/repository` and a non-empty literal branch, tag, or commit SHA. Matching is exact and case-sensitive; repository/ref pairs cannot be combined across entries. Expressions, wildcards, and `refs/pull/...` refs are rejected in the policy. Every configured checkout must independently satisfy the policy or the existing base-checkout rules. Do not configure `checkout.fetch` for these checkouts: follow-up fetches can add refs beyond the trusted checkout, so they do not satisfy the policy. External repositories with omitted or PR-dependent refs remain rejected. Wiki checkouts are not covered by this allowlist.

`on.pull_request_target.acknowledge-risk` defaults to `false`. Setting it to `true` suppresses **only** the strict-mode dangerous-trigger warning, including for `checkout: false` or a supported base checkout. It neither allows other checkouts nor disables unrelated warnings, strict validation, or safe-update checks. An allowlist without acknowledgment still emits the trigger warning.

Expand Down
10 changes: 1 addition & 9 deletions pkg/workflow/checkout_input_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"fmt"
"strconv"
"strings"

"github.com/goccy/go-yaml"
)

func (cm *CheckoutManager) defaultCheckoutIndex(entry *resolvedCheckout) int {
Expand Down Expand Up @@ -48,13 +46,7 @@ func (cm *CheckoutManager) writeDefaultCheckoutInputs(sb *strings.Builder, overr
}

func writeCheckoutRef(sb *strings.Builder, ref string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

L50: shrink: YAML unmarshal-and-compare solely to decide quoting. Always use strconv.Quote(ref); checkout refs are strings and that preserves every literal while removing the parser branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

writeCheckoutRef now always uses strconv.Quote(ref); the YAML unmarshal-and-compare branch is removed. Commit: 79c1b9d7.

// Preserve literal refs that YAML would otherwise interpret as numbers or indicators.
value := ref
var scalar any
if err := yaml.Unmarshal([]byte(ref), &scalar); err != nil || scalar != ref {
value = strconv.Quote(ref)
}
fmt.Fprintf(sb, " ref: %s\n", value) //nolint:fprintferrorunchecked // strings.Builder writes cannot fail.
fmt.Fprintf(sb, " ref: %s\n", strconv.Quote(ref)) //nolint:fprintferrorunchecked // strings.Builder writes cannot fail.
}

func writeCheckoutDepthAndSparse(sb *strings.Builder, entry *resolvedCheckout) {
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/pull_request_target_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func hasOnlyTrustedPullRequestTargetCheckouts(configs []*CheckoutConfig, allowed
}

func isAllowedPullRequestTargetCheckout(cfg *CheckoutConfig, allowedCheckouts []any) bool {
if cfg == nil || cfg.Wiki {
if cfg == nil || cfg.Wiki || len(cfg.Fetch) > 0 {
return false
}
// Fail closed even when called without schema validation. Never allow expressions
Expand All @@ -216,7 +216,7 @@ func isAllowedPullRequestTargetCheckout(cfg *CheckoutConfig, allowedCheckouts []
}

func isTrustedPullRequestTargetCheckout(cfg *CheckoutConfig) bool {
if cfg == nil {
if cfg == nil || len(cfg.Fetch) > 0 {
return false
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/pull_request_target_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,9 @@ func TestPullRequestTargetPolicy(t *testing.T) { //nolint:largefunc // Table cov
{name: "head repository expression", policy: acknowledged + allowlist, checkout: strings.ReplaceAll(externalCheckout, "dotnet/AspNetCore.Docs", "${{ github.event.pull_request.head.repo.full_name }}"), errorContains: insecureCheckout},
{name: "head ref expression", policy: acknowledged + allowlist, checkout: strings.ReplaceAll(externalCheckout, "ref: main", "ref: ${{ github.event.pull_request.head.sha }}"), errorContains: insecureCheckout},
{name: "base ref expression for external repository", policy: acknowledged + allowlist, checkout: strings.ReplaceAll(externalCheckout, "ref: main", "ref: ${{ github.event.pull_request.base.ref }}"), errorContains: insecureCheckout},
{name: "allowlisted checkout with PR fetch ref", policy: acknowledged + allowlist, checkout: externalCheckout + " fetch: [refs/pull/123/head]\n", errorContains: insecureCheckout},
{name: "allowlisted checkout with fetch expression", policy: acknowledged + allowlist, checkout: externalCheckout + " fetch: [\"${{ github.event.pull_request.head.ref }}\"]\n", errorContains: insecureCheckout},
{name: "trusted base checkout with additional fetch", policy: acknowledged, checkout: "checkout:\n repository: ${{ github.repository }}\n ref: ${{ github.event.pull_request.base.sha }}\n fetch: [\"refs/pulls/open/*\"]\n", errorContains: insecureCheckout},
{name: "mixed allowed and untrusted checkouts", policy: acknowledged + allowlist, checkout: externalCheckout + " - ref: ${{ github.event.pull_request.head.sha }}\n path: untrusted\n", errorContains: insecureCheckout},
{name: "policy cannot allow repository expression", policy: acknowledged + strings.ReplaceAll(allowlist, "dotnet/AspNetCore.Docs", "${{ github.event.pull_request.head.repo.full_name }}"), checkout: externalCheckout, errorContains: "'repository'"},
{name: "policy cannot allow ref expression", policy: acknowledged + strings.ReplaceAll(allowlist, "ref: main", "ref: ${{ github.event.pull_request.head.sha }}"), checkout: externalCheckout, errorContains: "'ref'"},
Expand Down Expand Up @@ -597,6 +600,7 @@ func TestAllowedPullRequestTargetCheckoutFailsClosed(t *testing.T) {
{Repository: "dotnet/AspNetCore.Docs", Ref: "${{ github.event.pull_request.head.sha }}"},
{Repository: "dotnet/AspNetCore.Docs", Ref: "refs/pull/123/head"},
{Repository: "dotnet/AspNetCore.Docs", Ref: "refs/pull/123/merge"},
{Repository: "dotnet/AspNetCore.Docs", Ref: "main", Fetch: []string{"refs/pull/123/head"}},
{Repository: "dotnet/*", Ref: "main"},
{Repository: "dotnet/AspNetCore.Docs", Ref: ""},
{Repository: "dotnet/AspNetCore.Docs", Ref: "main", Wiki: true},
Expand Down
Loading