Certificate (RFC 7523 client assertion) as an alternative to the client secret: one-click generation of a 3072-bit RSA key pair with a self-signed certificate, .cer download (public part only), own PEM upload with validation, expiry display, encrypted key storage. Both the authorization code exchange and the Graph client-credentials request use the selected method. Step-by-step guides for secret, certificate and the app registration are shown in the settings. Security audit (docs/security-audit.md) and fixes: - Multi-tenant mode ignored the unverified email claim: matching now uses the UPN only, or the email claim when xms_edov is true. - Login starts are rate limited per client (30 per 10 minutes). - Optional trusted proxy header for client IPs (M365_LOGIN_CLIENT_IP_HEADER / filter). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
11 KiB
Security-Audit: M365 Login 1.0.0
Stand: 22.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.
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.
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 | Kein Provisioning; 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
- Tenant-GUID eintragen (kein
organizations/common), Domain-Allowlist setzen. - Zertifikat statt Secret verwenden; Ablauf im Kalender notieren (Backend zeigt Restlaufzeit).
- In Entra ID „Zuweisung erforderlich“ für die Enterprise-Anwendung aktivieren und Benutzer/Gruppen zuweisen — zweite Schranke neben der WordPress-Benutzerliste.
- Gruppen-Beschränkung mit
groups-Claim und Graph-Berechtigung einrichten (Overage-Fall). - Conditional Access / MFA im Tenant erzwingen; das Plugin erbt die Stärke der Microsoft-Anmeldung.
- HTTPS mit HSTS;
COOKIE_DOMAINkorrekt; hinter ProxyM365_LOGIN_CLIENT_IP_HEADERsetzen. - Nur-Button-Modus erst nach erfolgreichem eigenen Microsoft-Login aktivieren; Fallback-Link im Passwortmanager ablegen.
- WordPress-Salts nicht ohne Neueingabe von Secret/Zertifikat rotieren.
- 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.