From 4cb600fccb936ac07210207e5854d56cc1b9d9f0 Mon Sep 17 00:00:00 2001 From: wecbaiyk-blip Date: Sat, 9 May 2026 03:27:40 +0300 Subject: [PATCH] fix(github-actions): reject symlinks in PR-supplied Firebase preview artifact `upload-artifacts-to-firebase` extracts a PR-supplied tarball (`unsafe-artifact.zip` -> `deploy-artifact.tar.gz`) into the Firebase public directory and then deploys it to a Firebase Hosting preview channel. The artifact is explicitly annotated as `RISK` in this action's comments and the README points at GitHub Security Lab's guidance on preventing pwn requests. Currently `tar -xzf` materialises any symlinks that the PR author chose to put in their artifact onto the runner's `firebase-public-dir`. Downstream tooling that walks `firebase-public-dir` to enumerate the files to upload (e.g. `firebase-tools` `listFiles`) calls `fs.readFile*` on each entry, which follows symlinks at the OS layer. A tar entry like `public/leak -> /proc/self/environ` or `public/leak -> ~/.config/gcloud/application_default_credentials.json` would therefore have its target's bytes uploaded to the publicly-readable preview CDN. Defense in depth: after extraction, fail the deploy if the public directory contains any symlinks. There is no legitimate reason for a static-hosting artifact to contain symlinks, so refusing them outright is safer than trying to enumerate safe symlink targets. Hardening applied from review: - Pass `firebase-public-dir` via a step-level `env` var instead of interpolating it into the script body (avoids shell injection). - `find -type l` is no longer suffixed with `|| true` / `2>/dev/null`, so under `set -euo pipefail` the check fails closed if the scan cannot complete instead of silently passing. - Drop `-v` from `tar` so a crafted entry name cannot emit a workflow command to stdout during extraction. - Indent the reported symlink list via `printf | sed` so a malicious filename containing a newline cannot be interpreted as a workflow command. This patch complements upstream `firebase-tools` hardening (separate PRs against `firebase/firebase-tools`) and stays defensive even if those land later or are partially reverted. --- .../upload-artifacts-to-firebase/action.yml | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/github-actions/previews/upload-artifacts-to-firebase/action.yml b/github-actions/previews/upload-artifacts-to-firebase/action.yml index 229f90dfd8..d53fdcace3 100644 --- a/github-actions/previews/upload-artifacts-to-firebase/action.yml +++ b/github-actions/previews/upload-artifacts-to-firebase/action.yml @@ -65,14 +65,49 @@ runs: # RISK: The downloaded `unsafe-artifact` is of input category `RISK`. - name: Extracting workflow artifact into Firebase public directory. shell: bash + # Pass the input via the environment rather than interpolating it into the + # script body, so its value can never be parsed as part of the shell script. + env: + FIREBASE_PUBLIC_DIR: ${{inputs.firebase-public-dir}} run: | + set -euo pipefail + extractDir="$RUNNER_TEMP/artifact-unpack" + publicDir="$FIREBASE_PUBLIC_DIR" - mkdir -p '${{inputs.firebase-public-dir}}' + mkdir -p "$publicDir" mkdir -p "$extractDir" unzip unsafe-artifact.zip -d "$extractDir" - tar -xvzf "$extractDir/deploy-artifact.tar.gz" -C '${{inputs.firebase-public-dir}}' + # No `-v`: the archive is attacker-influenced (PR-supplied) and a crafted + # entry name containing a newline + `::workflow-command::` could otherwise + # be echoed to stdout during extraction and interpreted by the runner. + tar -xzf "$extractDir/deploy-artifact.tar.gz" -C "$publicDir" + + # Defense-in-depth: fail the deploy if the extracted artifact contains + # symlinks. The artifact is attacker-influenced (PR-supplied build + # output); a symlink such as `public/leak -> /proc/self/environ` or + # `public/leak -> ~/.config/gcloud/application_default_credentials.json` + # would otherwise be followed by downstream tooling that walks + # `firebase-public-dir` and reads each entry, ending up on the + # public Firebase Hosting CDN. There is no legitimate reason for a + # `public` artifact destined for static hosting to contain + # symlinks, so refusing them outright is the safest default. + # See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/. + # + # `find` is not suffixed with `|| true` / `2>/dev/null`: under `set -e` + # any failure to complete the scan must fail the step (fail closed) + # rather than silently treating the artifact as symlink-free. + symlinks=$(find "$publicDir" -type l) + if [ -n "$symlinks" ]; then + echo "::error title=Symlinks rejected in artifact::The deploy artifact contains symlinks, which are not permitted in a Hosting public directory for security reasons (a symlink can leak the contents of files outside the source tree onto the public Firebase Hosting CDN)." + echo "Symlinks found:" + # Indent so a malicious filename containing a newline cannot start a + # line with `::` and be interpreted as a workflow command; `printf` + # is used instead of `echo` for safe handling of arbitrary strings. + printf '%s\n' "$symlinks" | sed 's/^/ /' + exit 1 + fi - name: Extracting artifact metadata id: artifact-info