Compare commits

...

2 Commits

Author SHA1 Message Date
Aiqiao Yan
0717577d45 backport fixes to releases-v2 (#2526)
* backport fixes to releases-v2

* fix dist/index.js
2026-07-16 15:43:54 -04:00
Aiqiao Yan
262cdb5f1c backport allow-unsafe-pr-checkout to v2 (#2504)
* block checking out fork pr for pull_request_target and workflow_run (#2454)

* block checking out fork pr for some events

* address copilot and reviewer feedback

* run prettier formatting

* build

* update urls

* update readme

* update description and url again

* edit url one more time

* update error wording (#2467)

* bump upload-artifact

* rebuild
2026-07-07 16:34:16 -04:00
11 changed files with 641 additions and 11 deletions

View File

@@ -44,7 +44,7 @@ jobs:
fi
# If dist/ was different than expected, upload the expected version as an artifact
- uses: actions/upload-artifact@v2
- uses: actions/upload-artifact@v4
if: ${{ failure() && steps.diff.conclusion == 'failure' }}
with:
name: dist

View File

@@ -110,6 +110,15 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous
# config --global --add safe.directory <path>`
# Default: true
set-safe-directory: ''
# Required to check out fork pull request code from a workflow triggered by
# `pull_request_target` or `workflow_run`. These workflows run with the base
# repository's GITHUB_TOKEN, secrets, default-branch cache scope, and runner
# access; fetching and executing a fork's code in that trusted context commonly
# leads to "pwn request" vulnerabilities. Set to `true` only after reviewing the
# risks at https://gh.io/securely-using-pull_request_target.
# Default: false
allow-unsafe-pr-checkout: ''
```
<!-- end usage -->

View File

@@ -778,7 +778,8 @@ async function setup(testName: string): Promise<void> {
sshKnownHosts: '',
sshStrict: true,
workflowOrganizationId: 123456,
setSafeDirectory: true
setSafeDirectory: true,
allowUnsafePrCheckout: false
}
}

View File

@@ -12,15 +12,24 @@ const gitHubWorkspace = path.resolve('/checkout-tests/workspace')
// Inputs for mock @actions/core
let inputs = {} as any
// Replicate @actions/core getInput behavior: it trims whitespace by default
// (String.prototype.trim(), which strips characters such as a leading U+FEFF BOM)
// unless trimWhitespace is explicitly set to false.
const getInputImpl = (name: string, options?: {trimWhitespace?: boolean}) => {
const val = inputs[name] ?? ''
if (options && options.trimWhitespace === false) {
return val
}
return typeof val === 'string' ? val.trim() : val
}
// Shallow clone original @actions/github context
let originalContext = {...github.context}
describe('input-helper tests', () => {
beforeAll(() => {
// Mock getInput
jest.spyOn(core, 'getInput').mockImplementation((name: string) => {
return inputs[name]
})
jest.spyOn(core, 'getInput').mockImplementation(getInputImpl as any)
// Mock error/warning/info/debug
jest.spyOn(core, 'error').mockImplementation(jest.fn())
@@ -86,6 +95,7 @@ describe('input-helper tests', () => {
expect(settings.repositoryOwner).toBe('some-owner')
expect(settings.repositoryPath).toBe(gitHubWorkspace)
expect(settings.setSafeDirectory).toBe(true)
expect(settings.allowUnsafePrCheckout).toBe(false)
})
it('qualifies ref', async () => {
@@ -135,8 +145,76 @@ describe('input-helper tests', () => {
expect(settings.commit).toBeFalsy()
})
it('does not reclassify a ref as sha when a BOM is prefixed', async () => {
// A fork branch named "<U+FEFF>" + 40 hex chars. core.getInput trims the
// BOM by default, which previously collapsed this into a bare SHA and
// bypassed the unsafe fork PR checkout guard.
inputs.ref = '\uFEFF522d932fae5296da51fdf431934425ecf891c6a2'
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.commit).toBeFalsy()
expect(settings.ref).toBe('522d932fae5296da51fdf431934425ecf891c6a2')
})
it('treats a sha surrounded by ascii whitespace as a commit', async () => {
// ASCII whitespace can only come from the workflow author's YAML (git ref
// names cannot contain it), so trimming it and treating the value as a
// commit is safe.
inputs.ref = ' 1111111111222222222233333333334444444444 '
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.ref).toBeFalsy()
expect(settings.commit).toBe('1111111111222222222233333333334444444444')
})
it('sets workflow organization ID', async () => {
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.workflowOrganizationId).toBe(123456)
})
describe('unsafe PR checkout guard', () => {
const forkPayload = {
repository: {id: 100},
pull_request: {
head: {
sha: '1234567890123456789012345678901234567890',
repo: {id: 200, full_name: 'attacker/fork'}
},
merge_commit_sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
}
}
it('allows the default self-checkout on a fork pull_request_target', async () => {
const originalEvent = github.context.eventName
const originalPayload = github.context.payload
const originalSha = github.context.sha
try {
github.context.eventName = 'pull_request_target'
github.context.payload = forkPayload as any
// Simulate a rebase/fast-forward merge where the base tip (event SHA)
// equals the PR head SHA. The default self-checkout must still succeed.
github.context.sha = '1234567890123456789012345678901234567890'
const settings: IGitSourceSettings = await inputHelper.getInputs()
expect(settings.commit).toBe('1234567890123456789012345678901234567890')
} finally {
github.context.eventName = originalEvent
github.context.payload = originalPayload
github.context.sha = originalSha
}
})
it('refuses an explicit fork repository on pull_request_target', async () => {
const originalEvent = github.context.eventName
const originalPayload = github.context.payload
try {
github.context.eventName = 'pull_request_target'
github.context.payload = forkPayload as any
inputs.repository = 'attacker/fork'
await expect(inputHelper.getInputs()).rejects.toThrow(
/Refusing to check out fork pull request code/
)
} finally {
github.context.eventName = originalEvent
github.context.payload = originalPayload
}
})
})
})

View File

@@ -0,0 +1,267 @@
import * as github from '@actions/github'
import {assertSafePrCheckout} from '../lib/unsafe-pr-checkout-helper'
// Shallow clone original @actions/github context
const originalContext = {...github.context}
const originalEventName = github.context.eventName
const originalPayload = github.context.payload
const BASE_REPO_ID = 100
const FORK_REPO_ID = 200
const PR_HEAD_SHA = '1111111111111111111111111111111111111111'
const PR_MERGE_SHA = '2222222222222222222222222222222222222222'
const SAFE_BASE_SHA = '3333333333333333333333333333333333333333'
const WORKFLOW_RUN_HEAD_COMMIT_SHA = '4444444444444444444444444444444444444444'
const BASE_QUALIFIED_REPO = 'some-owner/some-repo'
const FORK_QUALIFIED_REPO = 'another-repo/fork'
function setContext(eventName: string, payload: object): void {
;(github.context as {eventName: string}).eventName = eventName
;(github.context as {payload: object}).payload = payload
}
function forkPullRequestTargetPayload(): object {
return {
repository: {id: BASE_REPO_ID},
pull_request: {
head: {
sha: PR_HEAD_SHA,
repo: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO}
},
merge_commit_sha: PR_MERGE_SHA
}
}
}
function sameRepoPullRequestTargetPayload(): object {
return {
repository: {id: BASE_REPO_ID},
pull_request: {
head: {
sha: PR_HEAD_SHA,
repo: {id: BASE_REPO_ID, full_name: BASE_QUALIFIED_REPO}
},
merge_commit_sha: PR_MERGE_SHA
}
}
}
function forkWorkflowRunPayload(): object {
return {
repository: {id: BASE_REPO_ID},
workflow_run: {
event: 'pull_request',
head_commit: {id: WORKFLOW_RUN_HEAD_COMMIT_SHA},
head_repository: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO}
}
}
}
describe('unsafe-pr-checkout-helper', () => {
beforeAll(() => {
jest.spyOn(github.context, 'repo', 'get').mockReturnValue({
owner: 'some-owner',
repo: 'some-repo'
})
})
afterEach(() => {
;(github.context as {eventName: string}).eventName = originalEventName
;(github.context as {payload: object}).payload = originalPayload
})
afterAll(() => {
;(github.context as {eventName: string}).eventName =
originalContext.eventName
;(github.context as {payload: object}).payload = originalContext.payload
jest.restoreAllMocks()
})
it('allows pull_request events untouched', () => {
setContext('pull_request', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'attacker/fork',
ref: 'refs/pull/1/merge',
commit: '',
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('allows pull_request_target default checkout (base branch)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/heads/main',
commit: SAFE_BASE_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('allows same-repo pull_request_target checkout of PR head', () => {
setContext('pull_request_target', sameRepoPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('refuses pull_request_target fork PR head SHA checkout', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA,
allowUnsafePrCheckout: false
})
).toThrow(/Refusing to check out fork pull request code/)
})
it('refuses pull_request_target fork PR merge_commit_sha checkout', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_MERGE_SHA,
allowUnsafePrCheckout: false
})
).toThrow(/allow-unsafe-pr-checkout/)
})
it('refuses pull_request_target fork PR ref pattern (head)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/head',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target fork PR ref pattern (merge)', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/merge',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target when repository points at the fork', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: FORK_QUALIFIED_REPO,
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows pull_request_target checkout of an unrelated third-party repo', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: 'some-other/unrelated',
ref: 'refs/heads/main',
commit: '',
allowUnsafePrCheckout: false
})
).not.toThrow()
})
it('refuses pull_request_target ignoring repository case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: FORK_QUALIFIED_REPO.toUpperCase(),
ref: '',
commit: '',
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses pull_request_target ignoring commit SHA case differences', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: PR_HEAD_SHA.toUpperCase(),
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows pull_request_target fork PR checkout when opted in', () => {
setContext('pull_request_target', forkPullRequestTargetPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: 'refs/pull/42/merge',
commit: '',
allowUnsafePrCheckout: true
})
).not.toThrow()
})
it('refuses workflow_run fork PR head_commit.id checkout', () => {
setContext('workflow_run', forkWorkflowRunPayload())
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).toThrow()
})
it('refuses workflow_run with pull_request_target underlying event', () => {
const payload = forkWorkflowRunPayload() as {
workflow_run: {event: string}
}
payload.workflow_run.event = 'pull_request_target'
setContext('workflow_run', payload)
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).toThrow()
})
it('allows workflow_run same-repo PR (head_repository.id matches base)', () => {
const payload = forkWorkflowRunPayload() as {
workflow_run: {head_repository: {id: number}}
}
payload.workflow_run.head_repository.id = BASE_REPO_ID
setContext('workflow_run', payload)
expect(() =>
assertSafePrCheckout({
qualifiedRepository: BASE_QUALIFIED_REPO,
ref: '',
commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
allowUnsafePrCheckout: false
})
).not.toThrow()
})
})

View File

@@ -71,6 +71,15 @@ inputs:
set-safe-directory:
description: Add repository path as safe.directory for Git global config by running `git config --global --add safe.directory <path>`
default: true
allow-unsafe-pr-checkout:
description: >
Required to check out fork pull request code from a workflow triggered by
`pull_request_target` or `workflow_run`. These workflows run with the
base repository's GITHUB_TOKEN, secrets, default-branch cache scope, and
runner access; fetching and executing a fork's code in that trusted
context commonly leads to "pwn request" vulnerabilities. Set to `true`
only after reviewing the risks at https://gh.io/securely-using-pull_request_target.
default: false
runs:
using: node12
main: dist/index.js

139
dist/index.js vendored
View File

@@ -4740,7 +4740,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.checkCommitInfo = exports.testRef = exports.getRefSpec = exports.getRefSpecForAllHistory = exports.getCheckoutInfo = exports.tagsRefSpec = void 0;
exports.fromPayload = exports.checkCommitInfo = exports.testRef = exports.getRefSpec = exports.getRefSpecForAllHistory = exports.getCheckoutInfo = exports.tagsRefSpec = void 0;
const url_1 = __webpack_require__(835);
const core = __importStar(__webpack_require__(470));
const github = __importStar(__webpack_require__(469));
@@ -4958,6 +4958,7 @@ exports.checkCommitInfo = checkCommitInfo;
function fromPayload(path) {
return select(github.context.payload, path);
}
exports.fromPayload = fromPayload;
function select(obj, path) {
if (!obj) {
return undefined;
@@ -13322,6 +13323,102 @@ function getNextPage (octokit, link, headers) {
}
/***/ }),
/***/ 554:
/***/ (function(__unusedmodule, exports, __webpack_require__) {
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.assertSafePrCheckout = void 0;
const github = __importStar(__webpack_require__(469));
const ref_helper_1 = __webpack_require__(227);
const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/;
function assertSafePrCheckout(input) {
if (input.allowUnsafePrCheckout) {
return;
}
const eventName = github.context.eventName;
if (eventName !== 'pull_request_target' && eventName !== 'workflow_run') {
return;
}
const baseRepoId = (0, ref_helper_1.fromPayload)('repository.id');
if (typeof baseRepoId !== 'number') {
return;
}
let prHeadRepoId;
let prHeadRepoFullName;
const prShas = [];
if (eventName === 'pull_request_target') {
prHeadRepoId = (0, ref_helper_1.fromPayload)('pull_request.head.repo.id');
prHeadRepoFullName = (0, ref_helper_1.fromPayload)('pull_request.head.repo.full_name');
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('pull_request.head.sha'));
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('pull_request.merge_commit_sha'));
}
else {
const wrEvent = (0, ref_helper_1.fromPayload)('workflow_run.event');
if (typeof wrEvent !== 'string' || !wrEvent.startsWith('pull_request')) {
return;
}
prHeadRepoId = (0, ref_helper_1.fromPayload)('workflow_run.head_repository.id');
prHeadRepoFullName = (0, ref_helper_1.fromPayload)('workflow_run.head_repository.full_name');
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('workflow_run.head_commit.id'));
// For `pull_request_target`-triggered workflow_run, `head_sha` is the base
// default branch SHA (not the PR head)
if (wrEvent !== 'pull_request_target') {
pushIfSha(prShas, (0, ref_helper_1.fromPayload)('workflow_run.head_sha'));
}
}
// (A) Fork PR?
if (typeof prHeadRepoId !== 'number' || prHeadRepoId === baseRepoId) {
return;
}
// (B) We cannot check for all fork PR refs so check to see
// if the resolved input points to the fork PR sha we have in the payload
const repositoryMatchesPrHead = typeof prHeadRepoFullName === 'string' &&
input.qualifiedRepository.toLowerCase() === prHeadRepoFullName.toLowerCase();
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref);
const commitMatchesPrHeadSha = !!input.commit && prShas.includes(input.commit.toLowerCase());
if (!repositoryMatchesPrHead &&
!refMatchesPullPattern &&
!commitMatchesPrHeadSha) {
return;
}
throw new Error(`Refusing to check out fork pull request code from a '${eventName}' workflow. ` +
`This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch ` +
`cache scope, and runner access. Fetching and executing a fork's code in that trusted ` +
`context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks ` +
`at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' ` +
`on the actions/checkout step.`);
}
exports.assertSafePrCheckout = assertSafePrCheckout;
function pushIfSha(target, value) {
if (typeof value === 'string' && value.length > 0) {
target.push(value.toLowerCase());
}
}
/***/ }),
/***/ 558:
@@ -18372,6 +18469,7 @@ const core = __importStar(__webpack_require__(470));
const fsHelper = __importStar(__webpack_require__(618));
const github = __importStar(__webpack_require__(469));
const path = __importStar(__webpack_require__(622));
const unsafePrCheckoutHelper = __importStar(__webpack_require__(554));
const workflowContextHelper = __importStar(__webpack_require__(642));
function getInputs() {
return __awaiter(this, void 0, void 0, function* () {
@@ -18407,6 +18505,23 @@ function getInputs() {
`${github.context.repo.owner}/${github.context.repo.repo}`.toUpperCase();
// Source branch, source version
result.ref = core.getInput('ref');
// core.getInput()'s default trim strips a range of Unicode characters such as a
// leading BOM (U+FEFF) or NBSP (U+00A0). Those are valid in a git ref name, so
// a fork branch named "<BOM>" + 40 hex chars would trim down to a bare SHA and
// be silently reclassified as a commit, bypassing the unsafe fork PR checkout
// guard.
//
// The trim below strips only the ASCII whitespace characters which are all forbidden
// in a git branch name.
// \t U+0009 horizontal tab - ASCII control, forbidden in ref names
// \n U+000A line feed - ASCII control, forbidden in ref names
// \v U+000B vertical tab - ASCII control, forbidden in ref names
// \f U+000C form feed - ASCII control, forbidden in ref names
// \r U+000D carriage return - ASCII control, forbidden in ref names
// ' ' U+0020 space - forbidden in ref names
const asciiTrimmedRef = core
.getInput('ref', { trimWhitespace: false })
.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, '');
if (!result.ref) {
if (isWorkflowRepository) {
result.ref = github.context.ref;
@@ -18419,8 +18534,8 @@ function getInputs() {
}
}
// SHA?
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
result.commit = result.ref;
else if (asciiTrimmedRef.match(/^[0-9a-fA-F]{40}$/)) {
result.commit = asciiTrimmedRef;
result.ref = '';
}
core.debug(`ref = '${result.ref}'`);
@@ -18465,6 +18580,24 @@ function getInputs() {
// Set safe.directory in git global config.
result.setSafeDirectory =
(core.getInput('set-safe-directory') || 'true').toUpperCase() === 'TRUE';
// Allow unsafe PR checkout (opt-in for pull_request_target / workflow_run fork PRs)
result.allowUnsafePrCheckout =
(core.getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() ===
'TRUE';
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`);
// The default self-checkout (this repository with no explicit ref) always
// resolves to the trusted ref/commit GitHub set for the triggering event, so
// the fork-checkout guard only needs to run when the caller customized the
// repository or ref.
const isDefaultCheckout = isWorkflowRepository && !core.getInput('ref');
if (!isDefaultCheckout) {
unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository,
ref: result.ref,
commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout
});
}
return result;
});
}

View File

@@ -83,4 +83,10 @@ export interface IGitSourceSettings {
* Indicates whether to add repositoryPath as safe.directory in git global config
*/
setSafeDirectory: boolean
/**
* Opt-in to allow checking out fork pull request code from a workflow
* triggered by pull_request_target or workflow_run.
*/
allowUnsafePrCheckout: boolean
}

View File

@@ -2,6 +2,7 @@ import * as core from '@actions/core'
import * as fsHelper from './fs-helper'
import * as github from '@actions/github'
import * as path from 'path'
import * as unsafePrCheckoutHelper from './unsafe-pr-checkout-helper'
import * as workflowContextHelper from './workflow-context-helper'
import {IGitSourceSettings} from './git-source-settings'
@@ -58,6 +59,23 @@ export async function getInputs(): Promise<IGitSourceSettings> {
// Source branch, source version
result.ref = core.getInput('ref')
// core.getInput()'s default trim strips a range of Unicode characters such as a
// leading BOM (U+FEFF) or NBSP (U+00A0). Those are valid in a git ref name, so
// a fork branch named "<BOM>" + 40 hex chars would trim down to a bare SHA and
// be silently reclassified as a commit, bypassing the unsafe fork PR checkout
// guard.
//
// The trim below strips only the ASCII whitespace characters which are all forbidden
// in a git branch name.
// \t U+0009 horizontal tab - ASCII control, forbidden in ref names
// \n U+000A line feed - ASCII control, forbidden in ref names
// \v U+000B vertical tab - ASCII control, forbidden in ref names
// \f U+000C form feed - ASCII control, forbidden in ref names
// \r U+000D carriage return - ASCII control, forbidden in ref names
// ' ' U+0020 space - forbidden in ref names
const asciiTrimmedRef = core
.getInput('ref', {trimWhitespace: false})
.replace(/^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g, '')
if (!result.ref) {
if (isWorkflowRepository) {
result.ref = github.context.ref
@@ -71,8 +89,8 @@ export async function getInputs(): Promise<IGitSourceSettings> {
}
}
// SHA?
else if (result.ref.match(/^[0-9a-fA-F]{40}$/)) {
result.commit = result.ref
else if (asciiTrimmedRef.match(/^[0-9a-fA-F]{40}$/)) {
result.commit = asciiTrimmedRef
result.ref = ''
}
core.debug(`ref = '${result.ref}'`)
@@ -125,5 +143,26 @@ export async function getInputs(): Promise<IGitSourceSettings> {
// Set safe.directory in git global config.
result.setSafeDirectory =
(core.getInput('set-safe-directory') || 'true').toUpperCase() === 'TRUE'
// Allow unsafe PR checkout (opt-in for pull_request_target / workflow_run fork PRs)
result.allowUnsafePrCheckout =
(core.getInput('allow-unsafe-pr-checkout') || 'false').toUpperCase() ===
'TRUE'
core.debug(`allow unsafe PR checkout = ${result.allowUnsafePrCheckout}`)
// The default self-checkout (this repository with no explicit ref) always
// resolves to the trusted ref/commit GitHub set for the triggering event, so
// the fork-checkout guard only needs to run when the caller customized the
// repository or ref.
const isDefaultCheckout = isWorkflowRepository && !core.getInput('ref')
if (!isDefaultCheckout) {
unsafePrCheckoutHelper.assertSafePrCheckout({
qualifiedRepository,
ref: result.ref,
commit: result.commit,
allowUnsafePrCheckout: result.allowUnsafePrCheckout
})
}
return result
}

View File

@@ -259,7 +259,7 @@ export async function checkCommitInfo(
}
}
function fromPayload(path: string): any {
export function fromPayload(path: string): any {
return select(github.context.payload, path)
}

View File

@@ -0,0 +1,88 @@
import * as github from '@actions/github'
import {fromPayload} from './ref-helper'
const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/
export interface IUnsafePrCheckoutInput {
qualifiedRepository: string
ref: string
commit: string | undefined
allowUnsafePrCheckout: boolean
}
export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void {
if (input.allowUnsafePrCheckout) {
return
}
const eventName = github.context.eventName
if (eventName !== 'pull_request_target' && eventName !== 'workflow_run') {
return
}
const baseRepoId = fromPayload('repository.id')
if (typeof baseRepoId !== 'number') {
return
}
let prHeadRepoId: unknown
let prHeadRepoFullName: unknown
const prShas: string[] = []
if (eventName === 'pull_request_target') {
prHeadRepoId = fromPayload('pull_request.head.repo.id')
prHeadRepoFullName = fromPayload('pull_request.head.repo.full_name')
pushIfSha(prShas, fromPayload('pull_request.head.sha'))
pushIfSha(prShas, fromPayload('pull_request.merge_commit_sha'))
} else {
const wrEvent = fromPayload('workflow_run.event')
if (typeof wrEvent !== 'string' || !wrEvent.startsWith('pull_request')) {
return
}
prHeadRepoId = fromPayload('workflow_run.head_repository.id')
prHeadRepoFullName = fromPayload('workflow_run.head_repository.full_name')
pushIfSha(prShas, fromPayload('workflow_run.head_commit.id'))
// For `pull_request_target`-triggered workflow_run, `head_sha` is the base
// default branch SHA (not the PR head)
if (wrEvent !== 'pull_request_target') {
pushIfSha(prShas, fromPayload('workflow_run.head_sha'))
}
}
// (A) Fork PR?
if (typeof prHeadRepoId !== 'number' || prHeadRepoId === baseRepoId) {
return
}
// (B) We cannot check for all fork PR refs so check to see
// if the resolved input points to the fork PR sha we have in the payload
const repositoryMatchesPrHead =
typeof prHeadRepoFullName === 'string' &&
input.qualifiedRepository.toLowerCase() === prHeadRepoFullName.toLowerCase()
const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref)
const commitMatchesPrHeadSha =
!!input.commit && prShas.includes(input.commit.toLowerCase())
if (
!repositoryMatchesPrHead &&
!refMatchesPullPattern &&
!commitMatchesPrHeadSha
) {
return
}
throw new Error(
`Refusing to check out fork pull request code from a '${eventName}' workflow. ` +
`This workflow runs with the base repository's GITHUB_TOKEN, secrets, default-branch ` +
`cache scope, and runner access. Fetching and executing a fork's code in that trusted ` +
`context commonly leads to "pwn request" vulnerabilities. To opt in, review the risks ` +
`at https://gh.io/securely-using-pull_request_target and set 'allow-unsafe-pr-checkout: true' ` +
`on the actions/checkout step.`
)
}
function pushIfSha(target: string[], value: unknown): void {
if (typeof value === 'string' && value.length > 0) {
target.push(value.toLowerCase())
}
}