diff --git a/CHANGELOG.md b/CHANGELOG.md index c6863e1..52777be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ All notable changes to this project are documented in this file. The format foll ### Changed - The group picker is reusable (security groups, sync groups, role mapping). +### Security +Fixes from a full second security audit (details: docs/security-audit.md, section 6): +- Multisite: settings, connection test, certificates and user sync require `manage_network_options` (a site admin could otherwise sign in as the super admin via an own tenant). +- Administrator accounts are only linked (sync and first sign-in) through a matching user principal name of a member account, never through the freely settable `mail` attribute; their e-mail address is never changed by the sync; e-mail change notifications stay on for other accounts. +- Button-only mode: exemption by credential (application passwords, WP-CLI) instead of request context – closes bypasses through `xmlrpc.php` and REST login handlers of other plugins; API requests never receive login cookies; same message for right and wrong passwords; mode no longer switches off when the connection breaks; fallback cookie expires on the server; the correct fallback key works despite IP lockouts; right-most proxy header entry; start limit raised to 300. +- Multi-tenant mode refuses guest/external identities; one Microsoft object ID can only be bound to one WordPress account. +- Deactivation also sets a random password, revokes application passwords and removes the role (restored on reactivation); linked people disabled in Microsoft 365 are deactivated even if their e-mail vanished or changed domain; duplicate bindings are all deprovisioned. +- Sync safety: abort on an empty directory answer, no deprovisioning in the first run after a tenant change, atomic run lock, strict photo path validation. +- Certificates: key+certificate bundles are refused in the certificate field and only a clean re-exported certificate is stored. +- Robust sanitising of array input, encoded `redirect_to` in the button URL, separate nonces per AJAX action, escaped role lists, no Graph retries/sleeps during sign-in, warnings for public Microsoft 365 groups, group rules in multi-tenant mode and salts missing from wp-config.php, cached Graph token removed on uninstall. + ### Fixed - Failed Microsoft sign-ins (token exchange, token verification, object ID mismatch, group checks) ended in a PHP fatal error because the auth component's log helper had been removed in 1.0.0 development. - "Generate certificate" and removing the certificate did not keep the change and encrypted a stored client secret a second time (internal settings writes ran through the form sanitiser). diff --git a/README.md b/README.md index 9020155..b3b66fa 100644 --- a/README.md +++ b/README.md @@ -259,8 +259,14 @@ Im Tab *Sicherheit* → **Button-only mode**: - Blendet Benutzername/Passwort-Felder und den „Passwort vergessen?“-Link aus. - **Sperrt Passwort-Logins serverseitig**, nicht nur per CSS – auf `wp-login.php` und in jedem eigenen Login-Formular (`authenticate`-Filter). -- Application Passwords, REST API, XML-RPC, WP-CLI und Cron sind nicht betroffen. Einzelne Ausnahmen per Filter `m365_login_block_password_login`. -- Wird erst aktiv, wenn die Verbindung vollständig konfiguriert ist. +- Unterschieden wird nach **Zugangsdaten, nicht nach Anfrage-Typ**: Das normale Passwort wird überall abgelehnt – auch über + XML-RPC und in Login-Handlern anderer Plugins, die in `xmlrpc.php` oder einer REST-Anfrage laufen. Application Passwords + (REST, XML-RPC) und WP-CLI funktionieren weiter; API-Anfragen bekommen aber nie ein Login-Cookie. +- Falsches und richtiges Passwort erhalten dieselbe Meldung (kein Passwort-Orakel). +- Einzelne Ausnahmen per Filter `m365_login_block_password_login`. +- Bleibt aktiv, auch wenn die Verbindung zu Microsoft kaputtgeht (abgelaufenes Zertifikat, rotierte Salts) – dann gibt es + einen roten Hinweis im Backend, und nur der Fallback-Link oder die Konstante helfen. Passwort-Logins schalten sich nie + stillschweigend wieder ein. **Fallback (Notausgang):** Beim Speichern erzeugt das Plugin einen geheimen Schlüssel und zeigt den Fallback-Link an: @@ -298,6 +304,13 @@ Plugins, die `wp-login.php` umbenennen (z. B. WPS Hide Login), sind kompatibel, ### Benutzer-Sync +> **Administrator-Konten** (alle mit `manage_options`, `promote_users`, `edit_users` oder Super-Admin) werden nie über das +> `mail`-Attribut verknüpft, sondern nur, wenn der **User Principal Name** eines Mitglieds (kein Gast) exakt ihrer +> WordPress-E-Mail entspricht – das `mail`-Attribut kann jeder Benutzer- oder Exchange-Admin des Tenants frei setzen, der +> UPN nur auf verifizierten Domains. Dieselbe Regel gilt für die erste Microsoft-Anmeldung eines Administrators. Ihre +> E-Mail-Adresse ändert der Sync nie automatisch. Bei allen anderen Konten informiert WordPress die alte Adresse über eine +> Änderung. + Tab *Benutzer-Sync*. Legt WordPress-Konten für Microsoft-365-Benutzer an und hält sie aktuell – manuell per Knopfdruck, automatisch per WP-Cron (stündlich, zweimal täglich, täglich) oder per WP-CLI. @@ -346,7 +359,9 @@ höchstens 500 pro Lauf, der Rest folgt im nächsten (Filter `m365_login_sync_ph wenn Microsoft ausdrücklich „kein Foto“ meldet. **Abgewählte** Felder und Bilder werden beim nächsten Lauf aus den Profilen entfernt (Vor-, Nach- und Anzeigename bleiben stehen); wird ein Benutzer in WordPress gelöscht, wird auch sein Bild gelöscht. -**Deaktivierte Konten** können sich überhaupt nicht mehr anmelden – weder per Microsoft noch per Passwort, +**Deaktivierte Konten** bekommen ein Zufallspasswort, verlieren ihre Rolle auf der Site (sie wird gemerkt und bei der +Reaktivierung zurückgegeben) und alle Application Passwords – so bleiben sie auch gesperrt, wenn das Plugin einmal +deaktiviert wird. Sie können sich überhaupt nicht mehr anmelden – weder per Microsoft noch per Passwort, Anwendungspasswort oder bestehender Session (alle Sessions werden beendet). In der Benutzerliste zeigt die Spalte *Microsoft 365* den Status; per Zeilenaktion lassen sich Konten auch von Hand deaktivieren und reaktivieren. @@ -474,7 +489,7 @@ add_filter( 'm365_login_sync_protect_user', function ( $protected, WP_User $user // Sicherheitsstopp anheben (Standard: 20 % der verknüpften Konten, mindestens 5) add_filter( 'm365_login_sync_deprovision_limit', fn( $limit, $linked ) => max( 10, $linked * 0.5 ), 10, 2 ); -// Weitere: m365_login_sync_email, m365_login_sync_photo_limit, m365_login_sync_photo_interval +// Weitere: m365_login_sync_email, m365_login_sync_photo_limit, m365_login_sync_photo_interval, m365_login_is_privileged_user // Actions: m365_login_sync_user_created, m365_login_sync_finished, m365_login_user_disabled, m365_login_user_enabled ``` @@ -565,12 +580,14 @@ Ja, Tenant auf consumers oder common stellen. Microsof
Multisite? -Ja. Einstellungen gelten pro Site; der Benutzer muss Mitglied der Site (oder Super-Admin) sein. +Ja. Einstellungen gelten pro Site, dürfen aber **nur von Super-Admins** geändert werden: Sie entscheiden, welche +Microsoft-Identität sich als welcher (netzwerkweite) WordPress-Benutzer anmelden darf. Ein Site-Admin könnte sonst einen +eigenen Tenant eintragen und sich als Super-Admin anmelden. Der Benutzer muss Mitglied der Site (oder Super-Admin) sein.
Was passiert beim Deinstallieren? -Einstellungen, Caches (Transients), Sync-Protokoll, Cron-Termin, gespeicherte Profilbilder und die pro Benutzer gespeicherten Plugin-Daten (Objekt-ID, Deaktivierungs-Status) werden entfernt – auch in Multisite. Importierte Konten und übernommene Profilfelder (m365_*) bleiben erhalten. Deaktivierte Konten sind danach wieder aktiv; wer sie sperren will, sollte sie vorher löschen. +Einstellungen, Caches (Transients), Sync-Protokoll, Cron-Termin, gespeicherte Profilbilder und die pro Benutzer gespeicherten Plugin-Daten (Objekt-ID, Deaktivierungs-Status) werden entfernt – auch in Multisite. Importierte Konten und übernommene Profilfelder (m365_*) bleiben erhalten. Deaktivierte Konten bleiben ohne Rolle, mit Zufallspasswort und ohne Application Passwords – der Deaktivierungs-Vermerk selbst wird entfernt.
diff --git a/assets/js/admin.js b/assets/js/admin.js index 322fd04..28b83f6 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -3,6 +3,7 @@ 'use strict'; var cfg = window.m365LoginAdmin || {}; + cfg.nonces = cfg.nonces || {}; var i18n = cfg.i18n || {}; $( function () { @@ -166,7 +167,7 @@ $btn.prop( 'disabled', true ); $out.removeClass( 'is-success is-error' ).prop( 'hidden', false ).text( i18n.generating || '…' ); - $.post( cfg.ajaxUrl, { action: cfg.certAction, nonce: cfg.nonce, op: 'generate' } ).done( function ( res ) { + $.post( cfg.ajaxUrl, { action: cfg.certAction, nonce: cfg.nonces.cert, op: 'generate' } ).done( function ( res ) { if ( res && res.success ) { $out.addClass( 'is-success' ).text( res.data.message ); window.setTimeout( function () { window.location.reload(); }, 800 ); @@ -312,7 +313,7 @@ $.post( cfg.ajaxUrl, { action: cfg.groupAction, - nonce: cfg.nonce, + nonce: cfg.nonces.groups, query: query } ).done( function ( res ) { if ( ! res || ! res.success ) { @@ -369,7 +370,7 @@ $( '.m365-sync-run' ).prop( 'disabled', true ); $report.html( $( '

' ).text( i18n.syncRunning ) ); - $.post( cfg.ajaxUrl, { action: cfg.syncAction, nonce: cfg.nonce, op: op } ).done( function ( res ) { + $.post( cfg.ajaxUrl, { action: cfg.syncAction, nonce: cfg.nonces.sync, op: op } ).done( function ( res ) { if ( res && res.success ) { $report.html( res.data.html ); } else { @@ -400,7 +401,7 @@ $.post( cfg.ajaxUrl, { action: cfg.action, - nonce: cfg.nonce, + nonce: cfg.nonces.test, tenant: $( '#m365-tenant' ).val() } ).done( function ( res ) { if ( res && res.success ) { diff --git a/docs/security-audit.md b/docs/security-audit.md index 50eb2c3..28ef627 100644 --- a/docs/security-audit.md +++ b/docs/security-audit.md @@ -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. diff --git a/includes/class-m365-login-admin.php b/includes/class-m365-login-admin.php index cbfff4d..2ca6399 100644 --- a/includes/class-m365-login-admin.php +++ b/includes/class-m365-login-admin.php @@ -12,15 +12,18 @@ defined( 'ABSPATH' ) || exit; */ class M365_Login_Admin { - const PAGE = 'm365-login'; - const GROUP = 'm365_login'; - const AJAX_TEST = 'm365_login_test_connection'; - const AJAX_GROUPS = 'm365_login_search_groups'; - const AJAX_CERT = 'm365_login_certificate'; - const AJAX_SYNC = 'm365_login_sync_run'; - const POST_CERT = 'm365_login_download_cert'; - const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+'; - const NONCE_TEST = 'm365_login_test'; + const PAGE = 'm365-login'; + const GROUP = 'm365_login'; + const AJAX_TEST = 'm365_login_test_connection'; + const AJAX_GROUPS = 'm365_login_search_groups'; + const AJAX_CERT = 'm365_login_certificate'; + const AJAX_SYNC = 'm365_login_sync_run'; + const POST_CERT = 'm365_login_download_cert'; + const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+PHBhdGggZmlsbD0iYmxhY2siIGQ9Ik0yIDJoNy41djcuNUgyek0xMC41IDJIMTh2Ny41aC03LjV6TTIgMTAuNWg3LjVWMThIMnpNMTAuNSAxMC41SDE4VjE4aC03LjV6Ii8+PC9zdmc+'; + const NONCE_TEST = 'm365_login_test'; + const NONCE_GROUPS = 'm365_login_groups'; + const NONCE_CERT = 'm365_login_cert'; + const NONCE_SYNC = 'm365_login_sync'; /** * Settings. @@ -82,6 +85,19 @@ class M365_Login_Admin { add_action( 'update_option_' . M365_LOGIN_OPTION, array( $this->graph, 'flush_token' ) ); add_action( 'admin_notices', array( $this, 'setup_notice' ) ); add_filter( 'submenu_file', array( $this, 'highlight_submenu' ) ); + add_filter( 'option_page_capability_' . self::GROUP, array( __CLASS__, 'capability' ) ); + } + + /** + * Capability needed for the settings, the connection test, certificates and the user sync. + * + * On multisite only super admins: the settings decide which Microsoft identities may sign in + * as which (network-wide) WordPress users, so a site administrator must not control them. + * + * @return string + */ + public static function capability() { + return is_multisite() ? 'manage_network_options' : 'manage_options'; } /** @@ -91,7 +107,7 @@ class M365_Login_Admin { $this->hook = add_menu_page( __( 'M365 Login', 'm365-login' ), __( 'M365 Login', 'm365-login' ), - 'manage_options', + self::capability(), self::PAGE, array( $this, 'render' ), self::MENU_ICON, @@ -103,7 +119,7 @@ class M365_Login_Admin { self::PAGE, $label . ' – ' . __( 'M365 Login', 'm365-login' ), $label, - 'manage_options', + self::capability(), 'connection' === $tab ? self::PAGE : 'admin.php?page=' . self::PAGE . '&tab=' . $tab ); } @@ -182,7 +198,19 @@ class M365_Login_Admin { * Nudges administrators to finish the setup. */ public function setup_notice() { - if ( $this->settings->is_configured() || ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( self::capability() ) ) { + return; + } + if ( ! $this->settings->is_configured() && $this->settings->button_only() ) { + printf( + '

%s %s

', + esc_html__( 'M365 Login: button-only mode is on, but the connection to Microsoft is broken (missing or undecryptable secret, or expired certificate). Nobody can sign in except through the fallback link.', 'm365-login' ), + esc_url( self::url() ), + esc_html__( 'Open the settings', 'm365-login' ) + ); + return; + } + if ( $this->settings->is_configured() ) { return; } $screen = get_current_screen(); @@ -220,7 +248,12 @@ class M365_Login_Admin { 'm365LoginAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( self::NONCE_TEST ), + 'nonces' => array( + 'test' => wp_create_nonce( self::NONCE_TEST ), + 'groups' => wp_create_nonce( self::NONCE_GROUPS ), + 'cert' => wp_create_nonce( self::NONCE_CERT ), + 'sync' => wp_create_nonce( self::NONCE_SYNC ), + ), 'action' => self::AJAX_TEST, 'groupAction' => self::AJAX_GROUPS, 'certAction' => self::AJAX_CERT, @@ -257,7 +290,7 @@ class M365_Login_Admin { */ public function ajax_test_connection() { check_ajax_referer( self::NONCE_TEST, 'nonce' ); - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( self::capability() ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 ); } @@ -295,8 +328,8 @@ class M365_Login_Admin { * AJAX: search Entra groups through Microsoft Graph. */ public function ajax_search_groups() { - check_ajax_referer( self::NONCE_TEST, 'nonce' ); - if ( ! current_user_can( 'manage_options' ) ) { + check_ajax_referer( self::NONCE_GROUPS, 'nonce' ); + if ( ! current_user_can( self::capability() ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 ); } if ( ! $this->settings->is_configured() ) { @@ -321,8 +354,8 @@ class M365_Login_Admin { * AJAX: generate a new self-signed certificate and store it (key encrypted). */ public function ajax_certificate() { - check_ajax_referer( self::NONCE_TEST, 'nonce' ); - if ( ! current_user_can( 'manage_options' ) ) { + check_ajax_referer( self::NONCE_CERT, 'nonce' ); + if ( ! current_user_can( self::capability() ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 ); } $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; @@ -354,8 +387,8 @@ class M365_Login_Admin { * AJAX: run the user sync (dry run or live) with the saved settings. */ public function ajax_sync() { - check_ajax_referer( self::NONCE_TEST, 'nonce' ); - if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'create_users' ) ) { + check_ajax_referer( self::NONCE_SYNC, 'nonce' ); + if ( ! current_user_can( self::capability() ) || ! current_user_can( 'create_users' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'm365-login' ) ), 403 ); } $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; @@ -486,7 +519,7 @@ class M365_Login_Admin { @@ -498,7 +531,7 @@ class M365_Login_Admin { @@ -506,11 +539,30 @@ class M365_Login_Admin { elements for all editable roles (escaped, unlike wp_dropdown_roles()). + * + * @param string $selected Selected role slug. + */ + private function role_options( $selected ) { + if ( ! function_exists( 'get_editable_roles' ) ) { + require_once ABSPATH . 'wp-admin/includes/user.php'; + } + foreach ( array_reverse( get_editable_roles() ) as $role => $details ) { + printf( + '', + esc_attr( $role ), + selected( $selected, $role, false ), + esc_html( translate_user_role( $details['name'] ) ) + ); + } + } + /** * Sends the public certificate as a .cer download (never the private key). */ public function download_certificate() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'm365-login' ), 403 ); } check_admin_referer( self::POST_CERT ); @@ -599,13 +651,14 @@ class M365_Login_Admin {

+

group_picker( 'sync_role_map', $this->settings->sync_role_map(), 'roles', __( 'No group mapping – everybody gets the default role.', 'm365-login' ) ); ?>
@@ -703,7 +756,7 @@ class M365_Login_Admin { * Renders the settings screen. */ public function render() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You are not allowed to access this page.', 'm365-login' ) ); } @@ -806,6 +859,9 @@ class M365_Login_Admin {

+ +

+
> @@ -1085,6 +1141,9 @@ class M365_Login_Admin {

+ settings->is_multi_tenant() ) : ?> +

+ group_picker( 'denied_groups', $this->settings->denied_groups(), 'chips', __( 'No groups excluded.', 'm365-login' ) ); ?>

@@ -1093,7 +1152,7 @@ class M365_Login_Admin {

-

+