generoi/gds-security-hardening

Security hardening for WordPress that takes no per-site configuration

Maintainers

Package info

github.com/generoi/gds-security-hardening

Type:wordpress-muplugin

pkg:composer/generoi/gds-security-hardening

Transparency log

Statistics

Installs: 78

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.1.0 2026-08-19 11:34 UTC

This package is auto-updated.

Last update: 2026-08-19 11:34:29 UTC


README

Security hardening for WordPress that takes no per-site configuration.

Installs as an mu-plugin. Every module is unconditional: anything needing a per-site answer — a REST allowlist, an integration user, a CSP — belongs in that site's own mu-plugins, not here.

composer require generoi/gds-security-hardening

What it does

Module Control
XmlRpc Exits on XMLRPC_REQUEST before anything registers; filters xmlrpc_enabled off; removes the RSD link and X-Pingback
Rest Disables JSONP; refuses a read overridden into a write; removes REST discovery output
ApplicationPasswords Closes the remote authorization flow; keeps passwords to manage_options
Headers HSTS, nosniff, X-Frame-Options, Referrer-Policy on frontend, admin, login and REST
Uploads Keeps parser-heavy formats (PDF/EPS/SVG/HEIC/TIFF/macro-Office) to editors and above
UserEnumeration Blocks ?author=<id>; makes authentication, login and lost-password responses uniform
Passwords 12-character minimum, enforced server side
Credentials A password reset revokes application passwords and destroys sessions
Roles Pins default_role
Version Removes the generator tag; replaces ?ver= with a site-salted token
ContentSecurityPolicy Four no-upkeep directives on admin; a full Strict CSP (nonce + strict-dynamic) on wp-login.php
Filesystem Stops WordPress rewriting .htaccess

Off by default, opt in with the modules filter:

Module Control
TwoFactor Requires two-factor enrolment before any capability but read

A password reset revokes nothing in core

Worth spelling out because it is surprising. wp_set_password() (wp-includes/pluggable.php:3099) writes the hash, clears the activation key, cleans the cache and fires an action. That is all. Core contains no callers of wp_destroy_all_sessions(), and delete_all_application_passwords() is called from exactly one place — the REST controller's manual DELETE.

So the single action a site owner takes on suspicion of compromise leaves the attacker's session cookie live, and their application password live. The application password survives two-factor too, because it is not a session. Credentials binds after_password_reset and revokes both. It fires gds_security_hardening_credentials_revoked so the revocation is not invisible to whoever runs the integration that just lost its credential.

Deliberately not bound to wp_set_password: a reset is someone locking an intruder out, a routine profile save is not.

login_errors is not enough to stop user enumeration

Core applies that filter in exactly one place, wp-login.php. Every other login form in WordPress renders core's own message, and core's message names the account: "The username bob is not registered on this site" (wp-includes/user.php:185). WooCommerce's my-account form calls wp_signon() and throws get_error_message() verbatim with no branch on the code (includes/class-wc-form-handler.php:1076-1079).

So UserEnumeration normalises at authenticate (priority 100, after every core authenticator and the spam check) and keeps login_errors as a second pass. It replaces the message and leaves the code alone on purpose — the code is never rendered, while plugins legitimately branch on it, and a brute-force limiter counting incorrect_password separately from invalid_username should keep working.

A timing oracle survives this: core returns before wp_check_password() when the account does not exist, so an unknown username answers faster. Closing that means burning a hash round on every invalid-username attempt — self-inflicted CPU amplification an attacker can trigger at will. Not worth it.

Three things worth knowing before you change anything

JSONP and nosniff are one control, not two. Core validates the ?_jsonp= callback against [\w.]+ — dots included — so ?_jsonp=window.opener.approve.click executes as a method call in your origin. That is a Same Origin Method Execution primitive on every reachable route, and it is the step CVE-2026-64638 used. With JSONP off a REST response is application/json, and nosniff is what makes <script src="/wp-json/..."> fail rather than be sniffed into script.

Only a read promoted to a write is refused. @wordpress/api-fetch ships httpV1Middleware in its default middleware chain, rewriting every PATCH, PUT and DELETE into a POST with X-HTTP-Method-Override. Refusing overrides outright breaks every save of an existing post. See tests/Integration/RestTest.php.

The application-password gate cannot call user_can(). It runs inside wp_validate_application_password(), hooked on determine_current_user, so the current user is still being resolved; a capability check re-enters that through any user_has_cap callback and recurses until PHP runs out of memory. It reads $user->allcaps, which WP_User::get_role_caps() builds with no filters.

Turning a module on or off

add_filter('gds_security_hardening_modules', fn ($modules) => [
    ...$modules,
    \GeneroWP\SecurityHardening\Modules\TwoFactor::class,
]);

TwoFactor is off by default because it needs the two-factor plugin and locks every account out of everything but their profile until they enrol — a decision a site makes, not a package. Without that plugin active it is inert rather than locking people out of a site with no way to enrol.

The CSP is asymmetric on purpose

