diff --git a/.github/workflows/contributor-rewards.yml b/.github/workflows/contributor-rewards.yml index 88ee1cf1c..e2a6a9311 100644 --- a/.github/workflows/contributor-rewards.yml +++ b/.github/workflows/contributor-rewards.yml @@ -1,4 +1,21 @@ --- +# Security note: this workflow intentionally uses `pull_request_target` so the +# `reward user` label can fire on PRs from forks (the plain `pull_request` +# trigger cannot see labels applied to fork PRs and runs under a read-only +# GITHUB_TOKEN that cannot post comments / add labels). +# +# The job is bounded: +# - it never checks out fork code (no `actions/checkout` with `pull_request.head.ref`) +# - it never executes arbitrary scripts from the PR +# - the only side effects are: posting a templated comment and adding a label +# - permissions are minimal (contents:read, issues:write, pull-requests:read) +# - all PR/issue user metadata interpolated into search queries or comment +# bodies is treated as untrusted and normalized through safeLogin/safeUrl +# before use (see the inline script below). +# +# Do NOT broaden permissions, add a fork checkout, or remove the input +# normalization without a security review. + name: Contributor Rewards on: @@ -47,6 +64,25 @@ jobs: const merchUrl = (process.env.CONTRIBUTOR_REWARD_MERCH_URL || '').trim(); const customMessage = (process.env.CONTRIBUTOR_REWARD_MESSAGE || '').trim(); + // Treat all PR/issue metadata as untrusted display-only data — this + // workflow runs under `pull_request_target` so anything coming from + // a fork (login, html_url) crosses a trust boundary. Normalize + // before interpolating into search queries or comment bodies. + // GitHub usernames are 1-39 chars, alphanumeric + hyphen. + const safeLogin = (s) => String(s || '').replace(/[^A-Za-z0-9-]/g, '').slice(0, 39); + // Only allow github.com / *.github.com (covers GitHub Enterprise). + // Strip query/fragment to defeat comment-body injection via crafted + // URLs. Return '' if the URL is unparseable or off-host. + const safeUrl = (s) => { + try { + const u = new URL(String(s || '')); + const okHost = u.hostname === 'github.com' || u.hostname.endsWith('.github.com'); + return okHost ? `${u.origin}${u.pathname}` : ''; + } catch { + return ''; + } + }; + const normalize = value => String(value || '').trim().toLowerCase(); const triggerLabelKey = normalize(triggerLabel); @@ -94,12 +130,19 @@ jobs: return null; } + const login = safeLogin(pr.user?.login); + if (!login) { + core.info(`PR #${pr.number} has an unrecognized author login shape; skipping.`); + return null; + } + const htmlUrl = safeUrl(pr.html_url); + return { kind: 'pull request', number: pr.number, - htmlUrl: pr.html_url, - login: pr.user.login, - userType: pr.user.type, + htmlUrl, + login, + userType: pr.user?.type, reason: 'first merged pull request', manualOverride: false, requireFirstMergedPr: true, @@ -112,12 +155,19 @@ jobs: return null; } + const login = safeLogin(pr.user?.login); + if (!login) { + core.info(`PR #${pr.number} has an unrecognized author login shape; skipping.`); + return null; + } + const htmlUrl = safeUrl(pr.html_url); + return { kind: 'pull request', number: pr.number, - htmlUrl: pr.html_url, - login: pr.user.login, - userType: pr.user.type, + htmlUrl, + login, + userType: pr.user?.type, reason: `maintainer-applied "${triggerLabel}" label on a pull request`, manualOverride: true, requireFirstMergedPr: false, @@ -139,12 +189,19 @@ jobs: return null; } + const login = safeLogin(issue.user?.login); + if (!login) { + core.info(`Issue #${issue.number} has an unrecognized author login shape; skipping.`); + return null; + } + const htmlUrl = safeUrl(issue.html_url); + return { kind: 'issue', number: issue.number, - htmlUrl: issue.html_url, - login: issue.user.login, - userType: issue.user.type, + htmlUrl, + login, + userType: issue.user?.type, reason: `maintainer-applied "${triggerLabel}" label on an issue`, manualOverride: true, requireFirstMergedPr: false,