diff --git a/README.md b/README.md index f0f65f9..c36bea3 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,13 @@ 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: '' + + # Suppress the warning when pull_request_target checks out a non-default branch + # from the workflow repository. Only set this to true when you understand the + # security risk of running untrusted pull request code in a privileged context. + # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + # Default: false + dangerously-checkout-non-default-branch: '' ``` diff --git a/__test__/input-helper.test.ts b/__test__/input-helper.test.ts index 09331eb..311823f 100644 --- a/__test__/input-helper.test.ts +++ b/__test__/input-helper.test.ts @@ -55,6 +55,15 @@ describe('input-helper tests', () => { beforeEach(() => { // Reset inputs inputs = {} + github.context.eventName = 'push' + github.context.ref = 'refs/heads/some-ref' + github.context.sha = '1234567890123456789012345678901234567890' + github.context.payload = { + repository: { + default_branch: 'main' + } + } as any + jest.clearAllMocks() }) afterAll(() => { @@ -65,6 +74,8 @@ describe('input-helper tests', () => { } // Restore @actions/github context + github.context.eventName = originalContext.eventName + github.context.payload = originalContext.payload github.context.ref = originalContext.ref github.context.sha = originalContext.sha @@ -150,6 +161,75 @@ describe('input-helper tests', () => { expect(settings.commit).toBeFalsy() }) + it('warns when pull_request_target checks out a non-default branch', async () => { + github.context.eventName = 'pull_request_target' + inputs.ref = 'some-other-ref' + + await inputHelper.getInputs() + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining( + 'Checking out a non-default branch from pull_request_target' + ) + ) + }) + + it('does not warn when pull_request_target checks out the default branch name', async () => { + github.context.eventName = 'pull_request_target' + inputs.ref = 'main' + + await inputHelper.getInputs() + + expect(core.warning).not.toHaveBeenCalled() + }) + + it('does not warn when pull_request_target checks out the fully qualified default branch', async () => { + github.context.eventName = 'pull_request_target' + inputs.ref = 'refs/heads/main' + + await inputHelper.getInputs() + + expect(core.warning).not.toHaveBeenCalled() + }) + + it('does not warn when pull_request_target checks out the default branch sha', async () => { + github.context.eventName = 'pull_request_target' + inputs.ref = '1234567890123456789012345678901234567890' + + await inputHelper.getInputs() + + expect(core.warning).not.toHaveBeenCalled() + }) + + it('does not warn when dangerously-checkout-non-default-branch suppresses the warning', async () => { + github.context.eventName = 'pull_request_target' + inputs.ref = 'some-other-ref' + inputs['dangerously-checkout-non-default-branch'] = 'true' + + await inputHelper.getInputs() + + expect(core.warning).not.toHaveBeenCalled() + }) + + it('does not warn when pull_request checks out a non-default branch', async () => { + github.context.eventName = 'pull_request' + inputs.ref = 'some-other-ref' + + await inputHelper.getInputs() + + expect(core.warning).not.toHaveBeenCalled() + }) + + it('does not warn when pull_request_target checks out a different repository', async () => { + github.context.eventName = 'pull_request_target' + inputs.repository = 'some-owner/some-other-repo' + inputs.ref = 'some-other-ref' + + await inputHelper.getInputs() + + expect(core.warning).not.toHaveBeenCalled() + }) + it('sets workflow organization ID', async () => { const settings: IGitSourceSettings = await inputHelper.getInputs() expect(settings.workflowOrganizationId).toBe(123456) diff --git a/action.yml b/action.yml index 767c416..9d31742 100644 --- a/action.yml +++ b/action.yml @@ -98,6 +98,14 @@ 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 + dangerously-checkout-non-default-branch: + description: > + Suppress the warning when pull_request_target checks out a non-default + branch from the workflow repository. Only set this to true when you + understand the security risk of running untrusted pull request code in a + privileged context. + https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ + 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 57729b2..974fc65 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2008,7 +2008,8 @@ function getInputs() { const isWorkflowRepository = qualifiedRepository.toUpperCase() === `${github.context.repo.owner}/${github.context.repo.repo}`.toUpperCase(); // Source branch, source version - result.ref = core.getInput('ref'); + const inputRef = core.getInput('ref'); + result.ref = inputRef; if (!result.ref) { if (isWorkflowRepository) { result.ref = github.context.ref; @@ -2027,6 +2028,16 @@ function getInputs() { } core.debug(`ref = '${result.ref}'`); core.debug(`commit = '${result.commit}'`); + // Warn when pull_request_target checks out non-default code from the workflow repository. + // This event runs in the base repository context, so checking out PR-controlled code can be risky. + const suppressNonDefaultBranchWarning = (core.getInput('dangerously-checkout-non-default-branch') || 'false').toUpperCase() === 'TRUE'; + if (github.context.eventName === 'pull_request_target' && + isWorkflowRepository && + inputRef && + !suppressNonDefaultBranchWarning && + !isDefaultBranchRef(inputRef)) { + core.warning('Checking out a non-default branch from pull_request_target can put untrusted pull request code in a privileged context. If this is intentional, set dangerously-checkout-non-default-branch: true. Consider using pull_request or pull_request plus workflow_run instead. See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/'); + } // Clean result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE'; core.debug(`clean = ${result.clean}`); @@ -2098,6 +2109,15 @@ function getInputs() { return result; }); } +function isDefaultBranchRef(ref) { + var _a; + const defaultBranch = (_a = github.context.payload.repository) === null || _a === void 0 ? void 0 : _a.default_branch; + if (defaultBranch && + (ref === defaultBranch || ref === `refs/heads/${defaultBranch}`)) { + return true; + } + return ref.toUpperCase() === github.context.sha.toUpperCase(); +} /***/ }), diff --git a/src/input-helper.ts b/src/input-helper.ts index e0c61e2..f10808a 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -57,7 +57,8 @@ export async function getInputs(): Promise { `${github.context.repo.owner}/${github.context.repo.repo}`.toUpperCase() // Source branch, source version - result.ref = core.getInput('ref') + const inputRef = core.getInput('ref') + result.ref = inputRef if (!result.ref) { if (isWorkflowRepository) { result.ref = github.context.ref @@ -78,6 +79,24 @@ export async function getInputs(): Promise { core.debug(`ref = '${result.ref}'`) core.debug(`commit = '${result.commit}'`) + // Warn when pull_request_target checks out non-default code from the workflow repository. + // This event runs in the base repository context, so checking out PR-controlled code can be risky. + const suppressNonDefaultBranchWarning = + ( + core.getInput('dangerously-checkout-non-default-branch') || 'false' + ).toUpperCase() === 'TRUE' + if ( + github.context.eventName === 'pull_request_target' && + isWorkflowRepository && + inputRef && + !suppressNonDefaultBranchWarning && + !isDefaultBranchRef(inputRef) + ) { + core.warning( + 'Checking out a non-default branch from pull_request_target can put untrusted pull request code in a privileged context. If this is intentional, set dangerously-checkout-non-default-branch: true. Consider using pull_request or pull_request plus workflow_run instead. See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/' + ) + } + // Clean result.clean = (core.getInput('clean') || 'true').toUpperCase() === 'TRUE' core.debug(`clean = ${result.clean}`) @@ -163,3 +182,16 @@ export async function getInputs(): Promise { return result } + +function isDefaultBranchRef(ref: string): boolean { + const defaultBranch = (github.context.payload.repository as any) + ?.default_branch + if ( + defaultBranch && + (ref === defaultBranch || ref === `refs/heads/${defaultBranch}`) + ) { + return true + } + + return ref.toUpperCase() === github.context.sha.toUpperCase() +}