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
Privileged accounts are never linked through the settable mail attribute. Two new ways make that workable when UPN and e-mail differ: - "Link Microsoft account" on the profile screen: the signed-in user (nonce, same browser via the state cookie, same user at the callback) signs in with Microsoft once and binds that identity. Existing links can only be removed by an administrator; an object ID bound elsewhere is refused. - "Assigned Microsoft account (UPN)" per user, editable by administrators, used by sign-in and user sync; with an option to remove a link. Sign-in now finds accounts by bound object ID first, then by assigned UPN, then by e-mail, so linked users sign in whatever their addresses. Also: third-audit report (docs/security-audit.md section 7), README section on linking administrator accounts, translations, tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
302 lines
31 KiB
Markdown
302 lines
31 KiB
Markdown
# Security-Audit: M365 Login 1.1.0
|
||
|
||
**Stand:** 24.09.2026 (Erst-Audit 22.09.2026, Zweit-Audit 23.09.2026, Dritt-Audit 24.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 | Dritt-Audit | Behoben | Akzeptiert / dokumentiert |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| Kritisch | 0 | 1 (nur Multisite) | 0 | 1 | 0 |
|
||
| Hoch | 1 | 4 | 3 | 8 | 0 |
|
||
| Mittel | 3 | 9 | 5 | 17 | 0 |
|
||
| Niedrig | 5 | 11 | 9 | 20 | 5 |
|
||
| Hinweis | 6 | 11 | 8 | 8 | 17 |
|
||
|
||
Nach drei Audits sind **keine offenen kritischen, hohen oder mittleren Befunde** bekannt. Das Dritt-Audit hat gezielt die
|
||
Fixes des Zweit-Audits angegriffen und dabei unter anderem eine Umgehung über XML-RPC `system.multicall` gefunden. 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. Dritt-Audit 1.1.0 (24.09.2026)
|
||
|
||
Drei unabhängige Prüfbereiche: (1) Review aller Fixes des Zweit-Audits auf Vollständigkeit, Umgehbarkeit und neue Fehler,
|
||
(2) frischer Penetrationstest der Anmeldewege, (3) Sync, Admin-Oberfläche, Datenhaltung und Datenschutz. Jeder Befund per
|
||
Proof of Concept bestätigt (HTTP gegen `xmlrpc.php`/`wp-login.php`, signierte Test-Tokens, simulierte Graph-API, in eine
|
||
Multisite umgewandelte Testinstanz); jeder Fix mit Regressionstest.
|
||
|
||
| ID | Schwere | Befund | Status und Fix |
|
||
| --- | --- | --- | --- |
|
||
| D-1 | Hoch | **Nur-Button-Modus über XML-RPC `system.multicall`:** Die Ausnahme für Application Passwords nutzte `did_action()` (anfrageweit). Aufruf 1 mit einem beliebigen eigenen Application Password, Aufruf 2 mit Admin-Name und normalem Passwort → Admin-Zugriff. | ✅ Ausnahme nur für genau den Benutzer, den das Application Password im selben Anmeldedurchlauf authentifiziert hat (Reset bei Priorität 0). Per HTTP nachgetestet. |
|
||
| D-2 | Hoch | Admin-Schutzregel griff nicht bei ausgeschalteter Objekt-ID-Bindung (`bind_oid`) bzw. bei bereits gespeicherter, aber nicht geprüfter ID. | ✅ Privilegierte Konten gelten nur bei aktiver Bindung und passender ID als verknüpft, sonst gilt immer die Regel. |
|
||
| D-3 | Hoch (Multisite) | „Privilegiert“ wurde nur auf der aktuellen Site geprüft: Admin von Site B über Site A übernehmbar. | ✅ Rechte auf allen Sites des Benutzers zählen. |
|
||
| D-4 | Mittel | Liste privilegierter Rechte zu eng (Redakteure mit `unfiltered_html`, Plugin-/Theme-/Benutzerrechte fehlten). | ✅ Erweitert; Filter `m365_login_is_privileged_user`. |
|
||
| D-5 | Mittel | Deaktivierte Admins (ohne Rolle) galten als nicht privilegiert → verknüpfbar, bei Reaktivierung wieder Admin. | ✅ Gemerkte Rollen deaktivierter Konten zählen mit. |
|
||
| D-6 | Mittel | Cookie-Sperre (Z-3/Z-6) wirkungslos unter WordPress 6.0/6.1 (Filter-Argumente erst ab 6.2). | ✅ Ohne Benutzer-ID-Argument wird in API-Kontexten immer gesperrt. |
|
||
| D-7 | Mittel | Sicherheitsstopp durch im selben Lauf angelegte Konten verwässert; Testlauf und echter Lauf entschieden unterschiedlich. | ✅ Quote aus den vor dem Lauf verknüpften Konten. |
|
||
| D-8 | Mittel | Rollen-Entzug ohne Sicherheitsstopp: eine geleerte Gruppe stufte alle zugeordneten Admins herab. | ✅ Eigener Stopp für den Entzug administrativer Rollen (max. 20 %, nie alle; Filter `m365_login_sync_demotion_limit`). |
|
||
| D-9 | Niedrig | Lauf-Sperre per `add_option` nicht atomar, lief bei langen Läufen ab. | ✅ `INSERT IGNORE`, Übernahme per bedingtem `UPDATE`, Auffrischung alle 250 Benutzer. |
|
||
| D-10 | Niedrig | Tenant-Schutz verzögerte nur um einen Lauf und griff beim ersten Sync nicht. | ✅ Tenant wird pro Konto gespeichert; deprovisioniert wird nur im eigenen Tenant; Alt-Verknüpfungen ohne Tenant, die nicht gefunden werden, bleiben unangetastet (Warnung). |
|
||
| D-11 | Niedrig | Doppeltes Deaktivieren überschrieb gemerkte Rollen; Zeilenaktions-Nonce nicht an den Zustand gebunden. | ✅ Deaktivieren idempotent; Nonce pro Zustand. |
|
||
| D-12 | Niedrig | Nur-Button-Modus mit kaputter Verbindung zeigte Passwortfelder, die nichts bewirkten. | ✅ Felder ausgeblendet, Hinweis „vorübergehend nicht verfügbar“. |
|
||
| D-13 | Niedrig | Altdaten aus 1.0: deaktivierte Konten nicht gehärtet, gespeichertes Key+Cert-Bündel. | ✅ Einmalige Migration; `.cer`-Download immer neu exportiert. |
|
||
| D-14 | Niedrig | Speicher bei sehr großen Tenants; ein Fatal Error hinterließ Sperre und keinen Bericht. | ✅ Laufzeit-Cache wird regelmäßig geleert, nur IDs gehalten; Shutdown-Handler meldet den Abbruch und gibt die Sperre frei. 📄 Für >20 000 Benutzer WP-CLI empfohlen. |
|
||
| D-15 | Niedrig | Profilbilder nur am Header geprüft (Polyglot, Dekompressionsbombe, EXIF). | ✅ Neu kodiert (240 px JPEG/PNG), max. 4096 px, Download-Limit 2 MB, `index.php` im Ordner, Löschung bei Deaktivierung. |
|
||
| D-16 | Niedrig | Keine Export-/Lösch-Werkzeuge (DSGVO). | ✅ Exporter und Eraser registriert. |
|
||
| D-17 | Niedrig | Rechte-Proxy mit mehreren Stufen (CDN → Load Balancer): rechter XFF-Eintrag ist der innere Proxy. | 📄 Einwertige Header wie `HTTP_CF_CONNECTING_IP` verwenden (Doku). |
|
||
| D-18 | Hinweis | Nicht-privilegiertes Konto, später befördert, bleibt mit seiner Verknüpfung. | 📄 Vertrauensmodell: Verknüpfung prüft beim Verknüpfen. |
|
||
| D-19 | Hinweis | Graph-App-Token 50 Min. im Klartext-Transient. | 📄 Wie Core-Transients; Datenbank schützen. |
|
||
| D-20 | Hinweis | Avatar-Auflösung per E-Mail-Adresse. | 📄 Bewusst (Kompatibilität mit `get_avatar( $email )`). |
|
||
| D-21 | Hinweis | Benutzernamen aus dem lokalen Teil der E-Mail, Anzeigenamen aus Graph. | 📄 Nur kosmetisch. |
|
||
| D-22 | Hinweis | Admins mit UPN ≠ E-Mail waren gar nicht mehr verknüpfbar. | ✅ Neu: „Mit Microsoft-Konto verknüpfen“ im Profil und zugewiesener UPN pro Benutzer; Anmeldung findet gebundene Konten über die Objekt-ID. |
|
||
| D-23…25 | Hinweis | Kleinere Robustheitspunkte (Notices bei Array-Eingaben, Autoload der Einstellungen bei Netzwerk-Aktivierung, `fields => array('ID')` durch Abfrage-Cache als String geliefert). | ✅ Behoben. |
|
||
|
||
**Neue Funktion aus Sicherheitssicht (D-22):** Die Profil-Verknüpfung verlangt eine gültige WordPress-Session, einen
|
||
Nonce, denselben Browser (State-Cookie) und dieselbe angemeldete Person beim Callback; eine bestehende Verknüpfung kann
|
||
nur ein Administrator aufheben, eine bereits anderweitig gebundene Objekt-ID wird abgelehnt. Restrisiko: Wer eine gültige
|
||
WordPress-Session eines noch nicht verknüpften Kontos stiehlt, kann dieses mit seinem Microsoft-Konto verknüpfen – wie
|
||
bei jeder Selbstverwaltung eines zweiten Faktors.
|
||
|
||
## 8. Nicht im Umfang
|
||
|
||
Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes,
|
||
Schwachstellen in PHP/OpenSSL.
|