From b8447332b0339613f1faeb543152b81046bc41f4 Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:12:01 +0000 Subject: [PATCH 1/8] block checking out fork pr for some events --- README.md | 6 + __test__/git-auth-helper.test.ts | 3 +- __test__/input-helper.test.ts | 1 + __test__/unsafe-pr-checkout-helper.test.ts | 254 +++++++++++++++++++++ action.yml | 6 + dist/index.js | 104 +++++++++ src/git-source-settings.ts | 6 + src/input-helper.ts | 14 ++ src/ref-helper.ts | 2 +- src/unsafe-pr-checkout-helper.ts | 80 +++++++ 10 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 __test__/unsafe-pr-checkout-helper.test.ts create mode 100644 src/unsafe-pr-checkout-helper.ts diff --git a/README.md b/README.md index f0f65f9..50fd4b8 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,12 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ # running from unless specified. Example URLs are https://github.com or # https://my-ghes-server.example.com github-server-url: '' + + # Required to check out fork pull request code from a workflow triggered by + # `pull_request_target` or `workflow_run`. See [Pwn Requests](todo:need-link) for + # the risks. Set to `true` only after reviewing the risks. + # Default: false + allow-unsafe-pr-checkout: '' ``` diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index ad3566a..3c4f049 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -1173,7 +1173,8 @@ async function setup(testName: string): Promise { sshUser: '', workflowOrganizationId: 123456, setSafeDirectory: true, - githubServerUrl: githubServerUrl + githubServerUrl: githubServerUrl, + allowUnsafePrCheckout: false } } diff --git a/__test__/input-helper.test.ts b/__test__/input-helper.test.ts index 09331eb..25b6d18 100644 --- a/__test__/input-helper.test.ts +++ b/__test__/input-helper.test.ts @@ -91,6 +91,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 () => { diff --git a/__test__/unsafe-pr-checkout-helper.test.ts b/__test__/unsafe-pr-checkout-helper.test.ts new file mode 100644 index 0000000..9634618 --- /dev/null +++ b/__test__/unsafe-pr-checkout-helper.test.ts @@ -0,0 +1,254 @@ +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' + +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} + }, + 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} + }, + 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} + } + } +} + +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: 'attacker/fork', + ref: 'refs/heads/main', + commit: '', + allowUnsafePrCheckout: false + }) + ).toThrow() + }) + + it('refuses pull_request_target ignoring repository case differences', () => { + setContext('pull_request_target', forkPullRequestTargetPayload()) + expect(() => + assertSafePrCheckout({ + qualifiedRepository: 'SOME-OWNER/SOME-REPO', + ref: '', + commit: PR_HEAD_SHA, + 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() + }) +}) diff --git a/action.yml b/action.yml index 767c416..d69cdc1 100644 --- a/action.yml +++ b/action.yml @@ -98,6 +98,12 @@ inputs: github-server-url: description: The base URL for the GitHub instance that you are trying to clone from, will use environment defaults to fetch from the same instance that the workflow is running from unless specified. Example URLs are https://github.com or https://my-ghes-server.example.com required: false + allow-unsafe-pr-checkout: + description: > + Required to check out fork pull request code from a workflow triggered by + `pull_request_target` or `workflow_run`. See [Pwn Requests](todo:need-link) + for the risks. Set to `true` only after reviewing the risks. + default: false outputs: ref: description: 'The branch, tag or SHA that was checked out' diff --git a/dist/index.js b/dist/index.js index 906b59a..cf9067d 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2023,6 +2023,7 @@ const core = __importStar(__nccwpck_require__(2186)); const fsHelper = __importStar(__nccwpck_require__(7219)); const github = __importStar(__nccwpck_require__(5438)); const path = __importStar(__nccwpck_require__(1017)); +const unsafePrCheckoutHelper = __importStar(__nccwpck_require__(843)); const workflowContextHelper = __importStar(__nccwpck_require__(9568)); function getInputs() { return __awaiter(this, void 0, void 0, function* () { @@ -2144,6 +2145,17 @@ function getInputs() { // Determine the GitHub URL that the repository is being hosted from result.githubServerUrl = core.getInput('github-server-url'); core.debug(`GitHub Host URL = ${result.githubServerUrl}`); + // 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}`); + unsafePrCheckoutHelper.assertSafePrCheckout({ + qualifiedRepository, + ref: result.ref, + commit: result.commit, + allowUnsafePrCheckout: result.allowUnsafePrCheckout + }); return result; }); } @@ -2284,6 +2296,7 @@ exports.getRefSpecForAllHistory = getRefSpecForAllHistory; exports.getRefSpec = getRefSpec; exports.testRef = testRef; exports.checkCommitInfo = checkCommitInfo; +exports.fromPayload = fromPayload; const core = __importStar(__nccwpck_require__(2186)); const github = __importStar(__nccwpck_require__(5438)); const url_helper_1 = __nccwpck_require__(9437); @@ -2732,6 +2745,97 @@ if (!exports.IsPost) { } +/***/ }), + +/***/ 843: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (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 = assertSafePrCheckout; +const github = __importStar(__nccwpck_require__(5438)); +const ref_helper_1 = __nccwpck_require__(8601); +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; + const prShas = []; + if (eventName === 'pull_request_target') { + prHeadRepoId = (0, ref_helper_1.fromPayload)('pull_request.head.repo.id'); + 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'); + pushIfSha(prShas, (0, ref_helper_1.fromPayload)('workflow_run.head_commit.id')); + } + // (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 baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}`; + const repositoryDiffersFromBase = input.qualifiedRepository.toLowerCase() !== + baseQualifiedRepository.toLowerCase(); + const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref); + const commitMatchesPrHeadSha = !!input.commit && prShas.includes(input.commit.toLowerCase()); + if (!repositoryDiffersFromBase && + !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 fork's code in that trusted context is a ` + + `"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, set ` + + `'allow-unsafe-pr-checkout: true' on the actions/checkout step.`); +} +function pushIfSha(target, value) { + if (typeof value === 'string' && value.length > 0) { + target.push(value.toLowerCase()); + } +} + + /***/ }), /***/ 9437: diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts index 4e41ac3..79041c4 100644 --- a/src/git-source-settings.ts +++ b/src/git-source-settings.ts @@ -118,4 +118,10 @@ export interface IGitSourceSettings { * User override on the GitHub Server/Host URL that hosts the repository to be cloned */ githubServerUrl: string | undefined + + /** + * Opt-in to allow checking out fork pull request code from a workflow + * triggered by pull_request_target or workflow_run. + */ + allowUnsafePrCheckout: boolean } diff --git a/src/input-helper.ts b/src/input-helper.ts index e0c61e2..2d20930 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -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' @@ -161,5 +162,18 @@ export async function getInputs(): Promise { result.githubServerUrl = core.getInput('github-server-url') core.debug(`GitHub Host URL = ${result.githubServerUrl}`) + // 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}`) + + unsafePrCheckoutHelper.assertSafePrCheckout({ + qualifiedRepository, + ref: result.ref, + commit: result.commit, + allowUnsafePrCheckout: result.allowUnsafePrCheckout + }) + return result } diff --git a/src/ref-helper.ts b/src/ref-helper.ts index 71e8b22..8751712 100644 --- a/src/ref-helper.ts +++ b/src/ref-helper.ts @@ -292,7 +292,7 @@ export async function checkCommitInfo( } } -function fromPayload(path: string): any { +export function fromPayload(path: string): any { return select(github.context.payload, path) } diff --git a/src/unsafe-pr-checkout-helper.ts b/src/unsafe-pr-checkout-helper.ts new file mode 100644 index 0000000..860d5ed --- /dev/null +++ b/src/unsafe-pr-checkout-helper.ts @@ -0,0 +1,80 @@ +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 + 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 + const prShas: string[] = [] + + if (eventName === 'pull_request_target') { + prHeadRepoId = fromPayload('pull_request.head.repo.id') + 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') + pushIfSha(prShas, fromPayload('workflow_run.head_commit.id')) + } + + // (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 baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}` + const repositoryDiffersFromBase = + input.qualifiedRepository.toLowerCase() !== + baseQualifiedRepository.toLowerCase() + const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref) + const commitMatchesPrHeadSha = + !!input.commit && prShas.includes(input.commit.toLowerCase()) + + if ( + !repositoryDiffersFromBase && + !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 fork's code in that trusted context is a ` + + `"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, 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()) + } +} From 12a489776f8aacd88a4d1e19094ae82eff13f352 Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:12:03 +0000 Subject: [PATCH 2/8] address copilot and reviewer feedback --- __test__/unsafe-pr-checkout-helper.test.ts | 25 ++++++++++++++++------ dist/index.js | 16 ++++++++++---- src/unsafe-pr-checkout-helper.ts | 20 +++++++++++------ 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/__test__/unsafe-pr-checkout-helper.test.ts b/__test__/unsafe-pr-checkout-helper.test.ts index 9634618..9efa246 100644 --- a/__test__/unsafe-pr-checkout-helper.test.ts +++ b/__test__/unsafe-pr-checkout-helper.test.ts @@ -13,6 +13,7 @@ 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 @@ -25,7 +26,7 @@ function forkPullRequestTargetPayload(): object { pull_request: { head: { sha: PR_HEAD_SHA, - repo: {id: FORK_REPO_ID} + repo: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO} }, merge_commit_sha: PR_MERGE_SHA } @@ -38,7 +39,7 @@ function sameRepoPullRequestTargetPayload(): object { pull_request: { head: { sha: PR_HEAD_SHA, - repo: {id: BASE_REPO_ID} + repo: {id: BASE_REPO_ID, full_name: BASE_QUALIFIED_REPO} }, merge_commit_sha: PR_MERGE_SHA } @@ -51,7 +52,7 @@ function forkWorkflowRunPayload(): object { workflow_run: { event: 'pull_request', head_commit: {id: WORKFLOW_RUN_HEAD_COMMIT_SHA}, - head_repository: {id: FORK_REPO_ID} + head_repository: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO} } } } @@ -164,7 +165,7 @@ describe('unsafe-pr-checkout-helper', () => { setContext('pull_request_target', forkPullRequestTargetPayload()) expect(() => assertSafePrCheckout({ - qualifiedRepository: 'attacker/fork', + qualifiedRepository: FORK_QUALIFIED_REPO, ref: 'refs/heads/main', commit: '', allowUnsafePrCheckout: false @@ -172,13 +173,25 @@ describe('unsafe-pr-checkout-helper', () => { ).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: 'SOME-OWNER/SOME-REPO', + qualifiedRepository: FORK_QUALIFIED_REPO.toUpperCase(), ref: '', - commit: PR_HEAD_SHA, + commit: '', allowUnsafePrCheckout: false }) ).toThrow() diff --git a/dist/index.js b/dist/index.js index cf9067d..0acfdf1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2793,9 +2793,11 @@ function assertSafePrCheckout(input) { 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')); } @@ -2805,7 +2807,13 @@ function assertSafePrCheckout(input) { 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) { @@ -2813,12 +2821,12 @@ function assertSafePrCheckout(input) { } // (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 baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}`; - const repositoryDiffersFromBase = input.qualifiedRepository.toLowerCase() !== - baseQualifiedRepository.toLowerCase(); + 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 (!repositoryDiffersFromBase && + if (!repositoryMatchesPrHead && !refMatchesPullPattern && !commitMatchesPrHeadSha) { return; diff --git a/src/unsafe-pr-checkout-helper.ts b/src/unsafe-pr-checkout-helper.ts index 860d5ed..3e956f3 100644 --- a/src/unsafe-pr-checkout-helper.ts +++ b/src/unsafe-pr-checkout-helper.ts @@ -6,7 +6,7 @@ const PR_REF_PATTERN = /^refs\/pull\/[0-9]+\/(?:head|merge)$/ export interface IUnsafePrCheckoutInput { qualifiedRepository: string ref: string - commit: string + commit: string | undefined allowUnsafePrCheckout: boolean } @@ -26,10 +26,12 @@ export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void { } 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 { @@ -38,7 +40,13 @@ export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void { 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? @@ -48,16 +56,16 @@ export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void { // (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 baseQualifiedRepository = `${github.context.repo.owner}/${github.context.repo.repo}` - const repositoryDiffersFromBase = - input.qualifiedRepository.toLowerCase() !== - baseQualifiedRepository.toLowerCase() + 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 ( - !repositoryDiffersFromBase && + !repositoryMatchesPrHead && !refMatchesPullPattern && !commitMatchesPrHeadSha ) { From c12eb249cb5eb240b7f2358a90c9ee9a555bd05d Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:18:29 +0000 Subject: [PATCH 3/8] run prettier formatting --- src/unsafe-pr-checkout-helper.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/unsafe-pr-checkout-helper.ts b/src/unsafe-pr-checkout-helper.ts index 3e956f3..9d4c0d5 100644 --- a/src/unsafe-pr-checkout-helper.ts +++ b/src/unsafe-pr-checkout-helper.ts @@ -58,8 +58,7 @@ export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void { // 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() + input.qualifiedRepository.toLowerCase() === prHeadRepoFullName.toLowerCase() const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref) const commitMatchesPrHeadSha = !!input.commit && prShas.includes(input.commit.toLowerCase()) From c2edb9a7407067e94b7af06b290da20c8e50a2fd Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:26:12 +0000 Subject: [PATCH 4/8] build --- dist/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dist/index.js b/dist/index.js index 0acfdf1..cc237ad 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2822,8 +2822,7 @@ function assertSafePrCheckout(input) { // (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(); + input.qualifiedRepository.toLowerCase() === prHeadRepoFullName.toLowerCase(); const refMatchesPullPattern = PR_REF_PATTERN.test(input.ref); const commitMatchesPrHeadSha = !!input.commit && prShas.includes(input.commit.toLowerCase()); if (!repositoryMatchesPrHead && From 678aa28ba166d857e7dc762375613b26a196785d Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:07:36 +0000 Subject: [PATCH 5/8] update urls --- README.md | 7 +++++-- action.yml | 7 +++++-- dist/index.js | 5 +++-- src/unsafe-pr-checkout-helper.ts | 5 +++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 50fd4b8..11d2bd0 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,11 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ github-server-url: '' # Required to check out fork pull request code from a workflow triggered by - # `pull_request_target` or `workflow_run`. See [Pwn Requests](todo:need-link) for - # the risks. Set to `true` only after reviewing the risks. + # `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 a fork's code in that trusted context is the "pwn request" + # supply-chain attack pattern. Set to `true` only after reviewing the risks at + # https://gh.io/allow-unsafe-pr-checkout. # Default: false allow-unsafe-pr-checkout: '' ``` diff --git a/action.yml b/action.yml index d69cdc1..a2a5a1d 100644 --- a/action.yml +++ b/action.yml @@ -101,8 +101,11 @@ inputs: allow-unsafe-pr-checkout: description: > Required to check out fork pull request code from a workflow triggered by - `pull_request_target` or `workflow_run`. See [Pwn Requests](todo:need-link) - for the risks. Set to `true` only after reviewing the risks. + `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 a fork's code in that trusted context is a + "pwn request" supply-chain attack pattern. Set to `true` only after + reviewing the risks at https://gh.io/allow-unsafe-pr-checkout. default: false outputs: ref: diff --git a/dist/index.js b/dist/index.js index cc237ad..1e0b022 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2833,8 +2833,9 @@ function assertSafePrCheckout(input) { 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 fork's code in that trusted context is a ` + - `"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, set ` + - `'allow-unsafe-pr-checkout: true' on the actions/checkout step.`); + `"pwn request" supply-chain attack pattern. To opt in after reviewing the risks at ` + + `https://gh.io/allow-unsafe-pr-checkout, set 'allow-unsafe-pr-checkout: true' on the ` + + `actions/checkout step.`); } function pushIfSha(target, value) { if (typeof value === 'string' && value.length > 0) { diff --git a/src/unsafe-pr-checkout-helper.ts b/src/unsafe-pr-checkout-helper.ts index 9d4c0d5..7992caf 100644 --- a/src/unsafe-pr-checkout-helper.ts +++ b/src/unsafe-pr-checkout-helper.ts @@ -75,8 +75,9 @@ export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void { `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 fork's code in that trusted context is a ` + - `"pwn request" supply-chain attack pattern. To opt in after reviewing the risk, set ` + - `'allow-unsafe-pr-checkout: true' on the actions/checkout step.` + `"pwn request" supply-chain attack pattern. To opt in after reviewing the risks at ` + + `https://gh.io/allow-unsafe-pr-checkout, set 'allow-unsafe-pr-checkout: true' on the ` + + `actions/checkout step.` ) } From cb140b4908d6f8745205667913eadda5043605a4 Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:16:22 +0000 Subject: [PATCH 6/8] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11d2bd0..5014988 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ # 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 a fork's code in that trusted context is the "pwn request" + # access; fetching a fork's code in that trusted context is a "pwn request" # supply-chain attack pattern. Set to `true` only after reviewing the risks at # https://gh.io/allow-unsafe-pr-checkout. # Default: false From baaeba4a5e5144b3ac1268004c3c64433d3ec4a7 Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:07:44 +0000 Subject: [PATCH 7/8] update description and url again --- README.md | 6 +++--- action.yml | 6 +++--- dist/index.js | 8 ++++---- src/unsafe-pr-checkout-helper.ts | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5014988..f184d30 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,9 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ # 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 a fork's code in that trusted context is a "pwn request" - # supply-chain attack pattern. Set to `true` only after reviewing the risks at - # https://gh.io/allow-unsafe-pr-checkout. + # 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-checkout. # Default: false allow-unsafe-pr-checkout: '' ``` diff --git a/action.yml b/action.yml index a2a5a1d..a7321f2 100644 --- a/action.yml +++ b/action.yml @@ -103,9 +103,9 @@ inputs: 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 a fork's code in that trusted context is a - "pwn request" supply-chain attack pattern. Set to `true` only after - reviewing the risks at https://gh.io/allow-unsafe-pr-checkout. + 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-checkout. default: false outputs: ref: diff --git a/dist/index.js b/dist/index.js index 1e0b022..97b462a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2832,10 +2832,10 @@ function assertSafePrCheckout(input) { } 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 fork's code in that trusted context is a ` + - `"pwn request" supply-chain attack pattern. To opt in after reviewing the risks at ` + - `https://gh.io/allow-unsafe-pr-checkout, set 'allow-unsafe-pr-checkout: true' on the ` + - `actions/checkout step.`); + `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 after reviewing ` + + `the risks at https://gh.io/securely-using-pull-request-checkout, set ` + + `'allow-unsafe-pr-checkout: true' on the actions/checkout step.`); } function pushIfSha(target, value) { if (typeof value === 'string' && value.length > 0) { diff --git a/src/unsafe-pr-checkout-helper.ts b/src/unsafe-pr-checkout-helper.ts index 7992caf..f3ff242 100644 --- a/src/unsafe-pr-checkout-helper.ts +++ b/src/unsafe-pr-checkout-helper.ts @@ -74,10 +74,10 @@ export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void { 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 fork's code in that trusted context is a ` + - `"pwn request" supply-chain attack pattern. To opt in after reviewing the risks at ` + - `https://gh.io/allow-unsafe-pr-checkout, set 'allow-unsafe-pr-checkout: true' on the ` + - `actions/checkout step.` + `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 after reviewing ` + + `the risks at https://gh.io/securely-using-pull-request-checkout, set ` + + `'allow-unsafe-pr-checkout: true' on the actions/checkout step.` ) } From 921dc8dd2451069dc30c6c7363f96784f772eed5 Mon Sep 17 00:00:00 2001 From: Aiqiao Yan <55104035+aiqiaoy@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:41:19 +0000 Subject: [PATCH 8/8] edit url one more time --- README.md | 2 +- action.yml | 2 +- dist/index.js | 2 +- src/unsafe-pr-checkout-helper.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f184d30..240c4b6 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Please refer to the [release page](https://github.com/actions/checkout/releases/ # 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-checkout. + # risks at https://gh.io/securely-using-pull_request_target. # Default: false allow-unsafe-pr-checkout: '' ``` diff --git a/action.yml b/action.yml index a7321f2..5b0524f 100644 --- a/action.yml +++ b/action.yml @@ -105,7 +105,7 @@ inputs: 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-checkout. + only after reviewing the risks at https://gh.io/securely-using-pull_request_target. default: false outputs: ref: diff --git a/dist/index.js b/dist/index.js index 97b462a..b0f11a1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2834,7 +2834,7 @@ function assertSafePrCheckout(input) { `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 after reviewing ` + - `the risks at https://gh.io/securely-using-pull-request-checkout, set ` + + `the risks at https://gh.io/securely-using-pull_request_target, set ` + `'allow-unsafe-pr-checkout: true' on the actions/checkout step.`); } function pushIfSha(target, value) { diff --git a/src/unsafe-pr-checkout-helper.ts b/src/unsafe-pr-checkout-helper.ts index f3ff242..efc0ef6 100644 --- a/src/unsafe-pr-checkout-helper.ts +++ b/src/unsafe-pr-checkout-helper.ts @@ -76,7 +76,7 @@ export function assertSafePrCheckout(input: IUnsafePrCheckoutInput): void { `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 after reviewing ` + - `the risks at https://gh.io/securely-using-pull-request-checkout, set ` + + `the risks at https://gh.io/securely-using-pull_request_target, set ` + `'allow-unsafe-pr-checkout: true' on the actions/checkout step.` ) }