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
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:
parent
791f43a80b
commit
850f0dcd54
18 changed files with 1908 additions and 1270 deletions
|
|
@ -1,28 +1,33 @@
|
|||
# Security-Audit: M365 Login 1.1.0
|
||||
|
||||
**Stand:** 22.09.2026, Nachtrag Benutzer-Sync 23.09.2026 · **Umfang:** gesamter Plugin-Code (PHP, JS, CSS), Konfiguration, Deployment-Hinweise ·
|
||||
**Methode:** manuelle Code-Review gegen OAuth 2.0 / OpenID Connect Best Current Practice (RFC 6749, RFC 7636 PKCE,
|
||||
**Stand:** 23.09.2026 (Erst-Audit 22.09.2026, vollständiges Zweit-Audit 23.09.2026) · **Umfang:** gesamter Plugin-Code
|
||||
(PHP, JS, CSS) inkl. Benutzer-Sync, Konfiguration, Deployment-Hinweise ·
|
||||
**Methode:** Code-Review gegen OAuth 2.0 / OpenID Connect Best Current Practice (RFC 6749, RFC 7636 PKCE,
|
||||
RFC 7523 Client Assertions, OAuth 2.0 Security BCP), OWASP ASVS 4.0 (V2 Authentication, V3 Session, V5 Validation,
|
||||
V6 Cryptography), WordPress Plugin Handbook „Security“ sowie isolierte Tests der sicherheitskritischen Klassen.
|
||||
V6 Cryptography), WordPress Plugin Handbook „Security“ – und **dynamische Tests** in einer echten WordPress-Installation
|
||||
(7.1, SQLite, PHP 8.3): selbst signierte ID-Tokens mit eigenem JWKS gegen den echten Callback, simulierte Graph-API,
|
||||
echte HTTP-Requests (PHP-Webserver + curl) gegen `wp-login.php`, `xmlrpc.php` und die REST-API.
|
||||
|
||||
> Der Audit wurde ohne laufende WordPress-Instanz durchgeführt. Alle Aussagen zum Laufzeitverhalten beruhen auf
|
||||
> Code-Lesung und den isolierten Tests (JWT-Verifikation, Verschlüsselung, Zertifikate, Eingabeverarbeitung,
|
||||
> Nur-Button-Sperre). Ein Penetrationstest gegen eine echte Installation steht aus und wird empfohlen.
|
||||
> Der Benutzer-Sync (N-7) wurde zusätzlich in einer echten WordPress-Installation (7.1, SQLite) gegen eine simulierte
|
||||
> Graph-API getestet (Import, Paging, Rollen, Profilfelder, Fotos, Deaktivierung, Löschen, Sicherheitsstopp, Graph-Fehler).
|
||||
> Das Zweit-Audit (Abschnitt 6) wurde in vier getrennten Prüfbereichen durchgeführt: (1) OIDC/JWT/Kryptografie,
|
||||
> (2) Benutzer-Sync und Graph-Client, (3) Admin-Oberfläche/XSS/CSRF, (4) Umgehung von Nur-Button-Modus, Deaktivierung
|
||||
> und Gruppenregeln. Jeder Befund wurde mit einem Proof of Concept bestätigt oder als „unbestätigt“ markiert, und jeder
|
||||
> Fix ist durch einen Regressionstest belegt. Nicht getestet: echter Entra-Tenant, echte Multisite-Installation
|
||||
> (Multisite-Befunde über Code-Analyse und eine konvertierte Testinstanz), echter Browser.
|
||||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
| Schweregrad | Gefunden | Behoben | Offen (mit Empfehlung) |
|
||||
| --- | --- | --- | --- |
|
||||
| Hoch | 1 | 1 | 0 |
|
||||
| Mittel | 3 | 3 | 0 |
|
||||
| Niedrig | 5 | 3 | 2 |
|
||||
| Hinweis | 6 | – | 6 |
|
||||
| Schweregrad | Erst-Audit | Zweit-Audit | Behoben | Akzeptiert / dokumentiert |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Kritisch | 0 | 1 (nur Multisite) | 1 | 0 |
|
||||
| Hoch | 1 | 4 | 5 | 0 |
|
||||
| Mittel | 3 | 9 | 12 | 0 |
|
||||
| Niedrig | 5 | 11 | 12 | 4 |
|
||||
| Hinweis | 6 | 11 | 4 | 13 |
|
||||
|
||||
Der Login-Flow ist nach dem Audit **ohne bekannte kritische oder hohe Schwachstellen**. Der einzige Hoch-Befund
|
||||
(Account-Übernahme im Multi-Tenant-Modus über den unverifizierten `email`-Claim) wurde behoben. Die beiden offenen
|
||||
Niedrig-Befunde betreffen Betriebsumgebung und Konfiguration, nicht den Code.
|
||||
Nach beiden Audits sind **keine offenen kritischen, hohen oder mittleren Befunde** bekannt. Die schwersten Funde des
|
||||
Zweit-Audits betrafen nicht den OIDC-Kern (der hielt allen Angriffen stand), sondern die Ränder: Umgehung des
|
||||
Nur-Button-Modus über `xmlrpc.php`/REST, die Verknüpfung von Administrator-Konten über das frei setzbare
|
||||
`mail`-Attribut und Multisite-Rechte.
|
||||
|
||||
## 2. Bedrohungsmodell
|
||||
|
||||
|
|
@ -55,7 +60,8 @@ Sicherheit bleibt die Tenant-GUID die Empfehlung.
|
|||
|
||||
Der Nur-Button-Modus prüfte `$GLOBALS['pagenow']`; eigene Login-Formulare (`wp_signon()` von einer Seite)
|
||||
umgingen die Sperre. Jetzt greift der `authenticate`-Filter (Priorität 99, nach den Core-Handlern) für jede
|
||||
interaktive Passwort-Anmeldung; ausgenommen sind XML-RPC, REST (Application Passwords), WP-CLI und Cron, plus ein
|
||||
interaktive Passwort-Anmeldung; ausgenommen sind XML-RPC, REST (Application Passwords), WP-CLI und Cron (im Zweit-Audit als
|
||||
umgehbar erkannt und auf „nur Application Passwords und WP-CLI“ verschärft, siehe Z-3), plus ein
|
||||
Opt-out-Filter für vertrauenswürdige Plugins. Tests: `password login blocked without fallback cookie`,
|
||||
`forged fallback cookie rejected`, `REST requests exempt`.
|
||||
|
||||
|
|
@ -180,7 +186,77 @@ gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen).
|
|||
8. WordPress-Salts nicht ohne Neueingabe von Secret/Zertifikat rotieren.
|
||||
9. Vor Produktivgang: Durchlauf in einer Staging-Installation inkl. der Fehlerfälle (falsche E-Mail, fremde Gruppe, abgelaufenes Secret).
|
||||
|
||||
## 6. Nicht im Umfang
|
||||
## 6. Zweit-Audit 1.1.0 (23.09.2026)
|
||||
|
||||
Status: ✅ behoben (mit Regressionstest) · 📄 akzeptiert/dokumentiert
|
||||
|
||||
### Kritisch / Hoch
|
||||
|
||||
| ID | Befund | Status und Fix |
|
||||
| --- | --- | --- |
|
||||
| Z-1 | **Multisite: Site-Admin → Super-Admin-Übernahme.** Einstellungen verlangten nur `manage_options`. Ein Site-Admin konnte einen eigenen Tenant eintragen, einem Benutzer dort `mail` = Adresse des Super-Admins geben und sich per Microsoft als Super-Admin anmelden; der Sync verknüpfte bzw. überschrieb netzwerkweite Konten. | ✅ Auf Multisite verlangen Einstellungsseite, `options.php` (`option_page_capability_m365_login`), alle AJAX-Aktionen und der Zertifikats-Download `manage_network_options`. |
|
||||
| Z-2 | **Kontoübernahme über das `mail`-Attribut (Sync und Login).** Ein Benutzer-/Exchange-Admin des Tenants setzt `mail` = E-Mail eines noch nicht verknüpften WordPress-Admins → Sync bzw. erste Anmeldung verknüpft das Admin-Konto mit seiner Objekt-ID; bei verknüpften Konten schrieb der Sync die WordPress-E-Mail ohne Benachrichtigung um (→ Passwort-Reset). | ✅ Privilegierte Konten (`manage_options`, `promote_users`, `edit_users`, Super-Admin) werden nur verknüpft, wenn der UPN eines Mitglieds (kein Gast, keine externe Identität) exakt ihrer E-Mail entspricht – im Sync und bei der ersten Anmeldung (`privileged_unlinked`). Ihre E-Mail ändert der Sync nie; bei anderen Konten bleibt die Änderungs-Mail an die alte Adresse aktiv. Filter `m365_login_is_privileged_user`. |
|
||||
| Z-3 | **Nur-Button-Modus über `xmlrpc.php` umgehbar.** Die Ausnahme hing am Anfragekontext (`XMLRPC_REQUEST`), nicht an der Art der Zugangsdaten. Ein Login-Formular eines anderen Plugins (z. B. WooCommerce auf `wp_loaded`), per POST an `/xmlrpc.php` geschickt, lieferte ein vollwertiges Admin-Cookie; außerdem akzeptierte XML-RPC das normale Passwort. | ✅ Ausnahme nur noch nach Zugangsdaten: Application Password (`application_password_did_authenticate`) oder WP-CLI. Normales Passwort überall abgelehnt. Zusätzlich setzt der Filter `send_auth_cookies` in XML-RPC-/REST-Anfragen nie ein Login-Cookie. Per HTTP nachgetestet. |
|
||||
| Z-4 | **Multi-Tenant: Übernahme über `preferred_username` externer Identitäten.** Gäste/föderierte Identitäten eines fremden Tenants konnten einen beliebigen Namen tragen. | ✅ Im Multi-Tenant-Modus werden Tokens mit abweichendem `idp` abgelehnt (`external_identity`); privilegierte Konten sind dort gar nicht erstmals verknüpfbar (Z-2). |
|
||||
| Z-5 | **Fehlende Log-Methode** (seit Umstellung auf eigene Login-Seiten): jeder fehlgeschlagene Microsoft-Login endete in einem PHP-Fatal-Error statt einer Meldung. | ✅ `log()` wiederhergestellt (eigener Commit). |
|
||||
|
||||
### Mittel
|
||||
|
||||
| ID | Befund | Status und Fix |
|
||||
| --- | --- | --- |
|
||||
| Z-6 | Application Password → interaktive Session: `POST /wp/v2/users/me {password}` setzt in Core ein Login-Cookie. | ✅ `send_auth_cookies`-Sperre in REST/XML-RPC (Z-3). |
|
||||
| Z-7 | REST-Login-Endpunkte anderer Plugins (`wp_signon` in einer Route) waren durch die `REST_REQUEST`-Ausnahme freigestellt. | ✅ Ausnahme entfernt (Z-3). |
|
||||
| Z-8 | Passwort-Orakel: falsches Passwort → „incorrect“, richtiges → „Password sign-in is disabled“. | ✅ Im Nur-Button-Modus dieselbe Meldung für jeden Passwortversuch. |
|
||||
| Z-9 | In Entra deaktivierte, verknüpfte Personen wurden nicht deaktiviert, wenn ihre Mail leer war oder auf eine nicht erlaubte Domain wechselte (typisch beim Offboarding). | ✅ Kontostatus verknüpfter Personen wird vor jeder E-Mail-/Domain-Prüfung ausgewertet. |
|
||||
| Z-10 | Deaktivierung hielt nur, solange das Plugin aktiv war (Passwort und Application Passwords blieben gültig; Deinstallation entsperrte). | ✅ Deaktivierung setzt ein Zufallspasswort, löscht alle Application Passwords, entzieht die Rolle (gemerkt, bei Reaktivierung zurück) und beendet alle Sessions. |
|
||||
| Z-11 | Multisite: Deaktivierung durch eine Unter-Site galt netzwerkweit, „Löschen“ entfernte nur aus der Site. | ✅ Durch Z-1 nur noch von Super-Admins konfigurierbar; Verhalten dokumentiert (README, FAQ). |
|
||||
| Z-12 | Private Schlüssel im Zertifikatsfeld: ein eingefügtes Key+Cert-Bündel wurde als „Zertifikat“ im Klartext gespeichert und im `.cer`-Download ausgeliefert. | ✅ Zertifikatsfeld mit `PRIVATE KEY` wird abgelehnt; gespeichert wird nur das per `openssl_x509_export` neu exportierte Einzelzertifikat. |
|
||||
| Z-13 | Nur-Button-Modus schaltete sich ab, sobald die Verbindung als „nicht konfiguriert“ galt (abgelaufenes Zertifikat, rotierte Salts) → Passwort-Login ohne MFA kam still zurück. | ✅ Modus hängt nur an Einstellung, Tenant/Client-ID und Fallback-Key. Bei kaputter Verbindung roter Admin-Hinweis und Meldung auf der Login-Seite. |
|
||||
| Z-14 | IP-Limits als globaler DoS hinter NAT/Proxy (30 Login-Starts/10 Min.; 10 falsche Fallback-Keys sperrten auch den richtigen); linker, vom Client gesetzter XFF-Eintrag. | ✅ Start-Limit 300; der richtige Fallback-Key funktioniert immer; aus dem Proxy-Header wird der rechte (vom Proxy geschriebene) Eintrag genommen. |
|
||||
|
||||
### Niedrig
|
||||
|
||||
| ID | Befund | Status und Fix |
|
||||
| --- | --- | --- |
|
||||
| Z-15 | Fallback-Cookie ohne serverseitiges Ablaufdatum (konstanter HMAC). | ✅ Cookie = `Zeit|HMAC(Zeit, Key)`, Alter wird serverseitig geprüft (30 Min.). |
|
||||
| Z-16 | Eine Objekt-ID an zwei Konten gebunden → nur eines wurde deprovisioniert; der Login band ohne Eindeutigkeitsprüfung. | ✅ Sync behandelt alle Konten einer Objekt-ID; der Login verweigert die Bindung einer bereits vergebenen Objekt-ID. |
|
||||
| Z-17 | Foto-Löschung prüfte den gespeicherten Pfad nur per Präfix (`../` möglich) – ausnutzbar nur mit Schreibzugriff auf Meta. | ✅ Strenge Prüfung gegen das Dateinamensmuster überall (Löschen, Avatar-URL, Vergleich). |
|
||||
| Z-18 | Sicherheitsstopp umgehbar durch leere Graph-Antwort oder Tenant-Wechsel (alle Objekt-IDs → 404 → „gelöscht“). | ✅ Leere Liste bei vorhandenen Verknüpfungen bricht ab; nach einem Tenant-Wechsel wird im ersten Lauf nichts deprovisioniert. |
|
||||
| Z-19 | Lauf-Sperre nicht atomar (Transient), lief bei langen Läufen ab. | ✅ Atomare Sperre per `add_option` mit Token, 2 h, Übernahme nur bei veralteter Sperre. |
|
||||
| Z-20 | Query-Parameter-Injektion in die Start-URL des Buttons (`add_query_arg` kodiert nicht). | ✅ `redirect_to` wird validiert und URL-kodiert. |
|
||||
| Z-21 | `sanitize()` stürzte bei Array-Eingaben ab bzw. speicherte `"Array"` als Secret (nur durch Admin manipulierbar). | ✅ Skalar-Prüfung für alle Textfelder. |
|
||||
| Z-22 | Graph-Retries mit `sleep` im interaktiven Login (bis ~30 s pro Prüfung). | ✅ Keine Retries im Login-Pfad, nur im Sync. |
|
||||
| Z-23 | Gruppenregeln im Multi-Tenant-Modus blockieren jede Anmeldung (fail closed, aber unerwartet). | ✅ Warnhinweis im Tab Sicherheit. |
|
||||
| Z-24 | Rollen können nach einer Entra-Gruppe vergeben werden, deren Mitglieder sich selbst eintragen können (öffentliche Microsoft-365-Gruppen, Teams). | 📄 Gruppensuche kennzeichnet öffentliche Microsoft-365-Gruppen; Warnhinweis bei der Rollen-Zuordnung (Sicherheitsgruppen, ideal rollenzuweisbar, verwenden). |
|
||||
| Z-25 | Bestehende Administratoren werden bei Austritt nicht automatisch deaktiviert (nur protokolliert). | 📄 Bewusst (Schutz vor Aussperren); im Protokoll als „geschützt“ gemeldet. |
|
||||
|
||||
### Hinweise
|
||||
|
||||
| ID | Hinweis | Status |
|
||||
| --- | --- | --- |
|
||||
| Z-26 | Ein AJAX-Nonce für alle Aktionen (auch destruktive). | ✅ Eigener Nonce pro Aktion. |
|
||||
| Z-27 | `wp_dropdown_roles()` gibt Rollennamen unescaped aus. | ✅ Eigene, escapte Optionsliste. |
|
||||
| Z-28 | Uninstall ließ den Graph-Token in einem persistenten Object Cache. | ✅ `delete_transient()` für die bekannten Schlüssel; Sperre und Tenant-Option werden entfernt. |
|
||||
| Z-29 | Verschlüsselung nutzlos, wenn die Salts nicht in `wp-config.php` stehen (dann in der Datenbank). | ✅ Doku korrigiert, Warnhinweis im Backend. |
|
||||
| Z-30 | Button-Icon darf eine externe http(s)-URL sein (Tracking/Mixed Content). | 📄 Admin-Entscheidung; Mediathek wird empfohlen. |
|
||||
| Z-31 | JWKS-`issuer` wird im Multi-Tenant-Modus nicht geprüft. | 📄 Risiko minimal (Token kommt direkt vom Token-Endpunkt über TLS). |
|
||||
| Z-32 | Gruppenregeln wirken nur bei der Microsoft-Anmeldung; bestehende Sessions (bis 14 Tage), Application Passwords laufen weiter; Deaktivierung aus Entra greift erst beim nächsten Sync. | 📄 Dokumentiert; Sync-Intervall und „Angemeldet bleiben“ entsprechend wählen. |
|
||||
| Z-33 | Plugins, die selbst `wp_set_current_user()`/`wp_set_auth_cookie()` nach eigener Passwortprüfung aufrufen, umgehen jede Login-Sperre. | 📄 Außerhalb der Kontrolle des Plugins. |
|
||||
| Z-34 | Fallback-Key steht in der URL (Webserver-/Proxy-Logs). | 📄 Link nach Nutzung neu erzeugen; Logs schützen. |
|
||||
| Z-35 | Im Single-Tenant-Modus melden sich B2B-Gäste über ihren `email`-Claim an. | 📄 Für normale Konten gewollt; privilegierte Konten sind für Gäste nicht verknüpfbar (Z-2). |
|
||||
| Z-36 | Profilbilder öffentlich, mit EXIF-Daten, auch nach Deaktivierung; Benutzernamen aus dem lokalen Teil der E-Mail. | 📄 Dokumentiert; Fotos/Felder sind Opt-in und werden beim Abwählen entfernt. |
|
||||
|
||||
### Geprüft und ohne Befund (Auszug)
|
||||
|
||||
- **OIDC-Kern:** State 256 Bit, einmalig, HMAC-Transient-Schlüssel, an HttpOnly/SameSite-Cookie gebunden; `error` erst nach State-Prüfung; PKCE S256; Nonce mit `hash_equals`; kein Login-CSRF, keine Code-Injection.
|
||||
- **JWT:** nur RS256 (`none`/HS256/Array-`kid` abgelehnt), `openssl_verify === 1`, `aud` exakt, `tid`-Pinning, `iss` aus `tid` gebildet, `exp`/`nbf`/`iat` mit 120 s Toleranz, JWKS-Refresh nur bei unbekanntem `kid`.
|
||||
- **Krypto:** AES-256-GCM mit frischem 96-Bit-IV und 16-Byte-Tag, HKDF-SHA256; manipulierte Tags abgelehnt. Client Assertion mit korrektem `aud`, `jti`, `exp`, `x5t`/`x5t#S256`.
|
||||
- **Gruppen:** Ausschluss fail closed, Overage/gefilterter Claim → Graph, Großschreibung normalisiert.
|
||||
- **Admin:** jede AJAX-Aktion mit Nonce + Capability; kein Stored/DOM-XSS (Graph-Gruppennamen, Sync-Attribute, Protokoll, Rollenname per jsdom-Fuzzing geprüft); keine CSS-Injection; kein SSRF (Tenant nur GUID/Alias); keine offenen Redirects (`//evil`, `/\evil`, `user@evil`, `javascript:`).
|
||||
- **Deaktivierte Konten:** Passwort, XML-RPC (Passwort und Application Password), REST, bestehende Cookies, Microsoft-Callback, Super-Admin – alle abgewiesen; keine Selbst-Reaktivierung möglich.
|
||||
- **Graph-Client:** Paging-Links auf `https://graph.microsoft.com/v1.0/` festgelegt, 1000-Seiten-Limit, jeder Fehler bricht vor Änderungen ab.
|
||||
|
||||
## 7. Nicht im Umfang
|
||||
|
||||
Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes,
|
||||
Schwachstellen in PHP/OpenSSL.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue