From 8fe7f425e1683bf4bbd6ddb7f583e33b0fe51a93 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 15:06:54 -0500 Subject: [PATCH 01/10] Harden deploy preview trust boundary --- .github/scripts/resolve-workflow-run-pr.mjs | 43 +++++++ .../scripts/resolve-workflow-run-pr.test.mjs | 109 ++++++++++++++++++ .github/workflows/preview-build.yml | 3 + .github/workflows/preview-deploy.yml | 75 ++++++++---- 4 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 .github/scripts/resolve-workflow-run-pr.mjs create mode 100644 .github/scripts/resolve-workflow-run-pr.test.mjs diff --git a/.github/scripts/resolve-workflow-run-pr.mjs b/.github/scripts/resolve-workflow-run-pr.mjs new file mode 100644 index 0000000..3a027db --- /dev/null +++ b/.github/scripts/resolve-workflow-run-pr.mjs @@ -0,0 +1,43 @@ +export default async function resolveWorkflowRunPr({ + github, + context, + expectedBaseBranch, +}) { + const workflowRun = context.payload.workflow_run; + const headRepository = workflowRun?.head_repository?.full_name; + const headBranch = workflowRun?.head_branch; + const headSha = workflowRun?.head_sha; + + if (!headRepository || !headBranch || !headSha) { + throw new Error("Workflow run is missing head repository, branch, or SHA"); + } + + const baseRepository = `${context.repo.owner}/${context.repo.repo}`; + const pullRequests = await github.paginate(github.rest.pulls.list, { + ...context.repo, + state: "open", + base: expectedBaseBranch, + per_page: 100, + }); + const matches = pullRequests.filter( + (pullRequest) => + pullRequest.head.repo?.full_name === headRepository && + pullRequest.head.ref === headBranch && + pullRequest.head.sha === headSha && + pullRequest.base.repo?.full_name === baseRepository && + pullRequest.base.ref === expectedBaseBranch, + ); + + if (matches.length !== 1) { + throw new Error( + `Expected exactly one open pull request for workflow run ${workflowRun.id}; found ${matches.length}`, + ); + } + + const pullRequestNumber = matches[0].number; + if (!Number.isSafeInteger(pullRequestNumber) || pullRequestNumber < 1) { + throw new Error("GitHub returned an invalid pull request number"); + } + + return pullRequestNumber; +} diff --git a/.github/scripts/resolve-workflow-run-pr.test.mjs b/.github/scripts/resolve-workflow-run-pr.test.mjs new file mode 100644 index 0000000..a41cf13 --- /dev/null +++ b/.github/scripts/resolve-workflow-run-pr.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import resolveWorkflowRunPr from "./resolve-workflow-run-pr.mjs"; + +const listPullRequests = () => {}; + +function makeContext(overrides = {}) { + return { + repo: { owner: "graphql", repo: "gaps" }, + payload: { + workflow_run: { + id: 123, + head_repository: { full_name: "contributor/gaps" }, + head_branch: "preview", + head_sha: "abc123", + ...overrides, + }, + }, + }; +} + +function makePullRequest(overrides = {}) { + return { + number: 40, + head: { + repo: { full_name: "contributor/gaps" }, + ref: "preview", + sha: "abc123", + }, + base: { + repo: { full_name: "graphql/gaps" }, + ref: "main", + }, + ...overrides, + }; +} + +function makeGithub(pullRequests) { + return { + rest: { pulls: { list: listPullRequests } }, + paginate: async (method, parameters) => { + assert.equal(method, listPullRequests); + assert.deepEqual(parameters, { + owner: "graphql", + repo: "gaps", + state: "open", + base: "main", + per_page: 100, + }); + return pullRequests; + }, + }; +} + +test("resolves the PR using the complete workflow identity", async () => { + const shellMetacharacterBranch = "preview$(touch${IFS}/tmp/pwned)"; + const exactMatch = makePullRequest({ + head: { + repo: { full_name: "contributor/gaps" }, + ref: shellMetacharacterBranch, + sha: "abc123", + }, + }); + const sameBranchFromAnotherFork = makePullRequest({ + number: 41, + head: { + repo: { full_name: "attacker/gaps" }, + ref: shellMetacharacterBranch, + sha: "abc123", + }, + }); + + const result = await resolveWorkflowRunPr({ + github: makeGithub([sameBranchFromAnotherFork, exactMatch]), + context: makeContext({ head_branch: shellMetacharacterBranch }), + expectedBaseBranch: "main", + }); + + assert.equal(result, 40); +}); + +test("rejects stale and ambiguous workflow runs", async () => { + await assert.rejects( + resolveWorkflowRunPr({ + github: makeGithub([ + makePullRequest({ + head: { + repo: { full_name: "contributor/gaps" }, + ref: "preview", + sha: "newer-sha", + }, + }), + ]), + context: makeContext(), + expectedBaseBranch: "main", + }), + /found 0/, + ); + + await assert.rejects( + resolveWorkflowRunPr({ + github: makeGithub([makePullRequest(), makePullRequest({ number: 41 })]), + context: makeContext(), + expectedBaseBranch: "main", + }), + /found 2/, + ); +}); diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml index 06d1239..bbfa323 100644 --- a/.github/workflows/preview-build.yml +++ b/.github/workflows/preview-build.yml @@ -26,6 +26,9 @@ jobs: node-version: "24" cache: "npm" + - name: Test workflow helpers + run: node --test .github/scripts/*.test.mjs + - name: Install dependencies run: npm ci diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 5956201..c4688e5 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -15,19 +15,34 @@ jobs: name: Deploy preview to Cloudflare runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }} + concurrency: + group: preview-deploy-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true steps: - name: Checkout wrangler config uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: main + persist-credentials: false + sparse-checkout-cone-mode: false sparse-checkout: | wrangler.jsonc .github/scripts + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "24" + + # Install before the Cloudflare token is exposed to the job. The action + # will verify and reuse this exact local version instead of installing a + # floating Wrangler release while holding the token. + - name: Install pinned Wrangler + run: npm install --no-save --package-lock=false --no-audit --no-fund wrangler@4.124.0 + # Security: actions/download-artifact handles zip extraction safely - # (immune to zip-slip), and the artifact only contains static HTML/CSS/JS - # built by the unprivileged preview-build workflow. + # (immune to zip-slip). Treat every extracted file as attacker-controlled. - name: Download build artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -36,19 +51,27 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} path: _site - - name: Get PR number - id: pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Cloudflare parses these files as deployment configuration rather than + # serving them as static assets. They must only come from trusted code. + - name: Reject deployment config from artifact run: | - PR_NUM=$(gh pr list --repo "${{ github.repository }}" \ - --head "${{ github.event.workflow_run.head_branch }}" \ - --json number --jq '.[0].number') - if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then - echo "::error::Could not determine PR number" + if [[ -e _site/_headers || -e _site/_redirects ]]; then + echo "::error::Preview artifact contains Cloudflare configuration" exit 1 fi - echo "number=$PR_NUM" >> "$GITHUB_OUTPUT" + + - name: Resolve current PR + id: pr + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + result-encoding: string + script: | + const { default: resolveWorkflowRunPr } = await import('${{ github.workspace }}/.github/scripts/resolve-workflow-run-pr.mjs'); + return resolveWorkflowRunPr({ + github, + context, + expectedBaseBranch: 'main', + }); # Security: Wrangler is configured for static asset serving only (see # wrangler.jsonc). The uploaded content is pre-built HTML/CSS/JS with no @@ -59,40 +82,52 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: versions upload --no-bundle --preview-alias "pr-${{ steps.pr.outputs.number }}" --message "PR #${{ steps.pr.outputs.number }} preview (${{ github.event.workflow_run.head_sha }})" + wranglerVersion: "4.124.0" + command: versions upload --no-bundle --preview-alias "pr-${{ steps.pr.outputs.result }}" --message "PR #${{ steps.pr.outputs.result }} preview (${{ github.event.workflow_run.head_sha }})" - name: Hide old deploy preview comments uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + PR_NUMBER: ${{ steps.pr.outputs.result }} with: script: | const { default: run } = await import('${{ github.workspace }}/.github/scripts/hide-old-deploy-comments.mjs'); - await run({ github, context, prNumber: ${{ steps.pr.outputs.number }} }); + await run({ github, context, prNumber: Number(process.env.PR_NUMBER) }); - name: Comment on PR (success) uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + PR_NUMBER: ${{ steps.pr.outputs.result }} with: script: | + const prNumber = Number(process.env.PR_NUMBER); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pr.outputs.number }}, + issue_number: prNumber, body: [ '', '### 🚀 Deploy Preview', '', - '- **This commit:** ${{ steps.deploy.outputs.deployment-url }}', - '- **This PR** (kept up to date): https://pr-${{ steps.pr.outputs.number }}-gaps.graphql-foundation.workers.dev' + '> [!WARNING]', + '> This site was built from pull request code and should be treated as untrusted.', + '', + `- **This commit:** ${process.env.DEPLOYMENT_URL}`, + `- **This PR** (kept up to date): https://pr-${prNumber}-gaps.graphql-foundation.workers.dev` ].join('\n') }); - name: Comment on PR (failure) - if: failure() + if: ${{ failure() && steps.pr.outputs.result != '' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + PR_NUMBER: ${{ steps.pr.outputs.result }} with: script: | await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pr.outputs.number }}, - body: 'Deploy preview failed.\n\n```\ngh run view ${{ github.run_id }} --repo ${{ github.repository }} --log-failed\n```' + issue_number: Number(process.env.PR_NUMBER), + body: `Deploy preview failed.\n\n\`\`\`\ngh run view ${context.runId} --repo ${context.repo.owner}/${context.repo.repo} --log-failed\n\`\`\`` }); From 008b0702976887603d2b4d46196011ba3695c330 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 16:18:47 -0500 Subject: [PATCH 02/10] Refine preview workflow validation --- .github/workflows/preview-build.yml | 3 --- .github/workflows/preview-deploy.yml | 28 ++++++++++++++-------------- .github/workflows/validate.yml | 3 +++ package.json | 1 + 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml index bbfa323..06d1239 100644 --- a/.github/workflows/preview-build.yml +++ b/.github/workflows/preview-build.yml @@ -26,9 +26,6 @@ jobs: node-version: "24" cache: "npm" - - name: Test workflow helpers - run: node --test .github/scripts/*.test.mjs - - name: Install dependencies run: npm ci diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index c4688e5..4e36b54 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -30,6 +30,19 @@ jobs: wrangler.jsonc .github/scripts + - name: Resolve current PR + id: pr + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + result-encoding: string + script: | + const { default: resolveWorkflowRunPr } = await import('${{ github.workspace }}/.github/scripts/resolve-workflow-run-pr.mjs'); + return resolveWorkflowRunPr({ + github, + context, + expectedBaseBranch: 'main', + }); + - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: @@ -60,19 +73,6 @@ jobs: exit 1 fi - - name: Resolve current PR - id: pr - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - with: - result-encoding: string - script: | - const { default: resolveWorkflowRunPr } = await import('${{ github.workspace }}/.github/scripts/resolve-workflow-run-pr.mjs'); - return resolveWorkflowRunPr({ - github, - context, - expectedBaseBranch: 'main', - }); - # Security: Wrangler is configured for static asset serving only (see # wrangler.jsonc). The uploaded content is pre-built HTML/CSS/JS with no # server-side execution capability. @@ -119,7 +119,7 @@ jobs: }); - name: Comment on PR (failure) - if: ${{ failure() && steps.pr.outputs.result != '' }} + if: ${{ failure() && steps.pr.outcome == 'success' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: PR_NUMBER: ${{ steps.pr.outputs.result }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f06af2c..d07135e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -24,5 +24,8 @@ jobs: - name: Install dependencies run: npm ci + - name: Test workflow helpers + run: npm run test:workflow-helpers + - name: Validate GAP structure run: npm run test:structure diff --git a/package.json b/package.json index a2621eb..b7abe40 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:format": "prettier --check . || npm run suggest:format", "test:spelling": "cspell \"spec/**/*.md\" README.md LICENSE.md", "test:structure": "find ./gaps -maxdepth 1 -type d -name 'GAP-*' | xargs -I{} ./scripts/validate-structure.js {}", + "test:workflow-helpers": "node --test .github/scripts/*.test.mjs", "sync:codeowners": "node scripts/sync-codeowners.js" }, "devDependencies": { From 652b5d80981e66b8c21c43dd3d6a3d1b0555a9ef Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 16:31:52 -0500 Subject: [PATCH 03/10] Remove ineffective preview warning --- .github/workflows/preview-deploy.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 4e36b54..958c634 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -110,9 +110,6 @@ jobs: '', '### 🚀 Deploy Preview', '', - '> [!WARNING]', - '> This site was built from pull request code and should be treated as untrusted.', - '', `- **This commit:** ${process.env.DEPLOYMENT_URL}`, `- **This PR** (kept up to date): https://pr-${prNumber}-gaps.graphql-foundation.workers.dev` ].join('\n') From 11462d4bb175b2e146eebdcaeeaf0d067853fea0 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 16:36:21 -0500 Subject: [PATCH 04/10] Explain failure comment guard --- .github/workflows/preview-deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 958c634..6ee3691 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -115,6 +115,8 @@ jobs: ].join('\n') }); + # GitHub requires a PR number for this comment. If checkout or PR + # resolution fails, there is no trustworthy PR to target. - name: Comment on PR (failure) if: ${{ failure() && steps.pr.outcome == 'success' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 From cc33a8da14f11ee45f1285047316b0521ea3c143 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 16:40:41 -0500 Subject: [PATCH 05/10] Use direct guard wording --- .github/workflows/preview-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 6ee3691..100891d 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -116,7 +116,7 @@ jobs: }); # GitHub requires a PR number for this comment. If checkout or PR - # resolution fails, there is no trustworthy PR to target. + # resolution fails, that number is unavailable. - name: Comment on PR (failure) if: ${{ failure() && steps.pr.outcome == 'success' }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 From 2621b5bdb871c4d38aa01a13d405f8b1a88dd094 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 16:40:54 -0500 Subject: [PATCH 06/10] Reject asset ignore configuration --- .github/workflows/preview-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 100891d..a51cf20 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -68,7 +68,7 @@ jobs: # serving them as static assets. They must only come from trusted code. - name: Reject deployment config from artifact run: | - if [[ -e _site/_headers || -e _site/_redirects ]]; then + if [[ -e _site/.assetsignore || -e _site/_headers || -e _site/_redirects ]]; then echo "::error::Preview artifact contains Cloudflare configuration" exit 1 fi From 31aa73a503f6019238a6dfbf75820d6bb7b4023b Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 16:52:41 -0500 Subject: [PATCH 07/10] Let Wrangler action install pinned CLI --- .github/workflows/preview-deploy.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index a51cf20..f19cef1 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -43,17 +43,6 @@ jobs: expectedBaseBranch: 'main', }); - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: "24" - - # Install before the Cloudflare token is exposed to the job. The action - # will verify and reuse this exact local version instead of installing a - # floating Wrangler release while holding the token. - - name: Install pinned Wrangler - run: npm install --no-save --package-lock=false --no-audit --no-fund wrangler@4.124.0 - # Security: actions/download-artifact handles zip extraction safely # (immune to zip-slip). Treat every extracted file as attacker-controlled. - name: Download build artifact From 9512689ae755208fd9720e1cfae5bee1cf1eb36d Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 17:14:46 -0500 Subject: [PATCH 08/10] Clarify preview config rejection --- .github/workflows/preview-deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index f19cef1..47a2679 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -53,8 +53,8 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} path: _site - # Cloudflare parses these files as deployment configuration rather than - # serving them as static assets. They must only come from trusted code. + # These are valid Cloudflare configuration files, but the PR-built artifact + # cannot supply them. Preview support would require trusted copies from main. - name: Reject deployment config from artifact run: | if [[ -e _site/.assetsignore || -e _site/_headers || -e _site/_redirects ]]; then From ed704e36a203266c311ec3528c538834737ea0ed Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 17:18:20 -0500 Subject: [PATCH 09/10] Clarify trusted config handling --- .github/workflows/preview-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 47a2679..f4590eb 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -54,7 +54,7 @@ jobs: path: _site # These are valid Cloudflare configuration files, but the PR-built artifact - # cannot supply them. Preview support would require trusted copies from main. + # cannot supply them. If main adds any, copy them from main into _site here. - name: Reject deployment config from artifact run: | if [[ -e _site/.assetsignore || -e _site/_headers || -e _site/_redirects ]]; then From 9afefde3ffe8c15adacc9f272ec3937ba89890da Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Wed, 19 Aug 2026 17:22:22 -0500 Subject: [PATCH 10/10] Use explicit config rejection wording --- .github/workflows/preview-deploy.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index f4590eb..ea541c5 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -53,8 +53,9 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} path: _site - # These are valid Cloudflare configuration files, but the PR-built artifact - # cannot supply them. If main adds any, copy them from main into _site here. + # These are valid Cloudflare configuration files. The PR-built artifact must + # not ever supply these. If the main branch ever adds these files for real, + # copy them over. - name: Reject deployment config from artifact run: | if [[ -e _site/.assetsignore || -e _site/_headers || -e _site/_redirects ]]; then