diff --git a/docs/deploy/services/cloudflare.rst b/docs/deploy/services/cloudflare.rst index 450c8518e..ac0d2e8c7 100644 --- a/docs/deploy/services/cloudflare.rst +++ b/docs/deploy/services/cloudflare.rst @@ -72,8 +72,6 @@ Proxy status .. attention:: - If a service expects the client's IP, reconfigure it to use the `CF-Connecting-IP `__ header: for example, `WordFence `__. - Inform server operators (like RBC Group and Datanomix) to reconfigure web server logging to use the ``CF-Connecting-IP`` header, as we do for Apache and Nginx. - Proxy A, AAAA and CNAME records for `URL forwarding `__. diff --git a/docs/develop/update/wordpress.rst b/docs/develop/update/wordpress.rst index eb480fa9d..025e31f8a 100644 --- a/docs/develop/update/wordpress.rst +++ b/docs/develop/update/wordpress.rst @@ -80,12 +80,18 @@ WordPress Salt contains `WordPress states `__, but they are limited. Also, WordPress is often deployed by copying files, rather than via fresh installs. -#. Configure `WP-CLI `__. In the server's Pillar file, add, for example: +#. Configure `WP-CLI `__ and the site. In the server's Pillar file, add, for example: .. code-block:: yaml wordpress: cli_version: 2.7.1 + sites: + coalition: + database: coalition_wp + cron: + contact: + - sysadmin@open-contracting.org #. :doc:`Deploy the server<../../deploy/deploy>`. #. :doc:`Connect to the server<../../use/ssh>` as the WordPress user (e.g. ``coalition``). @@ -101,58 +107,71 @@ WordPress wp core download --locale=en_US -#. Create the ``wp-config.php`` file, and configure the database connection, to correspond to the :ref:`MySQL configuration`. For example: +#. Create the ``wp-config.php`` file, and configure the database connection to correspond to the :ref:`MySQL configuration`. For example: .. code-block:: bash wp config create --dbname=DBNAME --dbuser=USERNAME --dbpass=PASSWORD -#. Set `WP_AUTO_UPDATE_CORE `__, to enable minor WordPress updates only. +#. Install WordPress, creating a user for yourself. For example: .. code-block:: bash - wp config set WP_AUTO_UPDATE_CORE minor + wp core install --url=www.open-spending.eu --title="www.open-spending.eu" --admin_user=jmckinney --admin_password=PASSWORD --admin_email=jmckinney@open-contracting.org --skip-email -#. Set `DISABLE_WP_CRON `__, since system cron is used instead. +#. Uninstall default plugins: .. code-block:: bash - wp config set --raw DISABLE_WP_CRON true + wp plugin uninstall hello -#. Install WordPress, with a ``siteadmin`` user associated to ``sysadmin@open-contracting.org``. For example: +#. Install `Two Factor `__ (2FA is required for administrators by the ``require-two-factor`` must-use plugin): .. code-block:: bash - wp core install --url=www.open-spending.eu --title="www.open-spending.eu" --admin_user=siteadmin --admin_password=PASSWORD --admin_email=sysadmin@open-contracting.org --skip-email + wp plugin install two-factor --activate - .. tip:: +#. Add or override any constants, setting values to strings of PHP source. (The constants under ``wordpress:constants`` in the ``pillar/cms.sls`` file are configured on every site.) For example: - To list the cron jobs, run: - - .. code-block:: bash + .. code-block:: yaml - wp cron event list + wordpress: + sites: + USERNAME: + constants: + WP_AUTO_UPDATE_CORE: 'false' -#. Uninstall default plugins: - .. code-block:: bash +#. Add any `must-use plugins `__, with any context they need. (The must-use plugins under ``wordpress:mu_plugins`` in the ``pillar/cms.sls`` file are installed on every site.) For example: - wp plugin uninstall hello - -#. Add a `must-use plugin `__ to auto-update plugins for non-major versions only. For example, replacing ``USERNAME``: + .. code-block:: yaml - .. note:: + wordpress: + sites: + USERNAME: + mu_plugins: + - fathom-analytics + context: + FATHOM_ANALYTICS_ID: ABCDEFGH - `WP Rocket can't auto-update. `__ See the `changelog `__. +#. Unless the site uses a security plugin's file scanner, schedule checks: .. code-block:: yaml wordpress: sites: USERNAME: - plugins: - - auto-update-plugin + checks: + enabled: True + premium_plugins: + - advanced-custom-fields-pro + .. admonition:: Interpreting the emails it sends + + - The integrity check lists core and plugin files that differ from wordpress.org's copy: modified, missing or added. A plugin that writes to its own directory can cause a false positive. Confirm changes before restoring files from a backup. + - The updates check lists the updates that won't install automatically, like major versions. It also lists the plugins whose update API gave no answer, which can be due to an HTTP timeout, an expired license, or the plugin being closed on wordpress.org. + +#. :doc:`Deploy the server<../../deploy/deploy>`. #. If you have a custom theme, download and activate it. For example: .. code-block:: bash @@ -185,8 +204,3 @@ Strings to replace might include: - Domain names - Theme names - File paths - -If the site uses these plugins, perform these operations to remove old items in the database: - -- `Rank Math `__: *Status & Tools* menu item > *Database Tools* tab > Click the *Delete Internal Links* and *Clear 404 Log* buttons. -- `WordFence `__: *Scan* menu item -> Click the *START NEW SCAN* button. You can also manually delete rows from the ``wp_wfhits`` and ``wp_wflogins`` tables. diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 1f7715280..25ef56715 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -107,13 +107,6 @@ Cybersecurity issues are monitored by `SecurityScorecard `__. - -WordFence is managed in each WordPress installation, rather than by visiting its website. - .. _hosting: Hosting diff --git a/pillar/cms.sls b/pillar/cms.sls index b5dc86d78..d7e513418 100644 --- a/pillar/cms.sls +++ b/pillar/cms.sls @@ -57,3 +57,13 @@ redis: wordpress: cli_version: 2.12.0 + constants: + # https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/ + DISABLE_WP_CRON: 'true' + # https://developer.wordpress.org/advanced-administration/security/hardening/#disable-file-editing + DISALLOW_FILE_EDIT: 'true' + mu_plugins: + - disable-admin-view-transitions + - disable-comments-pingbacks + - mail-from + - require-two-factor diff --git a/pillar/coalition.sls b/pillar/coalition.sls index 17ee65ea0..a3f48cf3e 100644 --- a/pillar/coalition.sls +++ b/pillar/coalition.sls @@ -31,23 +31,28 @@ phpfpm: pm: dynamic pm_max_children: 6 pm_max_requests: 500 - cron: - contact: - - sysadmin@open-contracting.org - # Reproduce with: wp cron event run --quiet --all - # https://developer.wordpress.org/reference/classes/wp_site_health/detect_plugin_theme_auto_update_issues/ - ignore: - - a-fake-plugin.php wordpress: sites: coalition: database: coalition_wp - plugins: + cron: + contact: + - sysadmin@open-contracting.org + ignore: + # Reproduce with: wp cron event run --quiet --all + # https://developer.wordpress.org/reference/classes/wp_site_health/detect_plugin_theme_auto_update_issues/ + - a-fake-plugin.php + constants: + # https://developer.wordpress.org/advanced-administration/upgrade/upgrading/#constant-to-configure-core-updates + WP_AUTO_UPDATE_CORE: "'minor'" + mu_plugins: - auto-update-plugin - - disable-admin-view-transitions - - disable-comments-pingbacks + - disable-user-enumeration - fathom-analytics - - mail-from + checks: + enabled: True + premium_plugins: + - advanced-custom-fields-pro context: FATHOM_ANALYTICS_ID: LNRZMMVR diff --git a/pillar/corporate.sls b/pillar/corporate.sls index 2659ef9f0..70a219dab 100644 --- a/pillar/corporate.sls +++ b/pillar/corporate.sls @@ -35,20 +35,17 @@ phpfpm: php_value[memory_limit]: 256M php_value[upload_max_filesize]: 8M php_value[post_max_size]: 10M - cron: - contact: - - sysadmin@open-contracting.org - - support+ocp@theideabureau.co wordpress: sites: corporate: database: corporate_wp - plugins: - - disable-admin-view-transitions - - disable-comments-pingbacks + cron: + contact: + - sysadmin@open-contracting.org + - support+ocp@theideabureau.co + mu_plugins: - google-tag-manager - - mail-from context: GTM_CONTAINER_ID: GTM-WMV9C5F diff --git a/salt/apache/files/sites/wordpress.conf.include b/salt/apache/files/sites/wordpress.conf.include index 8e780f302..363470f3c 100644 --- a/salt/apache/files/sites/wordpress.conf.include +++ b/salt/apache/files/sites/wordpress.conf.include @@ -20,6 +20,38 @@ Header set X-Content-Type-Options nosniff Require valid-user +# Paths inside dot-directories (.git/HEAD), except ACME's. + + Require all denied + +# Apache's covers .ht* only. + + Require all denied + + + + Require all denied + + +# Core's PHP entry points are all at the root (index.php, wp-login.php, wp-cron.php, ...). + + + Require all denied + + # The one file under wp-includes that browsers request. + + Require all granted + + + + Require all denied + + + + Require all denied + + + Require all denied diff --git a/salt/cms/files/check-updates.php b/salt/cms/files/check-updates.php new file mode 100644 index 000000000..02c8bc7b5 --- /dev/null +++ b/salt/cms/files/check-updates.php @@ -0,0 +1,128 @@ + Updates, which get_core_updates() omits by default. + foreach ( (array) get_core_updates( array( 'dismissed' => true ) ) as $offer ) { + // Ignore "latest" and "development" offers. + if ( 'upgrade' !== $offer->response ) { + continue; + } + if ( $auto_update && $auto_update->current === $offer->current ) { + continue; + } + $pending[] = array( 'core', 'wordpress', get_bloginfo( 'version' ), $offer->current ); + } + + $plugins = get_plugins(); + // Not `wp plugin list --update=available`, which also lists the updates that WP_Automatic_Updater is about to install. + foreach ( get_plugin_updates() as $file => $data ) { + if ( $updater->should_update( 'plugin', $data->update, WP_PLUGIN_DIR ) ) { + continue; + } + $pending[] = array( 'plugin', '.' === dirname( $file ) ? $file : dirname( $file ), $plugins[ $file ]['Version'], $data->update->new_version ); + } + + // Not `wp theme list --update=available`, which also lists the updates that WP_Automatic_Updater is about to install. + foreach ( get_theme_updates() as $stylesheet => $theme ) { + // The transient stores theme offers as arrays. + $item = (object) $theme->update; + if ( $updater->should_update( 'theme', $item, get_theme_root( $stylesheet ) ) ) { + continue; + } + $pending[] = array( 'theme', $stylesheet, $theme->get( 'Version' ), $item->new_version ); + } + + return $pending; +} + +/** + * Returns the installed plugins that received no answer from their update API. + * + * WordPress lists every plugin it asked about in `checked`, and every answer in `response` or `no_update`. A plugin + * in neither was not answered for: the request timed out, its license is expired, or it's closed on wordpress.org. + * (Not `wp plugin list`, which shows such a plugin as up to date.) + * + * @return array[] Rows of name and installed version. + */ +function opencontracting_silent_plugins() { + $transient = get_site_transient( 'update_plugins' ); + + if ( empty( $transient->checked ) ) { + return array(); + } + + $silent = array_diff_key( + (array) $transient->checked, + (array) ( $transient->response ?? array() ), + (array) ( $transient->no_update ?? array() ) + ); + + $rows = array(); + foreach ( $silent as $file => $version ) { + $rows[] = array( '.' === dirname( $file ) ? $file : dirname( $file ), $version ); + } + + return $rows; +} + +opencontracting_updates_refresh(); + +$opencontracting_pending = opencontracting_updates_check(); +$opencontracting_silent = opencontracting_silent_plugins(); + +if ( $opencontracting_pending ) { + WP_CLI::line( "Updates that won't install automatically:" ); + foreach ( $opencontracting_pending as $row ) { + WP_CLI::line( sprintf( '%s %s: %s -> %s', $row[0], $row[1], $row[2], $row[3] ) ); + } +} + +if ( $opencontracting_silent ) { + WP_CLI::line( "Plugins that can't update, because their update API gave no answer:" ); + foreach ( $opencontracting_silent as $row ) { + WP_CLI::line( sprintf( 'plugin %s: %s', $row[0], $row[1] ) ); + } +} + +if ( $opencontracting_pending || $opencontracting_silent ) { + WP_CLI::halt( 1 ); +} diff --git a/salt/cms/files/auto-update-plugin.php b/salt/cms/files/mu-plugins/auto-update-plugin.php similarity index 100% rename from salt/cms/files/auto-update-plugin.php rename to salt/cms/files/mu-plugins/auto-update-plugin.php diff --git a/salt/cms/files/disable-admin-view-transitions.php b/salt/cms/files/mu-plugins/disable-admin-view-transitions.php similarity index 100% rename from salt/cms/files/disable-admin-view-transitions.php rename to salt/cms/files/mu-plugins/disable-admin-view-transitions.php diff --git a/salt/cms/files/disable-comments-pingbacks.php b/salt/cms/files/mu-plugins/disable-comments-pingbacks.php similarity index 100% rename from salt/cms/files/disable-comments-pingbacks.php rename to salt/cms/files/mu-plugins/disable-comments-pingbacks.php diff --git a/salt/cms/files/mu-plugins/disable-user-enumeration.php b/salt/cms/files/mu-plugins/disable-user-enumeration.php new file mode 100644 index 000000000..a7e006521 --- /dev/null +++ b/salt/cms/files/mu-plugins/disable-user-enumeration.php @@ -0,0 +1,25 @@ + 'users' === $name ? false : $provider, 10, 2 ); diff --git a/salt/cms/files/fathom-analytics.php b/salt/cms/files/mu-plugins/fathom-analytics.php similarity index 100% rename from salt/cms/files/fathom-analytics.php rename to salt/cms/files/mu-plugins/fathom-analytics.php diff --git a/salt/cms/files/google-tag-manager.php b/salt/cms/files/mu-plugins/google-tag-manager.php similarity index 100% rename from salt/cms/files/google-tag-manager.php rename to salt/cms/files/mu-plugins/google-tag-manager.php diff --git a/salt/cms/files/mail-from.php b/salt/cms/files/mu-plugins/mail-from.php similarity index 100% rename from salt/cms/files/mail-from.php rename to salt/cms/files/mu-plugins/mail-from.php diff --git a/salt/cms/files/mu-plugins/require-two-factor.php b/salt/cms/files/mu-plugins/require-two-factor.php new file mode 100644 index 000000000..65ecb974e --- /dev/null +++ b/salt/cms/files/mu-plugins/require-two-factor.php @@ -0,0 +1,49 @@ + array_intersect_key( $providers, array_flip( array( 'Two_Factor_Totp', 'Two_Factor_Backup_Codes' ) ) ) +); + +/** + * Whether the current user must enrol. + */ +function opencontracting_needs_two_factor() { + return class_exists( 'Two_Factor_Core' ) + && current_user_can( 'manage_options' ) + && ! Two_Factor_Core::is_user_using_two_factor(); +} + +add_action( + 'admin_init', + function () { + global $pagenow; + + // The profile page hosts Two Factor's settings, and its setup runs over admin-ajax.php. + if ( 'profile.php' === $pagenow || wp_doing_ajax() || ! opencontracting_needs_two_factor() ) { + return; + } + + wp_safe_redirect( get_edit_profile_url() . '#two-factor-options' ); + exit; + } +); + +add_action( + 'admin_notices', + function () { + if ( opencontracting_needs_two_factor() ) { + echo '

