From e3771207492c31535acda642c9767b8e88529761 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Sat, 14 Nov 2026 10:08:47 -0400 Subject: [PATCH] fix: pin plugin and theme sources and clone them as www-data Signed-off-by: YvesCesar --- .docker/wordpress/entrypoint.sh | 186 +++++++++++++++++------ README.md | 11 +- common-services.yml | 1 + tests/security/helpers/entrypoint.bash | 8 +- tests/security/pinned-setup-sources.bats | 128 ++++++++++++++++ 5 files changed, 286 insertions(+), 48 deletions(-) create mode 100644 tests/security/pinned-setup-sources.bats diff --git a/.docker/wordpress/entrypoint.sh b/.docker/wordpress/entrypoint.sh index 880169b..1bdf220 100644 --- a/.docker/wordpress/entrypoint.sh +++ b/.docker/wordpress/entrypoint.sh @@ -199,56 +199,156 @@ reset_local_user_passwords() { echo "No local password reset target configured; set WORDPRESS_LOCAL_RESET_ALL_USERS_PASSWORDS=1 or WORDPRESS_LOCAL_RESET_PASSWORD_FOR_USER." } +allow_unpinned_entry() { + local resource_label="$1" + + if is_truthy "${WORDPRESS_SETUP_REQUIRE_PINNED:-0}"; then + echo " ✗ $resource_label has no pin and WORDPRESS_SETUP_REQUIRE_PINNED is enabled; skipping" + return 1 + fi + + echo " ⚠ $resource_label has no pin; using the latest version" + return 0 +} + install_plugin() { local plugin_slug="$1" + local plugin_version="$2" + local installed_version + local -a version_args=() + + if [ -z "$plugin_version" ]; then + allow_unpinned_entry "Plugin $plugin_slug" || return 0 + else + version_args=(--version="$plugin_version" --force) + fi if runuser -u www-data -- wp plugin is-installed "$plugin_slug" 2>/dev/null; then - echo " ✓ Plugin $plugin_slug is already installed" + installed_version="$(runuser -u www-data -- wp plugin get "$plugin_slug" --field=version 2>/dev/null)" + if [ -z "$plugin_version" ] || [ "$installed_version" = "$plugin_version" ]; then + echo " ✓ Plugin $plugin_slug is already installed" + return 0 + fi + echo " ↻ Updating $plugin_slug to $plugin_version..." else echo " ↓ Installing $plugin_slug..." - if runuser -u www-data -- wp plugin install "$plugin_slug" --activate 2>/dev/null; then - echo " ✓ Plugin $plugin_slug installed" - else - echo " ✗ Failed to install $plugin_slug" - fi + fi + + if runuser -u www-data -- wp plugin install "$plugin_slug" "${version_args[@]}" --activate 2>/dev/null; then + echo " ✓ Plugin $plugin_slug installed" + else + echo " ✗ Failed to install $plugin_slug" fi } install_plugin_archive() { local plugin_slug="$1" local archive_source="$2" + local archive_sha256="$3" + local download_dir + local archive_file + + if [ -z "$archive_sha256" ]; then + allow_unpinned_entry "Plugin $plugin_slug" || return 0 + fi if runuser -u www-data -- wp plugin is-installed "$plugin_slug" 2>/dev/null; then echo " ✓ Plugin $plugin_slug is already installed" - else - echo " ↓ Installing $plugin_slug from archive..." - if runuser -u www-data -- wp plugin install "$archive_source" --activate 2>/dev/null; then - echo " ✓ Plugin $plugin_slug installed" - else - echo " ✗ Failed to install $plugin_slug from archive" + return 0 + fi + + echo " ↓ Installing $plugin_slug from archive..." + if [ -n "$archive_sha256" ]; then + download_dir="$(mktemp -d)" + archive_file="$download_dir/$plugin_slug.zip" + if ! curl -fsSL "$archive_source" -o "$archive_file"; then + echo " ✗ Failed to download $plugin_slug" + rm -rf "$download_dir" + return 0 fi + if ! printf '%s %s\n' "$archive_sha256" "$archive_file" | sha256sum -c --status; then + echo " ✗ The archive of $plugin_slug does not match its sha256 checksum; skipping" + rm -rf "$download_dir" + return 0 + fi + chown -R www-data:www-data "$download_dir" + archive_source="$archive_file" + fi + + if runuser -u www-data -- wp plugin install "$archive_source" --activate 2>/dev/null; then + echo " ✓ Plugin $plugin_slug installed" + else + echo " ✗ Failed to install $plugin_slug from archive" + fi + + if [ -n "$download_dir" ]; then + rm -rf "$download_dir" fi } -clone_custom_plugin() { - local plugin_name="$1" +checkout_git_ref() { + local target_dir="$1" local git_url="$2" - local plugin_dir="/var/www/html/wp-content/plugins/$plugin_name" + local git_ref="$3" + local head_commit - if [ -d "$plugin_dir" ]; then - echo " ✓ Plugin $plugin_name already exists" - return 0 - else - echo " ↓ Cloning $plugin_name..." - cd /var/www/html/wp-content/plugins/ - if git clone "$git_url" "$plugin_name" 2>/dev/null; then - echo " ✓ Plugin $plugin_name cloned" + runuser -u www-data -- git -C "$target_dir" fetch --quiet "$git_url" "$git_ref" || return 1 + runuser -u www-data -- git -C "$target_dir" checkout --quiet --detach FETCH_HEAD || return 1 + + if [[ "$git_ref" =~ ^[0-9a-f]{40}$ ]]; then + head_commit="$(runuser -u www-data -- git -C "$target_dir" rev-parse HEAD)" + if [ "$head_commit" != "$git_ref" ]; then + echo " ✗ $target_dir is at $head_commit instead of $git_ref" + return 1 + fi + fi +} + +sync_git_checkout() { + local resource_label="$1" + local target_dir="$2" + local git_url="$3" + local git_ref="$4" + + if [ -z "$git_ref" ]; then + allow_unpinned_entry "$resource_label" || return 1 + fi + + if [ -d "$target_dir" ]; then + if [ -z "$git_ref" ]; then + echo " ✓ $resource_label already exists" return 0 - else - echo " ✗ Failed to clone $plugin_name" + fi + if [ "$(runuser -u www-data -- git -C "$target_dir" rev-parse HEAD 2>/dev/null)" = "$git_ref" ]; then + echo " ✓ $resource_label is already at $git_ref" + return 0 + fi + echo " ↻ Updating $resource_label to $git_ref..." + else + echo " ↓ Cloning $resource_label..." + if ! runuser -u www-data -- git clone --quiet "$git_url" "$target_dir"; then + echo " ✗ Failed to clone $resource_label" return 1 fi + if [ -z "$git_ref" ]; then + echo " ✓ $resource_label cloned" + return 0 + fi + fi + + if ! checkout_git_ref "$target_dir" "$git_url" "$git_ref"; then + echo " ✗ Failed to check out $git_ref for $resource_label" + return 1 fi + echo " ✓ $resource_label is at $git_ref" +} + +clone_custom_plugin() { + local plugin_name="$1" + local git_url="$2" + local git_ref="$3" + + sync_git_checkout "Plugin $plugin_name" "/var/www/html/wp-content/plugins/$plugin_name" "$git_url" "$git_ref" } run_custom_plugin_post_install_commands() { @@ -284,24 +384,15 @@ finalize_custom_plugin() { clone_custom_theme() { local theme_name="$1" local git_url="$2" - local theme_dir="/var/www/html/wp-content/themes/$theme_name" + local git_ref="$3" - if [ -d "$theme_dir" ]; then - echo " ✓ Theme $theme_name already exists" - else - echo " ↓ Cloning theme $theme_name..." - cd /var/www/html/wp-content/themes/ - if git clone "$git_url" "$theme_name" 2>/dev/null; then - chown -R www-data:www-data "$theme_dir" - echo " ✓ Theme $theme_name cloned" - else - echo " ✗ Failed to clone $theme_name" - fi - fi + sync_git_checkout "Theme $theme_name" "/var/www/html/wp-content/themes/$theme_name" "$git_url" "$git_ref" || true } install_org_plugins_from_config() { + local entry local plugin_slug + local plugin_version echo "Plugins from wordpress.org:" @@ -310,19 +401,21 @@ install_org_plugins_from_config() { return fi - while IFS= read -r plugin_slug; do - plugin_slug="$(trim_value "$plugin_slug")" + while IFS= read -r entry; do + plugin_slug="$(trim_value "$(printf '%s' "$entry" | base64 -d | yq -r 'if type == "object" then .slug else . end')")" + plugin_version="$(printf '%s' "$entry" | base64 -d | yq -r 'if type == "object" then (.version // "" | tostring) else "" end')" if [ -z "$plugin_slug" ] || [ "$plugin_slug" = "null" ]; then continue fi - install_plugin "$plugin_slug" - done < <(read_yaml_values '.wordpress_org_plugins[]?') + install_plugin "$plugin_slug" "$plugin_version" + done < <(read_yaml_values '.wordpress_org_plugins[]? | @base64') } install_archive_plugins_from_config() { local entry local plugin_slug local archive_source + local archive_sha256 echo "Plugins from archive:" @@ -334,13 +427,14 @@ install_archive_plugins_from_config() { while IFS= read -r entry; do plugin_slug="$(printf '%s' "$entry" | base64 -d | yq -r '.slug')" archive_source="$(printf '%s' "$entry" | base64 -d | yq -r '.source')" + archive_sha256="$(printf '%s' "$entry" | base64 -d | yq -r '.sha256 // ""')" if [ -z "$plugin_slug" ] || [ "$plugin_slug" = "null" ] || [ -z "$archive_source" ] || [ "$archive_source" = "null" ]; then echo " ✗ Invalid configuration in wordpress_archive_plugins" continue fi - install_plugin_archive "$plugin_slug" "$archive_source" + install_plugin_archive "$plugin_slug" "$archive_source" "$archive_sha256" done < <(read_yaml_values '.wordpress_archive_plugins[]? | @base64') } @@ -351,6 +445,7 @@ install_git_entries() { local entry local resource_name local source_url + local git_ref echo "$label:" @@ -362,6 +457,7 @@ install_git_entries() { while IFS= read -r entry; do resource_name="$(printf '%s' "$entry" | base64 -d | yq -r '.slug')" source_url="$(printf '%s' "$entry" | base64 -d | yq -r '.source')" + git_ref="$(printf '%s' "$entry" | base64 -d | yq -r '.ref // "" | tostring')" if [ -z "$resource_name" ] || [ "$resource_name" = "null" ] || [ -z "$source_url" ] || [ "$source_url" = "null" ]; then echo " ✗ Invalid configuration in $label" @@ -369,11 +465,11 @@ install_git_entries() { fi if [ "$entry_type" = "plugin" ]; then - if clone_custom_plugin "$resource_name" "$source_url"; then + if clone_custom_plugin "$resource_name" "$source_url" "$git_ref"; then finalize_custom_plugin "$resource_name" "$entry" fi else - clone_custom_theme "$resource_name" "$source_url" + clone_custom_theme "$resource_name" "$source_url" "$git_ref" fi done < <(read_yaml_values "${query}[]? | @base64") } diff --git a/README.md b/README.md index 76b3275..35a7e8f 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,11 @@ services: # Use wordpress_org_plugins, wordpress_archive_plugins, wordpress_custom_plugins, and wordpress_custom_themes. WORDPRESS_SETUP_CONFIG_YAML: | # Plugins installed directly from the WordPress.org repository. + # Use a mapping with version to pin a release. wordpress_org_plugins: - advanced-custom-fields - - woocommerce + - slug: woocommerce + version: 10.3.0 # Plugins installed from a .zip URL (e.g. GitHub Releases or S3). # Optional: post_install_commands — list of shell commands run after installing @@ -62,15 +64,19 @@ services: wordpress_archive_plugins: - slug: my-premium-plugin source: https://example.com/releases/my-premium-plugin-1.0.0.zip + # Optional: the archive is only installed when it matches this checksum. + sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef # Plugins cloned from a Git repository. # Optional: post_install_commands — list of shell commands run after cloning # (and on every container start if the plugin directory already exists). # Commands run as www-data from the WordPress root (/var/www/html). # The env var PLUGIN_DIR is set to the absolute plugin directory path. + # Optional: ref pins a tag or a full commit SHA. A plugin whose commit does not match is not activated. wordpress_custom_plugins: - slug: my-plugin source: https://github.com/org/my-plugin.git + ref: v1.2.0 - slug: my-plugin-with-fixup source: https://github.com/org/my-plugin-with-fixup.git post_install_commands: @@ -81,6 +87,7 @@ services: wordpress_custom_themes: - slug: my-theme source: https://github.com/org/my-theme.git + ref: 0123456789abcdef0123456789abcdef01234567 nginx: environment: @@ -100,6 +107,8 @@ services: - 127.0.0.1:${MAILPIT_PORT:-8025}:8025 ``` +Entries without `version`, `sha256` or `ref` install whatever the source serves at that moment and are reported in the log. Set `WORDPRESS_SETUP_REQUIRE_PINNED=1` to skip them instead. + After that, start the stack with the standard Compose command: ```bash diff --git a/common-services.yml b/common-services.yml index e9e7c5f..dbb25a0 100644 --- a/common-services.yml +++ b/common-services.yml @@ -16,6 +16,7 @@ services: - WORDPRESS_DB_PASSWORD=${WORDPRESS_DB_PASSWORD:-root} - WORDPRESS_TABLE_PREFIX=${WORDPRESS_TABLE_PREFIX:-wp_} - WORDPRESS_SETUP_CONFIG_YAML=${WORDPRESS_SETUP_CONFIG_YAML:-} + - WORDPRESS_SETUP_REQUIRE_PINNED=${WORDPRESS_SETUP_REQUIRE_PINNED:-0} - WP_ENVIRONMENT_TYPE - WORDPRESS_LOCAL_USERS_PASSWORD=${WORDPRESS_LOCAL_USERS_PASSWORD:-} - WORDPRESS_LOCAL_RESET_ALL_USERS_PASSWORDS=${WORDPRESS_LOCAL_RESET_ALL_USERS_PASSWORDS:-0} diff --git a/tests/security/helpers/entrypoint.bash b/tests/security/helpers/entrypoint.bash index 43ded7f..6659cac 100644 --- a/tests/security/helpers/entrypoint.bash +++ b/tests/security/helpers/entrypoint.bash @@ -4,10 +4,12 @@ entrypoint_root="${BATS_TEST_DIRNAME}/../.." entrypoint_tmp="${BATS_TEST_TMPDIR}/entrypoint" entrypoint_network="" entrypoint_database_container="" +entrypoint_image="php:8.3-fpm" entrypoint_setup() { entrypoint_network="entrypoint-bats-${BATS_TEST_NUMBER:-0}-$$" - mkdir -p "${entrypoint_tmp}/document-root" "${entrypoint_tmp}/bin" "${entrypoint_tmp}/log" + mkdir -p "${entrypoint_tmp}/document-root/wp-content/plugins" "${entrypoint_tmp}/document-root/wp-content/themes" \ + "${entrypoint_tmp}/bin" "${entrypoint_tmp}/log" "${entrypoint_tmp}/sources" touch "${entrypoint_tmp}/document-root/wp-load.php" "${entrypoint_tmp}/document-root/wp-config.php" chmod 777 "${entrypoint_tmp}/log" @@ -15,6 +17,7 @@ entrypoint_setup() { #!/bin/sh echo "wp $*" >> /var/log/entrypoint/wp-calls case "$1 $2" in + "plugin is-installed") exit 1 ;; "user list") echo 1 ;; "user get") echo 1 ;; esac @@ -59,7 +62,8 @@ entrypoint_run() { -v "${entrypoint_tmp}/bin/php-fpm:/usr/local/sbin/php-fpm:ro" \ -v "${entrypoint_tmp}/log:/var/log/entrypoint" \ -v "${entrypoint_tmp}/document-root:/var/www/html" \ - php:8.3-fpm bash /var/www/scripts/entrypoint.sh + -v "${entrypoint_tmp}/sources:/sources:ro" \ + "${entrypoint_image}" bash /var/www/scripts/entrypoint.sh } entrypoint_wp_calls() { diff --git a/tests/security/pinned-setup-sources.bats b/tests/security/pinned-setup-sources.bats new file mode 100644 index 0000000..85508b3 --- /dev/null +++ b/tests/security/pinned-setup-sources.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats + +load 'helpers/entrypoint.bash' + +setup() { + entrypoint_setup + entrypoint_image="ghcr.io/librecodecoop/wordpress-docker:latest" + + local repository="${entrypoint_tmp}/work" + git init -q -b main "${repository}" + git -C "${repository}" -c user.name=test -c user.email=test@example.com commit -q --allow-empty -m first + git -C "${repository}" tag v1.0.0 + first_commit="$(git -C "${repository}" rev-parse HEAD)" + git -C "${repository}" -c user.name=test -c user.email=test@example.com commit -q --allow-empty -m second + second_commit="$(git -C "${repository}" rev-parse HEAD)" + git clone -q --bare "${repository}" "${entrypoint_tmp}/sources/plugin.git" + + printf '%s\n' 'archive' > "${entrypoint_tmp}/sources/plugin.zip" + archive_sha256="$(sha256sum "${entrypoint_tmp}/sources/plugin.zip" | cut -d' ' -f1)" +} + +teardown() { + entrypoint_teardown +} + +plugin_head() { + git -C "${entrypoint_tmp}/document-root/wp-content/plugins/$1" rev-parse HEAD +} + +@test "a custom plugin pinned to a commit is checked out at that commit and activated" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_custom_plugins: + - slug: pinned + source: file:///sources/plugin.git + ref: ${first_commit}" + [ "${status}" -eq 0 ] + [ "$(plugin_head pinned)" = "${first_commit}" ] + [[ "$(entrypoint_wp_calls)" == *"plugin activate pinned"* ]] +} + +@test "a custom plugin pinned to a tag is checked out at the tag" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_custom_plugins: + - slug: tagged + source: file:///sources/plugin.git + ref: v1.0.0" + [ "${status}" -eq 0 ] + [ "$(plugin_head tagged)" = "${first_commit}" ] +} + +@test "a custom plugin whose commit cannot be checked out is not activated" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_custom_plugins: + - slug: missing + source: file:///sources/plugin.git + ref: 0123456789abcdef0123456789abcdef01234567" + [ "${status}" -eq 0 ] + [[ "$(entrypoint_wp_calls)" != *"plugin activate missing"* ]] +} + +@test "an existing custom plugin is moved to a new pinned commit" { + git clone -q "${entrypoint_tmp}/sources/plugin.git" "${entrypoint_tmp}/document-root/wp-content/plugins/existing" + git -C "${entrypoint_tmp}/document-root/wp-content/plugins/existing" checkout -q --detach "${first_commit}" + + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_custom_plugins: + - slug: existing + source: file:///sources/plugin.git + ref: ${second_commit}" + [ "${status}" -eq 0 ] + [ "$(plugin_head existing)" = "${second_commit}" ] +} + +@test "a custom theme pinned to a commit is checked out at that commit" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_custom_themes: + - slug: theme + source: file:///sources/plugin.git + ref: ${first_commit}" + [ "${status}" -eq 0 ] + [ "$(git -C "${entrypoint_tmp}/document-root/wp-content/themes/theme" rev-parse HEAD)" = "${first_commit}" ] +} + +@test "an unpinned entry is reported" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_custom_plugins: + - slug: floating + source: file:///sources/plugin.git" + [ "${status}" -eq 0 ] + [[ "${output}" == *"floating has no pin"* ]] + [ "$(plugin_head floating)" = "${second_commit}" ] +} + +@test "unpinned entries are skipped when pins are required" { + run entrypoint_run WORDPRESS_SETUP_REQUIRE_PINNED=1 "WORDPRESS_SETUP_CONFIG_YAML=wordpress_org_plugins: + - woocommerce +wordpress_archive_plugins: + - slug: archived + source: file:///sources/plugin.zip +wordpress_custom_plugins: + - slug: floating + source: file:///sources/plugin.git" + [ "${status}" -eq 0 ] + [ ! -e "${entrypoint_tmp}/document-root/wp-content/plugins/floating" ] + [[ "$(entrypoint_wp_calls)" != *"plugin install"* ]] + [[ "$(entrypoint_wp_calls)" != *"plugin activate"* ]] +} + +@test "a wordpress.org plugin is installed at its pinned version" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_org_plugins: + - slug: woocommerce + version: 10.3.0" + [ "${status}" -eq 0 ] + [[ "$(entrypoint_wp_calls)" == *"plugin install woocommerce --version=10.3.0 --force --activate"* ]] +} + +@test "an archive plugin is installed only when its checksum matches" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_archive_plugins: + - slug: archived + source: file:///sources/plugin.zip + sha256: ${archive_sha256}" + [ "${status}" -eq 0 ] + [[ "$(entrypoint_wp_calls)" == *"plugin install /tmp/"*"/archived.zip --activate"* ]] +} + +@test "an archive plugin with a wrong checksum is not installed" { + run entrypoint_run "WORDPRESS_SETUP_CONFIG_YAML=wordpress_archive_plugins: + - slug: archived + source: file:///sources/plugin.zip + sha256: 0000000000000000000000000000000000000000000000000000000000000000" + [ "${status}" -eq 0 ] + [[ "${output}" == *"checksum"* ]] + [[ "$(entrypoint_wp_calls)" != *"plugin install"* ]] +}