Fix the findings of a full second security audit
Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run

Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses)
with dynamic PoCs against a real WordPress install; every fix is covered
by a regression test. Report: docs/security-audit.md, section 6.

Critical/High
- Multisite: settings, AJAX actions and certificate download require
  manage_network_options (site admins could sign in as super admin).
- Privileged accounts are only linked (sync and first sign-in) via a
  matching UPN of a member account, never via the settable mail
  attribute; the sync never changes their e-mail address; e-mail change
  notifications stay on.
- Button-only mode exempts by credential (application passwords, WP-CLI)
  instead of request context, closing bypasses through xmlrpc.php and
  REST login handlers; API requests never receive login cookies.
- Multi-tenant mode refuses guest/external identities.

Medium/Low
- Same message for right and wrong passwords; button-only no longer
  switches off when the connection breaks; server-side fallback cookie
  expiry; correct fallback key beats IP lockouts; right-most proxy hop;
  higher start limit; one object ID per account.
- Deactivation sets a random password, revokes application passwords and
  removes the role (restored on reactivation); disabled people are
  deactivated even when their mail vanished; duplicate bindings handled.
- Sync: abort on empty directory answer, no deprovisioning right after a
  tenant change, atomic run lock, strict photo path validation.
- Certificates: key bundles refused, clean re-exported certificate.
- Array-safe sanitising, encoded redirect_to, per-action nonces, escaped
  role lists, no Graph sleeps during sign-in, warnings for public groups,
  multi-tenant group rules and missing salts, uninstall clears the token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 17:10:30 +00:00
parent 791f43a80b
commit 850f0dcd54
18 changed files with 1908 additions and 1270 deletions

View file

@ -34,7 +34,8 @@ The plugin is deliberately small and strict:
* Optional **tenant pinning**: when a tenant GUID is configured, tokens from any other tenant are rejected.
* **Account binding**: on first sign-in the immutable Microsoft object ID is stored with the user; later sign-ins with the same e-mail but a different Microsoft identity are refused.
* Optional **e-mail domain allow-list** and **group allow-list** (fails closed when membership cannot be verified).
* **Button-only mode** blocks password sign-in server-side, not just visually; the fallback key is rate limited and never stored in a cookie.
* **Button-only mode** blocks password sign-in server-side, not just visually everywhere, XML-RPC and other plugins' login handlers included (application passwords and WP-CLI keep working, API requests never get a login cookie). Right and wrong passwords get the same answer. The fallback cookie carries its issue time and expires on the server.
* Administrator accounts are only linked (by sign-in or sync) through a matching user principal name of a member account, never through the freely settable e-mail attribute; one Microsoft identity can only be bound to one WordPress account.
* **Certificate authentication** (RFC 7523 client assertion) as an alternative to a client secret: generate a 3072-bit key pair in the settings, upload only the public certificate to Entra ID. Nothing secret is ever transmitted.
* The **client secret / private key is encrypted at rest** (AES-256-GCM, key derived from your WordPress salts) and never displayed again.
* In multi-tenant mode the unverified `email` claim is ignored; matching uses the user principal name (verified domain) only.
@ -132,11 +133,11 @@ Yes. Forms rendered with `wp_login_form()` get the button automatically. For pag
= Does it support multisite? =
Yes. Settings are per site; a user must be a member of the site (or a super admin) to sign in.
Yes. Settings are per site but can only be changed by super admins, because they decide which Microsoft identity may sign in as which network-wide WordPress user. A user must be a member of the site (or a super admin) to sign in.
= What happens on uninstall? =
The settings, cached data, the sync report and schedule, stored profile photos and the per-user plugin data (Microsoft object ID, deactivation status) are removed. Imported accounts and copied profile fields (`m365_*` user meta) are kept. Deactivated accounts become active again, so delete them first if they must stay locked.
The settings, cached data, the sync report and schedule, stored profile photos and the per-user plugin data (Microsoft object ID, deactivation status) are removed. Imported accounts and copied profile fields (`m365_*` user meta) are kept. Deactivated accounts stay without a role, with a random password and without application passwords.
== Screenshots ==
@ -156,6 +157,7 @@ The settings, cached data, the sync report and schedule, stored profile photos a
* New: `wp m365-login sync [--dry-run]` WP-CLI command and scheduled sync via WP-Cron.
* New: excluded Entra groups their members can never sign in with Microsoft.
* Fix: failed Microsoft sign-ins (e.g. expired secret, group not allowed) ended in a PHP fatal error instead of the error message.
* Security: fixes from a full security audit see docs/security-audit.md (button-only bypasses via XML-RPC/REST, administrator linking, multisite settings restricted to super admins, deactivation hardening, and more).
* Fix: generating or removing the certificate in the settings did not keep the change and broke a stored client secret.
= 1.0.0 =