fix(github-actions): verify preview artifact metadata before use - #3986
ParthShethia25 wants to merge 2 commits into
Conversation
The upload-artifacts-to-firebase action reads the pull request number and the build revision out of the build artifact. That artifact is produced by the unprivileged build job, so a pull request author controls its contents. The values select the Firebase preview channel that is deployed to and the pull request the bot comments on, and neither was checked. Check the format of both values, and require the claimed pull request's head commit to match github.event.workflow_run.head_sha before using it. Take the build revision from the workflow_run payload instead of the artifact, and drop the unsafe- prefix from the outputs now that they are verified.
There was a problem hiding this comment.
Code Review
This pull request enhances the security of the artifact metadata extraction process by validating the artifact's claimed pull request against the triggering workflow run using the GitHub API. It introduces format checks for metadata values, fetches the pull request head SHA via Octokit, and verifies it against the trusted workflow run head SHA. Feedback on these changes suggests wrapping the GitHub API call in a try-catch block to provide more descriptive error messages on failure, and performing a case-insensitive comparison of the git SHAs to prevent unexpected mismatches.
| const {data: pullRequest} = await github.pulls.get({ | ||
| owner, | ||
| repo, | ||
| pull_number: Number(pullNumber), | ||
| }); |
There was a problem hiding this comment.
If the GitHub API request fails (e.g., due to rate limits, invalid token permissions, or a non-existent pull request), the action will fail with a generic Octokit error. Wrapping this call in a try-catch block and rethrowing a more descriptive error will make troubleshooting much easier for maintainers.
let pullRequest;
try {
const response = await github.pulls.get({
owner,
repo,
pull_number: Number(pullNumber),
});
pullRequest = response.data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw Error("Failed to fetch pull request #" + pullNumber + " from GitHub API: " + message);
}| pull_number: Number(pullNumber), | ||
| }); | ||
|
|
||
| if (pullRequest.head.sha !== workflowRunHeadSha) { |
There was a problem hiding this comment.
Comparing git SHAs case-sensitively can lead to unexpected failures if one of the SHAs is returned in uppercase (e.g., by certain git tools or APIs). It is safer and more robust to perform a case-insensitive comparison by converting both SHAs to lowercase.
if (pullRequest.head.sha.toLowerCase() !== workflowRunHeadSha.toLowerCase()) {Addresses review feedback. Wrap the pull request lookup so a rate limit or a permissions problem reports what actually went wrong instead of a raw Octokit error, and compare the two commit SHAs case-insensitively since they are hex.
The problem
upload-artifacts-to-firebaseruns in the privilegedworkflow_runjob. It reads twovalues out of the build artifact:
__metadata__pull_number.txt__metadata__build_revision.txtThat artifact is produced by the unprivileged build job, so a pull request author controls
its contents, including from a fork.
extract-artifact-metadata.tschecks that the filesare not symlinks, but never checks the values themselves, and emits them as the
unsafe-pull-numberandunsafe-build-revisionoutputs.action.ymlthen uses those outputs for three things:number:for the sticky comment, so the comment goes to whichever pull request theartifact names, posted with the trusted
github-tokenmessage:, which putsunsafe-build-revisioninto that comment as markdownchannelId: pr-<repo>-<unsafe-pull-number>-<artifact-name>, which picks the Firebasepreview channel that gets deployed to
So a pull request can write a different pull request's number into its own artifact and
get its build deployed onto that pull request's preview channel, plus a bot comment on it
pointing at the preview. Someone reviewing that other pull request opens the link and sees
content from a build they never looked at.
The
TRUSTED/RISKcomment at the top ofaction.ymlalready draws this line. Theartifact metadata is
RISKdata being used to makeTRUSTEDdecisions.Reproducing it
Running the currently checked-in
extract-artifact-metadata.jsdirectly, with an artifactthat was built for #3945 but whose
__metadata__pull_number.txtsays3911:It emits
3911, so the deploy and the comment go to #3911. With this change the sameinput stops:
A genuine artifact for #3945 still succeeds and sets
pull-number=3945. Abuild-revisioncontaining markdown such as[click me](http://evil.example) `whoami`is rejected on the format check.
The fix
pull-numbermust be a positiveinteger,
build-revisionmust be a hex SHA.github.event.workflow_run.head_sha. An artifact naming a different pull request nolonger matches the run that produced it, and the job stops.
build-revisionfrom theworkflow_runpayload rather than the artifact, so thecomment no longer contains a string from the build.
unsafe-pull-number/unsafe-build-revisiontopull-number/
build-revision, since they are checked at the point they are set.@octokit/restwas already a dependency of this action but was not listed in theextract_artifact_metadata_libdeps, so it is added there. The checked-in bundle grows byabout 240KB because Octokit is now bundled into it, the same way it already is in
fetch-workflow-artifact.js.Worth a second opinion
The check assumes
github.event.workflow_run.head_shais the pull request head commit.That holds for a
workflow_runtriggered by apull_requestbuild, which is how thisaction is used here, but you know the consumers of this action better than I do. If there
is a case where it does not hold, resolving the pull request number from the workflow run
instead would work too.
Testing
bazel test //github-actions/previews/...- 10/10 pass, includingextract_artifact_metadata_lib_strict_deps_testandextract-artifact-metadata_testbazel run //github-actions/previews/upload-artifacts-to-firebase:extract-artifact-metadatato regenerate the checked-in bundle, which is included in this PR
prettier --checkclean on the changed sourceWhere this came from
I reported this to the Google OSS VRP. They referred it here to be fixed with the
maintainers directly and asked me to mention the referral.