diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae40cc8..a467b11 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -101,8 +101,10 @@ jobs: - name: Resolve newsletter popup asset path run: | - remote_file=$(./scripts/derive-newsletter-remote-path.sh "${THREEW_PROD_REMOTE_THEME_DIR}") - echo "NEWSLETTER_REMOTE_FILE=${remote_file}" >> "${GITHUB_ENV}" + shop_file=$(./scripts/derive-newsletter-remote-path.sh "${THREEW_PROD_REMOTE_THEME_DIR}" shop) + local_sha256=$(sha256sum deploy/production/3w-newsletter-popup/assets/newsletter-popup.js | awk '{print $1}') + echo "NEWSLETTER_REMOTE_FILE_SHOP=${shop_file}" >> "${GITHUB_ENV}" + echo "NEWSLETTER_LOCAL_SHA256=${local_sha256}" >> "${GITHUB_ENV}" - name: Deploy newsletter popup fix with rollback evidence env: @@ -112,11 +114,11 @@ jobs: ./scripts/deploy-file.sh \ --target production \ --local-file deploy/production/3w-newsletter-popup/assets/newsletter-popup.js \ - --remote-file "${NEWSLETTER_REMOTE_FILE}" \ + --remote-file "${NEWSLETTER_REMOTE_FILE_SHOP}" \ --backup-file "${BACKUP_FILE}" \ --verified-file "${VERIFIED_FILE}" \ --expected-remote-sha256 f3873bf2cdef7967a6d227a66d46ce6076a42ec6b660416e0b6a8379f1134cc3 \ - --expected-remote-sha256 b6af9603cef2cf5cc82541ef256a720a157c2c2e9456eef55ca059711c6e207b + --expected-remote-sha256 "${NEWSLETTER_LOCAL_SHA256}" - name: Verify newsletter popup bytes at the origin run: | diff --git a/docs/deployment-pipeline.md b/docs/deployment-pipeline.md index c27ebcd..2a65ef9 100644 --- a/docs/deployment-pipeline.md +++ b/docs/deployment-pipeline.md @@ -83,5 +83,5 @@ Do not add `.env`, passwords, deploy keys, or host credentials to git. - `scripts/deploy-theme.sh` loads `.env` for local runs only. GitHub Actions supplies the same variable names from GitHub Secrets. - The npm audit gate omits development dependencies because the WordPress build toolchain currently reports dev-only advisories that require breaking tool upgrades; production dependency exposure is still gated, while CodeQL, Gitleaks, linting, builds and the popup regression cover PR quality/security. - The production deploy job is attached to the `production` environment so GitHub can enforce approvals before secrets are exposed to the runner. -- `scripts/deploy-file.sh` accepts only a reviewed remote path, safe path characters and an allowlist of expected remote hashes. Use it for bounded production assets only; do not replace plugin directories with the theme mirror command. +- `scripts/deploy-file.sh` accepts only a bounded remote path with safe path segments and an allowlist of expected remote hashes. Use it for bounded production assets only; do not replace plugin directories with the theme mirror command. - If SFTP key auth is required later, update `scripts/deploy-theme.sh` to support a production-specific key secret materialized into a temporary runner file, then pass `THREEW_SSH_KEY_PATH` to the script. diff --git a/docs/newsletter-popup-fix.md b/docs/newsletter-popup-fix.md index c132b2d..6693b3c 100644 --- a/docs/newsletter-popup-fix.md +++ b/docs/newsletter-popup-fix.md @@ -24,7 +24,7 @@ The replacement is verified by `tests/e2e/newsletter-popup.spec.js` at desktop a ## Deployment and rollback -The production workflow reruns the desktop/mobile popup regression, deploys the theme as before, then uses `scripts/derive-newsletter-remote-path.sh` to derive the plugin path from relative, site-root-relative or absolute production theme directories. It validates the expected `wp-content/themes/3w-2025` path components before returning a plugin path. `scripts/deploy-file.sh` then: +The production workflow reruns the desktop/mobile popup regression, deploys the theme as before, then uses `scripts/derive-newsletter-remote-path.sh` to derive the shop plugin path from relative, site-root-relative or absolute production theme directories. It validates the expected `wp-content/themes/3w-2025` path components and inserts only the bounded `shop` site directory. `scripts/deploy-file.sh` then: 1. downloads the current remote JS as a rollback artifact; 2. refuses to overwrite any hash except the reviewed original or this exact override (the latter makes retries idempotent); diff --git a/scripts/deploy-file.sh b/scripts/deploy-file.sh index 99a80cb..b28203d 100755 --- a/scripts/deploy-file.sh +++ b/scripts/deploy-file.sh @@ -76,6 +76,15 @@ if [[ ! -f "$LOCAL_FILE" ]]; then exit 1 fi +# These local paths are also interpolated into lftp commands. The deployment +# workflow uses repository and runner-temp paths that fit this conservative set. +for local_path in "$LOCAL_FILE" "$BACKUP_FILE" "$VERIFIED_FILE"; do + if [[ ! "$local_path" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "Local path contains unsupported characters: $local_path" >&2 + exit 1 + fi +done + # Remote paths are interpolated into an lftp command stream. Limit the accepted # alphabet so a caller cannot turn a path into another command. if [[ ! "$REMOTE_FILE" =~ ^[A-Za-z0-9._/-]+$ || "$REMOTE_FILE" != */* || "$REMOTE_FILE" == *"//"* ]]; then @@ -123,6 +132,10 @@ SSL_VERIFY=${THREEW_SSL_VERIFY:-yes} FTP_USER=${THREEW_FTP_USER:-u659513315.thrwdiststaging} FTP_PASS=${THREEW_FTP_PASS:-} SSH_KEY_PATH=${THREEW_SSH_KEY_PATH:-} +if [[ -n "$SSH_KEY_PATH" && ! "$SSH_KEY_PATH" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "SSH key path contains unsupported characters" >&2 + exit 1 +fi TARGET_LABEL=staging if [[ "$TARGET" == "production" ]]; then diff --git a/scripts/derive-newsletter-remote-path.sh b/scripts/derive-newsletter-remote-path.sh index 82f0111..0caf0a8 100755 --- a/scripts/derive-newsletter-remote-path.sh +++ b/scripts/derive-newsletter-remote-path.sh @@ -2,12 +2,17 @@ # Derive the live newsletter-popup asset from the configured 3w-2025 theme path. set -euo pipefail -if [[ $# -ne 1 || -z "$1" ]]; then - echo "Usage: derive-newsletter-remote-path.sh REMOTE_THEME_DIR" >&2 +if [[ $# -lt 1 || $# -gt 2 || -z "$1" ]]; then + echo "Usage: derive-newsletter-remote-path.sh REMOTE_THEME_DIR [SITE_SUBDIRECTORY]" >&2 exit 1 fi THEME_DIR=${1%/} +SITE_SUBDIRECTORY=${2:-} +if [[ -n "$SITE_SUBDIRECTORY" && ! "$SITE_SUBDIRECTORY" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Site subdirectory must be one safe path segment" >&2 + exit 1 +fi if [[ ! "$THEME_DIR" =~ ^[A-Za-z0-9._/-]+$ || "$THEME_DIR" == *"//"* ]]; then echo "Remote theme directory contains unsupported characters" >&2 exit 1 @@ -43,4 +48,4 @@ case "$SITE_ROOT" in /) SITE_PREFIX="/" ;; *) SITE_PREFIX="${SITE_ROOT}/" ;; esac -printf '%s%s\n' "$SITE_PREFIX" "wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js" +printf '%s%s%s\n' "$SITE_PREFIX" "${SITE_SUBDIRECTORY:+${SITE_SUBDIRECTORY}/}" "wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js" diff --git a/tests/scripts/deploy-file.test.sh b/tests/scripts/deploy-file.test.sh index b499514..1f2fe1c 100755 --- a/tests/scripts/deploy-file.test.sh +++ b/tests/scripts/deploy-file.test.sh @@ -125,7 +125,9 @@ fi # Theme paths may be relative, site-root relative or absolute. [[ $("$DERIVE_PATH_SCRIPT" wp-content/themes/3w-2025) == 'wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] [[ $("$DERIVE_PATH_SCRIPT" public_html/wp-content/themes/3w-2025/) == 'public_html/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] +[[ $("$DERIVE_PATH_SCRIPT" wp-content/themes/3w-2025 shop) == 'shop/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] [[ $("$DERIVE_PATH_SCRIPT" /home/site/public_html/wp-content/themes/3w-2025) == '/home/site/public_html/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] +[[ $("$DERIVE_PATH_SCRIPT" /home/site/public_html/wp-content/themes/3w-2025 shop) == '/home/site/public_html/shop/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] [[ $("$DERIVE_PATH_SCRIPT" /wp-content/themes/3w-2025) == '/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js' ]] for unsafe_theme_path in \ themes/3w-2025 \