wp-admin gets only directives that need no per-site verification, and says nothing about scripts. It cannot take a nonce policy — core alone prints around a dozen un-nonced inline scripts per screen (core #59446) and the media library needs eval (core #62894) — and the nonce-free alternative, script-src-elem 'self' 'unsafe-inline', blocks the external <script src> that plugins legitimately use on their own screens. Enforcing that safely needs a per-site plugin sweep, which is what this package does not do.

wp-login.php gets a full Strict CSP: script-src 'nonce-…' 'strict-dynamic', no unsafe-inline. Nothing without that request's nonce executes — injected script tags, inline handlers, javascript: URIs and eval all fail — which is what stops an injected script reading the password field on submit. The credential-entry page is worth the stricter policy.

⚠️ The known cost: a plugin printing a raw <script> on the login screen stops working. limit-login-attempts-reloaded does exactly that when credentials are submitted, so it needs a patch or needs to go through wp_print_inline_script_tag(). tests/e2e/login-csp.spec.js drives a real browser through a successful and a failed login and fails on any CSP violation, so a site can find out before its users do.

A site that cannot take it removes the module with the filter; a site wanting more appends through gds_security_hardening_csp_directives.

Widening a control for one site

Never fork this package. Widen from the site's own mu-plugins at a later priority — for an integration user whose role has no manage_options:

add_filter('wp_is_application_passwords_available_for_user', function ($available, $user) {
    return $available || $user->user_login === 'some-integration-user';
}, 20, 2);

Core normalises $user to a WP_User and bails on a missing one before any filter runs, so the object is safe to rely on.

Testing

The suite is integration-shaped on purpose. Every defect this package was written to avoid was invisible to a unit test: the recursion above needed real user_has_cap callbacks, the override rejection being discarded needed another plugin on the same filter, and an earlier version of the upload gate broke public Gravity Forms uploads through a path no mock would model. Polylang is installed in the test environment because it registers four rest_pre_dispatch callbacks.

composer install
npm install
npx wp-env start
npm run test:php    # phpunit against a real WordPress
npm run test:e2e    # playwright against the running site

The test environment installs the plugins the controls actually have to work with — woocommerce, two-factor, limit-login-attempts-reloaded, wordfence and polylang — and tests/bootstrap.php loads WooCommerce and two-factor into the phpunit run. Several controls exist because of what those plugins do, so testing them against doubles would assert our reading of the plugin rather than the plugin.

That is why the suite needs -d memory_limit=1024M: WooCommerce does not fit in the default 128M alongside the test harness.

Concretely, these run against the real thing rather than a mock:

  • POST /wp/v2/users through rest_do_request(), because that route reaches wp_insert_user() and fires none of core's password hooks
  • WooCommerce's own woocommerce_save_account_details_errors and its validate_password_reset call, with the password_1 field it actually posts
  • WC_Shortcode_My_Account::reset_password(), so if WooCommerce ever drops the after_password_reset parity it added in 10.9.0 we find out here
  • current_user_can() against a real unenrolled two-factor user

What the login policy blocks, measured

Core is clean: every route it uses to emit script on wp-login.php carries the nonce — wp_script_attributes for tags, and wp_print_inline_script_tag() for localized data (class-wp-scripts.php:247), inline before/after scripts (:612) and translations (:366, :773).

  • two-factor: clean. It ships raw-script copies of login_header()/ login_footer() but only loads them when login_header() is undefined (class-two-factor-core.php:1111), and both entry points to the challenge are login_form_* actions that only fire inside wp-login.php, where core's is always defined.
  • wordfence, polylang: clean on the login screen.
  • limit-login-attempts-reloaded: blocked. It echoes a raw <script> on any POST to wp-login.php — a failed login and the two-factor challenge alike, not just failures. What that script does is wire up its own error display and an admin-ajax URL, so the cost is its supplementary messaging: the login form is core markup and keeps working, core still renders the error server-side, and LLAR's lockout enforcement is server-side and unaffected. The fix is to make it print through wp_print_inline_script_tag(); patching it via composer-patches is what we do.

Two environment notes, both of which shape the tests rather than the code. WordPress only loads mu-plugins sitting directly in WPMU_PLUGIN_DIR, so a package installed into a subdirectory needs something to require it — Bedrock's autoloader does that in production, and tests/loader.php stands in for it here. And wp-env's Apache ships AllowOverride None, so .htaccess is ignored and /wp-json/ never routes; the specs use the ?rest_route= form, and the editor spec asserts that a save is not refused by us rather than that it returns 200.

Deliberately out of scope

This package covers what a plugin can enforce. The rest of the WordPress hardening guidance lives elsewhere, and is listed here so the gap is explicit rather than forgotten:

  • wp-config.phpDISALLOW_FILE_EDIT, DISALLOW_FILE_MODS, DISALLOW_UNFILTERED_HTML, FORCE_SSL_ADMIN, and a non-default table prefix. Config, not runtime.
  • File permissions — 755/644, wp-config.php at 440, and keeping the web server user out of plugins/. Deployment.
  • Database privileges — the application user should hold SELECT, INSERT, UPDATE, DELETE and nothing else. No FILE privilege, so an injection cannot reach the filesystem. Hosting.
  • No PHP execution under wp-content/uploads/ — the single highest-leverage control against the arbitrary-file-upload class, and not expressible in PHP. Web server config.
  • ImageMagick policy.xml — the PDF coder reaches Ghostscript. Uploads narrows who can feed it; only the host policy narrows what it will parse.
  • Two-factor, WAF, backups, log monitoring, file integrity monitoring — separate tools.
  • Automatic updates — deliberately off under Composer-managed WordPress; patch latency is then a release-pipeline property, not a config one.