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>
262 lines
25 KiB
Markdown
262 lines
25 KiB
Markdown
# Security-Audit: M365 Login 1.1.0
|
||
|
||
**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“ – 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.
|
||
|
||
> 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 | 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 |
|
||
|
||
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
|
||
|
||
**Schutzziele:** (1) Nur die Person, die ein Microsoft-Konto kontrolliert, darf sich als der zugehörige
|
||
WordPress-Benutzer anmelden. (2) Client Secret bzw. privater Schlüssel dürfen nicht abfließen. (3) Der
|
||
Nur-Button-Modus darf nicht umgangen werden. (4) Keine Rechteausweitung über die Admin-Oberfläche.
|
||
|
||
**Angreifer:** (A) anonymer Internet-Nutzer, (B) Benutzer eines fremden Entra-Tenants, (C) Benutzer des eigenen
|
||
Tenants ohne WordPress-Konto, (D) Angreifer mit Lesezugriff auf die Datenbank (Backup, SQL-Injection in anderem
|
||
Plugin), (E) Angreifer im Netzwerkpfad (MITM), (F) angemeldeter WordPress-Benutzer mit niedriger Rolle.
|
||
|
||
## 3. Befunde
|
||
|
||
### H-1 · Multi-Tenant-Modus: Kontoübernahme über den `email`-Claim — **behoben**
|
||
|
||
**Beschreibung.** Bei Tenant `organizations`/`common` akzeptiert das Plugin Tokens beliebiger Tenants. Der
|
||
`email`-Claim in Entra-ID-Tokens ist ein frei editierbares Benutzerattribut des ausstellenden Tenants. Angreifer (B)
|
||
legt in seinem eigenen Tenant einen Benutzer mit `mail = admin@opfer.de` an und meldet sich damit an; das Plugin
|
||
findet den WordPress-Admin per E-Mail. Mit gepinnter Tenant-GUID (Standard-Empfehlung) war der Angriff nicht möglich.
|
||
|
||
**Fix.** `M365_Login_Auth::email_from_claims()`: Im Multi-Tenant-Modus wird ausschließlich der UPN
|
||
(`preferred_username`, dessen Domain im ausstellenden Tenant verifiziert sein muss) verwendet; der `email`-Claim
|
||
nur, wenn Microsoft ihn per `xms_edov = true` als domain-verifiziert markiert. Zusätzlich deutlicher Warnhinweis im
|
||
Backend und Empfehlung der Domain-Allowlist. Test: `multi-tenant: unverified email claim ignored`.
|
||
|
||
**Restrisiko.** Ein fremder Tenant kann eine Domain nur verifizieren, wenn er sie kontrolliert. Für maximale
|
||
Sicherheit bleibt die Tenant-GUID die Empfehlung.
|
||
|
||
### M-1 · Passwort-Login-Sperre nur auf `wp-login.php` — **behoben**
|
||
|
||
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 (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`.
|
||
|
||
### M-2 · `authenticate`-Filter mit zu früher Priorität — **behoben**
|
||
|
||
Erste Fassung hing bei Priorität 5; `wp_authenticate_username_password()` (Priorität 20) überschreibt einen
|
||
übergebenen `WP_Error`, wenn Benutzername und Passwort stimmen — die Sperre wäre wirkungslos gewesen. Korrigiert auf
|
||
Priorität 99 und Blockade nur, wenn bereits ein `WP_User` vorliegt (Core-Fehlermeldungen bleiben erhalten).
|
||
|
||
### M-3 · Unbegrenzte Erzeugung von State-Datensätzen — **behoben**
|
||
|
||
Jeder Aufruf von `wp-login.php?action=m365_login` legt einen Transient (10 Min.) an. Angreifer (A) konnte die
|
||
Options-Tabelle fluten. Jetzt max. 30 Starts pro Client-IP und 10 Minuten (`too_many_attempts`).
|
||
|
||
### N-1 · Rate-Limit nach `REMOTE_ADDR` hinter Reverse Proxy — **behoben (opt-in)**
|
||
|
||
Hinter Cloudflare/Load Balancer teilen sich alle Besucher eine IP: Fehlversuche eines Angreifers sperren alle
|
||
(DoS auf den Fallback-Link), bzw. der Angreifer verteilt sich nicht. Neu: Konstante `M365_LOGIN_CLIENT_IP_HEADER`
|
||
bzw. Filter `m365_login_client_ip_header` zur Angabe eines vertrauenswürdigen Proxy-Headers. Nicht automatisch
|
||
aktiv, weil ein Client-Header ohne Proxy fälschbar wäre. Der Fallback-Key selbst hat ≈139 Bit Entropie; das
|
||
Rate-Limit ist Defense-in-Depth, kein primärer Schutz.
|
||
|
||
### N-2 · Client Secret / privater Schlüssel: Schlüsselableitung aus den WordPress-Salts — **behoben (Design)**
|
||
|
||
AES-256-GCM mit HKDF aus `AUTH_KEY`/`SECURE_AUTH_KEY`. Angreifer (D) mit reinem DB-Zugriff kann die Werte nicht
|
||
entschlüsseln. Angreifer mit Zugriff auf `wp-config.php` hat ohnehin Vollzugriff. Bewertung: angemessen für die
|
||
Plattform; ein HSM/KMS ist in WordPress nicht praktikabel. **Hinweis:** Rotation der Salts macht gespeicherte Werte
|
||
unlesbar (Plugin meldet „nicht konfiguriert“, Neueingabe nötig) — im Backend dokumentiert.
|
||
|
||
### N-3 · Zertifikats-Authentifizierung (RFC 7523) — **neu, geprüft**
|
||
|
||
Client Assertion: `RS256`, `aud` = Token-Endpunkt, `iss`/`sub` = Client-ID, `jti` 192 Bit Zufall, `exp` 5 Min.,
|
||
Header `x5t` und `x5t#S256`. Privater Schlüssel: 3072 Bit RSA, verschlüsselt gespeichert, nie ausgegeben (Download
|
||
liefert ausschließlich das Zertifikat, Capability + Nonce geprüft). Eigene PEM-Paare: Prüfung auf RSA ≥ 2048 Bit,
|
||
Schlüssel-Zertifikat-Zugehörigkeit, Ablaufdatum; verschlüsselte Keys werden abgelehnt (keine Passphrase-Speicherung).
|
||
Tests: `assertion signature verifies with certificate`, `mismatched key/cert rejected`, `1024-bit key rejected`.
|
||
|
||
### N-4 · Fehlermeldungen als Codes — **in Ordnung**
|
||
|
||
`m365_error` transportiert nur einen Whitelist-Code; Texte sind fest und übersetzt. Keine Reflektion von
|
||
Microsoft-Fehlertexten an Endnutzer (nur ins Debug-Log). Kein Unterschied zwischen „Benutzer existiert nicht“ und
|
||
anderen Fehlern gegenüber Angreifer (C)? — Doch: `no_user` ist unterscheidbar. Bewertung: akzeptabel, weil der
|
||
Angreifer dafür bereits ein gültiges Konto des Tenants braucht und die Information (welche E-Mails ein WP-Konto
|
||
haben) für Tenant-Mitglieder unkritisch ist. `wp_login_failed` wird ausgelöst, damit Limit-Login-Plugins zählen.
|
||
|
||
### N-5 · Transient-Verlust bei Object-Cache-Eviction — **offen, Hinweis**
|
||
|
||
State-Datensätze liegen in Transients. Bei einem Object Cache mit aggressiver Eviction (kleiner Redis) kann ein
|
||
State vor Ablauf verschwinden → „Login request expired“. Kein Sicherheitsproblem (fail closed), aber ein
|
||
Verfügbarkeitsthema. Empfehlung: ausreichende Cache-Größe oder `m365_login_*`-Keys von der Eviction ausnehmen.
|
||
|
||
### N-6 · `https_ssl_verify` durch Dritte deaktivierbar — **offen, Hinweis**
|
||
|
||
Alle Requests laufen über die WordPress-HTTP-API mit Zertifikatsprüfung. Setzt ein anderes Plugin
|
||
`add_filter('https_ssl_verify','__return_false')`, wäre Angreifer (E) in der Lage, JWKS und Token-Endpunkt zu
|
||
fälschen. Das Plugin erzwingt `sslverify => true` für seine eigenen Requests nicht explizit, weil WordPress-Konventionen
|
||
den Site-Betreiber entscheiden lassen. Empfehlung: `https_ssl_verify` nie global deaktivieren.
|
||
|
||
### N-7 · Benutzer-Sync (1.1.0) — **neu, geprüft**
|
||
|
||
Der Sync legt Konten an, ändert Rollen und deaktiviert bzw. löscht Konten. Geprüft und abgesichert:
|
||
|
||
- **Auslösung:** nur durch Administratoren (`manage_options` + `create_users`, AJAX-Nonce), per WP-Cron nach expliziter
|
||
Aktivierung oder per WP-CLI. Der Login selbst legt weiterhin nie Konten an.
|
||
- **Vertrauensgrenze:** Wie beim Login gilt der gepinnte Tenant als vertrauenswürdig (Sync verlangt eine Tenant-GUID).
|
||
Bestehende Konten werden über die E-Mail-Adresse verknüpft; ein Konto mit abweichender gespeicherter Objekt-ID wird
|
||
übersprungen, eine E-Mail-Änderung auf eine bereits vergebene Adresse abgelehnt.
|
||
- **Fehlkonfiguration / Teilausfälle:** Jede fehlgeschlagene Graph-Anfrage bricht den Lauf vor jeder Deaktivierung ab.
|
||
„Gelöscht“ nur bei HTTP 404 für die konkrete Objekt-ID. Mehr als 20 % (mind. 5) Deaktivierungen/Löschungen pro Lauf →
|
||
Sicherheitsstopp ohne Änderungen. Testlauf ohne Schreibzugriffe. Sperre gegen Parallelläufe.
|
||
- **Rechteausweitung/-entzug:** Rollen werden nur bei importierten Konten (oder auf ausdrücklichen Wunsch) verwaltet.
|
||
Bestehende Administratoren, Super-Admins und das eigene Konto werden nie umgestuft, deaktiviert oder gelöscht.
|
||
Rollen-Slugs werden beim Speichern gegen existierende Rollen geprüft.
|
||
- **Deaktivierung:** blockiert Passwort- und Anwendungspasswort-Logins (`authenticate`, Priorität 100), bestehende Sessions
|
||
(`determine_current_user`, alle Session-Tokens werden gelöscht) und den Microsoft-Login. Löschen nur mit Übernahme der
|
||
Inhalte durch einen gültigen anderen Benutzer, sonst Deaktivierung.
|
||
- **Profilbilder:** Größenlimit 2 MB, Typprüfung per `getimagesizefromstring` (nur JPEG/PNG/GIF), Ablage über `wp_upload_bits`
|
||
in einem eigenen Unterordner, Dateiname aus gesalzenem Hash (keine Objekt-ID in der URL). Die Bilder sind – wie Gravatare – öffentlich.
|
||
- **Graph-Aufrufe:** nur `https://graph.microsoft.com/v1.0/`; Paging-Links werden auf diesen Präfix geprüft, IDs sind GUIDs.
|
||
- **Ausgabe:** Protokoll und Profilfelder werden escaped ausgegeben; Benutzer-Zeilenaktionen mit Nonce und `edit_user`.
|
||
|
||
### N-8 · Ausgeschlossene Gruppen und fehlende Log-Methode (1.1.0) — **neu / behoben**
|
||
|
||
- **Ausgeschlossene Entra-Gruppen:** Ausschluss vor Erlaubnis; Treffer im `groups`-Claim lehnt sofort ab, ohne Treffer wird
|
||
immer Graph `checkMemberGroups` (transitiv) gefragt, weil ein gefilterter Claim Nicht-Mitgliedschaft nicht beweist;
|
||
Graph-Fehler → Ablehnung (fail closed).
|
||
- **Behoben:** `M365_Login_Auth::log()` fehlte seit der Umstellung auf eigene Login-Seiten; alle Fehlerpfade des Callbacks
|
||
endeten in einem PHP-Fatal-Error (kein Sicherheitsleck – die Anmeldung scheiterte –, aber keine Fehlermeldung und ein
|
||
500er). Durch Tests in einer echten Installation gefunden.
|
||
|
||
Hinweis für den Betrieb: Personenbezogene Daten (Telefon, Adresse, Foto) nur synchronisieren, wenn sie auf der Website
|
||
gebraucht werden; die Auswahl ist bewusst Opt-in (Standard: nur Namen).
|
||
|
||
## 4. Geprüfte Kontrollen (ohne Befund)
|
||
|
||
| Bereich | Kontrolle | Ergebnis |
|
||
| --- | --- | --- |
|
||
| Autorisierungsanfrage | `state` 256 Bit, `nonce` 256 Bit, PKCE-Verifier 512 Bit (S256), `response_mode=query` | ✔ |
|
||
| State-Bindung | HMAC-Schlüssel in DB, Klartext nur in URL; HttpOnly/SameSite=Lax/Secure-Cookie mit separatem Token, Hash im Datensatz; einmalige Einlösung (Delete vor Prüfung); TTL 10 Min. | ✔ Login-CSRF und Replay ausgeschlossen |
|
||
| Token-Austausch | Server-zu-Server, Secret/Assertion nie im Browser; `redirect_uri` fest aus `home_url()` | ✔ |
|
||
| ID-Token | Nur `RS256`; `alg=none`/HMAC abgelehnt; `kid` Pflicht; JWKS über HTTPS, Cache 12 h, Refresh bei unbekanntem `kid`; `iss` gegen `tid` gebildet, `aud`, `tid` (Pinning), `exp`/`nbf`/`iat` mit 120 s Toleranz, `nonce` mit `hash_equals` | ✔ 11 Negativtests |
|
||
| Benutzerzuordnung | Login ohne Provisioning (Sync separat, siehe N-7); E-Mail lowercase + `is_email`; Domain-Allowlist; Gruppen-Check fail closed; `oid`-Bindung; Multisite-Mitgliedschaft | ✔ |
|
||
| Session | `wp_set_auth_cookie` nach Erfolg (neues Session-Token, keine Fixation); `login_redirect`-Filter; `wp_safe_redirect` überall | ✔ |
|
||
| Offene Redirects | `redirect_to` → `wp_validate_redirect`; Custom-Login-URL → `wp_validate_redirect` beim Speichern und beim Lesen | ✔ |
|
||
| SSRF | Tenant nur GUID oder Whitelist-Wort, `rawurlencode`; Graph-Pfade mit `rawurlencode`; keine benutzerkontrollierten Hosts | ✔ |
|
||
| Admin-Oberfläche | `manage_options` überall; Settings-API-Nonce; AJAX `check_ajax_referer` + Capability; Download `check_admin_referer` + Capability; alle Ausgaben `esc_*`; JS nutzt `.text()`/DOM-APIs statt HTML-Strings mit Nutzerdaten | ✔ |
|
||
| Eingaben | GUID-Regex, Hex-Farben, Radius-Cap, Icon-URL nur http(s) + Bildendung, Gruppen-IDs GUID, PEM-Größenlimit 20 KB | ✔ |
|
||
| Secrets in Logs | Nie geloggt; Token-Fehler nur als Fehlercode; Log nur bei `WP_DEBUG_LOG` | ✔ |
|
||
| Nur-Button-Fallback | Key 24 Zeichen/55er-Alphabet (≈139 Bit); Cookie enthält HMAC, nicht den Key; 30 Min.; 10 Versuche/IP/15 Min.; Rotation; Notschalter-Konstante | ✔ |
|
||
| Deinstallation | Option, Transients (prepared LIKE), User-Meta, Multisite-Loop | ✔ |
|
||
| Abhängigkeiten | Keine externen Bibliotheken, keine CDNs; OpenSSL-Pflicht bei Aktivierung geprüft | ✔ |
|
||
|
||
## 5. Empfehlungen für den Betrieb
|
||
|
||
1. **Tenant-GUID eintragen** (kein `organizations`/`common`), Domain-Allowlist setzen.
|
||
2. **Zertifikat statt Secret** verwenden; Ablauf im Kalender notieren (Backend zeigt Restlaufzeit).
|
||
3. In Entra ID **„Zuweisung erforderlich“** für die Enterprise-Anwendung aktivieren und Benutzer/Gruppen zuweisen — zweite Schranke neben der WordPress-Benutzerliste.
|
||
4. Gruppen-Beschränkung mit `groups`-Claim *und* Graph-Berechtigung einrichten (Overage-Fall).
|
||
5. **Conditional Access / MFA** im Tenant erzwingen; das Plugin erbt die Stärke der Microsoft-Anmeldung.
|
||
6. HTTPS mit HSTS; `COOKIE_DOMAIN` korrekt; hinter Proxy `M365_LOGIN_CLIENT_IP_HEADER` setzen.
|
||
7. Nur-Button-Modus erst nach erfolgreichem eigenen Microsoft-Login aktivieren; Fallback-Link im Passwortmanager ablegen.
|
||
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. 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.
|