Administrators must use two-factor authentication. Enable a method under Two-Factor Options, then update your profile.

'; + } + } +); diff --git a/salt/cms/init.sls b/salt/cms/init.sls index 6be44d6d9..b5524be81 100644 --- a/salt/cms/init.sls +++ b/salt/cms/init.sls @@ -11,32 +11,48 @@ wp-cli: - source_hash: https://github.com/wp-cli/wp-cli/releases/download/v{{ pillar.wordpress.cli_version }}/wp-cli-{{ pillar.wordpress.cli_version }}.phar.sha512 - mode: 755 +/usr/local/lib/wp-cli/check-updates.php: + file.managed: + - source: salt://cms/files/check-updates.php + - makedirs: True -{% for name, entry in pillar.phpfpm.sites|items %} -{% set user = entry.context.user %} +{% for user, entry in pillar.wordpress.sites|items %} {% set userdir = '/home/' + user %} +{% set database = pillar.mysql.databases[entry.database] %} +{% set mu_plugins = salt['pillar.get']('wordpress:mu_plugins', []) + entry.mu_plugins|default([]) %} +# Every PHP-FPM site on this server is a WordPress site, so the pool users are created here. {{ create_user(user, authorized_keys=pillar.ssh.get(user, [])) }} -# Allow Apache to access. See wordpress.conf.include. -allow {{ userdir }} access: - file.directory: - - name: {{ userdir }} - - mode: 755 - - require: - - user: {{ user }}_user_exists - -{{ userdir }}/public_html: +# Allow Apache to traverse to, and read, what it serves. See wordpress.conf.include. Parents first. +set {{ userdir }} directory permissions: file.directory: + - names: + - {{ userdir }} + - {{ userdir }}/public_html + - {{ userdir }}/public_html/wp-content + - {{ userdir }}/public_html/wp-content/uploads + - {{ userdir }}/public_html/wp-content/plugins + - {{ userdir }}/public_html/wp-content/themes - user: {{ user }} - group: {{ user }} - mode: 755 - require: - user: {{ user }}_user_exists -{{ set_cron_env(user, 'MAILTO', entry.cron.contact|join(','), 'cms' ) }} +set {{ userdir }}/public_html file permissions: + file.managed: + - names: + - {{ userdir }}/public_html/wp-config.php: + - mode: 600 + - {{ userdir }}/public_html/.htaccess: + - mode: 644 + - replace: False + - create: False + - require: + - file: {{ userdir }}/public_html -# Assumes that all PHP-FPM sites on the CMS server are WordPress. +{{ set_cron_env(user, 'MAILTO', entry.cron.contact|join(','), 'cms' ) }} /usr/local/bin/wp cron event run --quiet --due-now --path={{ userdir }}/public_html{% if 'ignore' in entry.cron %} 2>&1 | grep -v '{{ entry.cron.ignore|join('\|') }}'{% endif %}: cron.present: @@ -45,10 +61,6 @@ allow {{ userdir }} access: - minute: '*/5' - require: - user: {{ user }}_user_exists -{% endfor %} - -{% for user, entry in pillar.wordpress.sites|items %} -{% set database = pillar.mysql.databases[entry.database] %} {% for constant, value in ( ('DB_NAME', entry.database), @@ -66,10 +78,26 @@ allow {{ userdir }} access: - user: {{ user }}_user_exists {% endfor %} -{% for name in entry.plugins|default([]) %} +# Values are strings of PHP source. `wp config get` prints the evaluated value, like `php -r 'echo …;'`. +{% for constant, value in dict(salt['pillar.get']('wordpress:constants', {}), **entry.constants|default({}))|items %} +set {{ constant }} in {{ user }} wp-config.php: + cmd.run: + - name: /usr/local/bin/wp config set {{ constant }} "{{ value }}" --raw + - runas: {{ user }} + - cwd: {{ userdir }}/public_html + - onlyif: test -f {{ userdir }}/public_html/wp-config.php + - unless: >- + value=$(/usr/local/bin/wp config get {{ constant }} 2>/dev/null) + && [ "$value" = "$(php -r "echo {{ value }};")" ] + - require: + - file: wp-cli + - user: {{ user }}_user_exists +{% endfor %} + +{% for name in mu_plugins %} /home/{{ user }}/public_html/wp-content/mu-plugins/opencontracting-{{ name }}.php: file.managed: - - source: salt://cms/files/{{ name }}.php + - source: salt://cms/files/mu-plugins/{{ name }}.php - template: jinja - context: {{ entry.context|yaml }} - user: {{ user }} @@ -78,4 +106,35 @@ allow {{ userdir }} access: - require: - user: {{ user }}_user_exists {% endfor %} + +{% set checks = entry.checks|default({}) %} +{% if checks.get('enabled') %} +{% set wp = '/usr/local/bin/wp --no-color --path=' ~ userdir ~ '/public_html' %} +# The must-use plugins above have no checksums at wordpress.org. +{% set exclude = checks.premium_plugins|default([]) + mu_plugins|map('regex_replace', '^', 'opencontracting-')|list %} + +# Not --quiet, which also hides the warnings that name the files. +WordPress integrity check for {{ user }}: + cron.present: + - name: '( {{ wp }} core verify-checksums; {{ wp }} plugin verify-checksums --all --exclude={{ exclude|join(',') }} ) 2>&1 | grep -v "^Success: "' + - identifier: WORDPRESS_INTEGRITY_CHECK + - user: {{ user }} + - hour: 5 + - minute: 30 + - require: + - user: {{ user }}_user_exists + - file: wp-cli + +WordPress updates check for {{ user }}: + cron.present: + - name: {{ wp }} eval-file /usr/local/lib/wp-cli/check-updates.php + - identifier: WORDPRESS_UPDATES_CHECK + - user: {{ user }} + - hour: 5 + - minute: 45 + - require: + - user: {{ user }}_user_exists + - file: wp-cli + - file: /usr/local/lib/wp-cli/check-updates.php +{% endif %} {% endfor %} diff --git a/script/snapshot-site b/script/snapshot-site index 132d3d06c..cd4ddca21 100755 --- a/script/snapshot-site +++ b/script/snapshot-site @@ -13,7 +13,7 @@ set -euo pipefail # Configuration variables -skip_tables="relevanssi,relevanssi_stopwords,itsec_logs,itsec_distributed_storage,itsec_temp,redirection_404,redirection_logs,blc_links,blc_instances,blc_filters,blc_synch" +skip_tables="relevanssi,relevanssi_stopwords,redirection_404,redirection_logs" skip_paths=(wp-content/cache wp-content/upgrade) # Parse arguments