Skip to content

fix(github-actions): verify preview artifact metadata before use - #3986

Open
ParthShethia25 wants to merge 2 commits into
angular:mainfrom
ParthShethia25:fix-preview-artifact-metadata-verification
Open

ParthShethia25 wants to merge 2 commits into
angular:mainfrom
ParthShethia25:fix-preview-artifact-metadata-verification

Conversation

@ParthShethia25

Copy link
Copy Markdown

The problem

upload-artifacts-to-firebase runs in the privileged workflow_run job. It reads two
values out of the build artifact:

  • __metadata__pull_number.txt
  • __metadata__build_revision.txt

That artifact is produced by the unprivileged build job, so a pull request author controls
its contents, including from a fork. extract-artifact-metadata.ts checks that the files
are not symlinks, but never checks the values themselves, and emits them as the
unsafe-pull-number and unsafe-build-revision outputs.

action.yml then uses those outputs for three things:

  • number: for the sticky comment, so the comment goes to whichever pull request the
    artifact names, posted with the trusted github-token
  • message:, which puts unsafe-build-revision into that comment as markdown
  • channelId: pr-<repo>-<unsafe-pull-number>-<artifact-name>, which picks the Firebase
    preview 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 / RISK comment at the top of action.yml already draws this line. The
artifact metadata is RISK data being used to make TRUSTED decisions.

Reproducing it

Running the currently checked-in extract-artifact-metadata.js directly, with an artifact
that was built for #3945 but whose __metadata__pull_number.txt says 3911:

$ node extract-artifact-metadata.js ./artifact-dir
$ cat $GITHUB_OUTPUT
unsafe-pull-number<<...
3911
unsafe-build-revision<<...
fd189085e343e080bd3caeb88d72965db03f8917

It emits 3911, so the deploy and the comment go to #3911. With this change the same
input stops:

Error: Refusing to continue: the artifact claims pull request #3911, but that pull
request's head commit is d38a835e86744f1285e8ff4bff5deb15af606e12 while this workflow
run was triggered by fd189085e343e080bd3caeb88d72965db03f8917.

A genuine artifact for #3945 still succeeds and sets pull-number=3945. A
build-revision containing markdown such as [click me](http://evil.example) `whoami`
is rejected on the format check.

The fix

  • Check the format of both values before using them: pull-number must be a positive
    integer, build-revision must be a hex SHA.
  • Look up the claimed pull request and require its head commit to equal
    github.event.workflow_run.head_sha. An artifact naming a different pull request no
    longer matches the run that produced it, and the job stops.
  • Take build-revision from the workflow_run payload rather than the artifact, so the
    comment no longer contains a string from the build.
  • Rename the outputs from unsafe-pull-number / unsafe-build-revision to pull-number
    / build-revision, since they are checked at the point they are set.

@octokit/rest was already a dependency of this action but was not listed in the
extract_artifact_metadata_lib deps, so it is added there. The checked-in bundle grows by
about 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_sha is the pull request head commit.
That holds for a workflow_run triggered by a pull_request build, which is how this
action 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, including
    extract_artifact_metadata_lib_strict_deps_test and extract-artifact-metadata_test
  • bazel run //github-actions/previews/upload-artifacts-to-firebase:extract-artifact-metadata
    to regenerate the checked-in bundle, which is included in this PR
  • prettier --check clean on the changed source

Where 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.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +103 to +107
const {data: pullRequest} = await github.pulls.get({
owner,
repo,
pull_number: Number(pullNumber),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant