fix(ci): harden contributor-rewards pull_request_target (#2621) (#2628)

This commit is contained in:
oxoxDev
2026-05-25 09:17:11 -07:00
committed by GitHub
parent 5493b92be2
commit 1eb5b24b10
+66 -9
View File
@@ -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,