wp-m365-login/docs/security-audit.md
Friederich Loheide 4edf20bc45
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
Add Microsoft 365 user sync with roles, profile fields and deprovisioning
New "User sync" tab that imports Microsoft 365 / Entra ID users as
WordPress accounts and keeps them up to date:

- Scope: whole tenant or the (nested) members of selected groups,
  guests optional, e-mail domain allow-list respected. Existing accounts
  are linked by e-mail address.
- Roles: selectable default role plus a group -> role mapping (in
  addition to or instead of the default role, first match wins).
  Roles of pre-existing accounts are only managed on request.
- Profile: selectable Graph attributes (names, job title, department,
  phones, address, language, ...) and the profile photo as avatar.
- Deprovisioning: accounts disabled or deleted in Microsoft 365 (or
  removed from the sync groups) are deactivated or deleted; accounts
  deactivated by the sync are reactivated automatically. Deactivated
  accounts lose every sign-in path and all sessions.
- Safeguards: dry run, safety stop above 20 % (min. 5) deprovisioning,
  abort on any Graph error, "deleted" only on a 404 for the object ID,
  protected pre-existing administrators and own account, content
  reassignment required for deletion, run lock.
- Runs manually, via WP-Cron or `wp m365-login sync [--dry-run]`.
- Users screen column with deactivate/reactivate row actions and a
  read-only Microsoft 365 section on the profile screen.

The Graph client gains paging, retry on throttling and user, group
member and photo endpoints. The group picker is now reusable.
Version 1.1.0, German translations (du/Sie), docs and audit addendum.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:34:30 +00:00

177 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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,
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.
> 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).
## 1. Zusammenfassung
| Schweregrad | Gefunden | Behoben | Offen (mit Empfehlung) |
| --- | --- | --- | --- |
| Hoch | 1 | 1 | 0 |
| Mittel | 3 | 3 | 0 |
| Niedrig | 5 | 3 | 2 |
| Hinweis | 6 | | 6 |
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.
## 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, 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`.
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. Nicht im Umfang
Sicherheit der Microsoft-Seite (Entra ID, Graph), WordPress-Core, Hosting-Umgebung, andere Plugins/Themes,
Schwachstellen in PHP/OpenSSL.