8. Nov. 2026 · @schiwagoa
Worum es geht
Fail2ban sperrt IPs, die sich zu oft mit falschem Passwort an deinem Matrix-Server anmelden wollen. Der Filter liest dafür das Access-Log von nginx, die Sperre erfolgt direkt in der Host-Firewall (nftables oder iptables).
Das Setup in dieser Anleitung:
Komponente Domain Aufgabe nginx + fail2ban (direkt auf dem Host) – Reverse Proxy, Logs, Firewall Element Web element.example.com Client im Browser, nur statische Dateien Matrix Authentication Service (MAS) auth.example.com Login-Formular, prüft Passwörter Synapse matrix.example.com Homeserver, Client-API
Der wichtigste Punkt vorweg: Element Web braucht keinen eigenen Filter. Element Web ist nur HTML und JavaScript und sieht nie ein Passwort. Seit Synapse die Anmeldung an MAS delegiert, leitet Element den Browser per OIDC auf das MAS-Login-Formular weiter. Ein Brute-Force-Angriff landet deshalb bei auth.example.com , nicht bei Synapse.
Zusätzlich überwachen wir zwei Stellen an Synapse: den alten Passwort-Login über die Client-API (ältere Clients, Bots) und die Admin-API.
In allen Beispielen steht 203.0.113.42 für einen Client aus dem Internet (eine reine Dokumentations-IP).
Schritt 1: Verstehen, wie MAS Fehlversuche loggt
MAS antwortet auf ein falsches Passwort nicht mit 403 , sondern zeigt das Formular mit Status 200 erneut an. Ein erfolgreicher Login endet dagegen mit einer Weiterleitung (303 ). Genau diesen Unterschied nutzt der Filter.
Ergebnis Request Status Falsches Passwort oder unbekannter User POST /login200 OK Erfolgreicher Login POST /login303 See Other
So sieht das im MAS-Log aus (gekürzt):
WARN mas_handlers::views::login POST-154867 - User not found username="test"
INFO http.server.response POST-154867 - "POST /login HTTP/1.1" 200 OK ... client.address=203.0.113.42
INFO http.server.response POST-155127 - "POST /login HTTP/1.1" 303 See Other ... client.address=203.0.113.42
Prüfe das bei deiner Version einmal selbst, bevor du dich auf den Filter verlässt. Läuft MAS in Docker, schreibt docker logs auf stderr, deshalb braucht grep ein 2>&1:
docker logs -f --tail 0 <mas-container> 2>&1 | grep 'POST /login'
Melde dich in Element einmal falsch und einmal richtig an. Du solltest genau die beiden Statuscodes aus der Tabelle sehen.
Schritt 2: nginx vorbereiten
Jeder vhost bekommt ein eigenes Access-Log . Das Standardformat combined enthält den Hostnamen nicht, mit getrennten Dateien weiß fail2ban trotzdem, welche Domain gemeint ist.
In /etc/nginx/sites-available/auth.example.com:
server {
listen 443 ssl;
http2 on;
server_name auth.example.com;
access_log /var/log/nginx/auth.example.com.access.log;
error_log /var/log/nginx/auth.example.com.error.log;
location / {
proxy_pass http://10.0.0.20:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
In /etc/nginx/sites-available/matrix.example.com genauso:
access_log /var/log/nginx/matrix.example.com.access.log;
Passe 10.0.0.20:8080 an die Adresse deines MAS an. Die X-Forwarded-For-Header sorgen dafür, dass auch MAS die echte Client-IP sieht. MAS vertraut diesen Headern standardmäßig nur von privaten Netzen aus; prüfe http.trusted_proxies in der MAS-Config, falls dein Proxy eine öffentliche IP hat.
Konfiguration testen und neu laden:
nginx -t && systemctl reload nginx
Eine Zeile im neuen Log sieht so aus:
203.0.113.42 - - [08/Nov/2026:18:37:45 +0100] "POST /login HTTP/2.0" 200 4821 "https://auth.example.com/login" "Mozilla/5.0 ..."
Wichtig: Am Anfang muss die echte Client-IP stehen. Sitzt vor nginx noch etwas anderes (z. B. Cloudflare oder ein Load Balancer), richte zuerst das nginx-Modul real_ip ein. Sonst sperrt fail2ban den vorgelagerten Proxy.
Schritt 3: fail2ban installieren
fail2ban läuft auf dem Host mit nginx, denn nur dort kommen die Pakete der Angreifer an. Auf dem Matrix-Host selbst zu sperren bringt nichts: Dort kommt jeder Request von der IP des Reverse Proxy.
Auf Debian oder Ubuntu:
apt update
apt install fail2ban nftables
systemctl enable --now fail2ban
Prüfen, ob der Dienst läuft:
fail2ban-client version
systemctl status fail2ban
Nutzt dein System noch klassisches iptables, lass nftables weg. Die Jail-Konfiguration weiter unten zeigt beide Varianten.
Schritt 4: Filter anlegen
Der Filter erkennt drei Muster im nginx-Log. Lege /etc/fail2ban/filter.d/matrix-login.conf an:
[Definition]
# 1) MAS-Login-Formular: POST /login ohne Weiterleitung = Fehlversuch
# (Erfolg = 303, Fehlversuch = 200)
# 2) Passwort-Login über die Matrix-Client-API (ältere Clients, Bots)
# 3) Zugriffe auf die Synapse-Admin-API ohne gültiges Token
failregex = ^<HOST> \S+ \S+ \[[^\]]+\] "POST /login(?:\?\S*)? HTTP/[\d.]+" (?!3)\d{3}
^<HOST> \S+ \S+ \[[^\]]+\] "POST /_matrix/client/(?:r0|v3|unstable)/login\S* HTTP/[\d.]+" (?:401|403|429)
^<HOST> \S+ \S+ \[[^\]]+\] "\S+ /_synapse/admin/\S* HTTP/[\d.]+" (?:401|403)
ignoreregex =
Die Teile kurz erklärt:
<HOST> ist der Platzhalter von fail2ban für die IP, die gesperrt wird.
\[[^\]]+\] überspringt den Zeitstempel. Das Datumsformat von nginx erkennt fail2ban automatisch.
(?!3)\d{3} bedeutet: jeder Statuscode, der nicht mit 3 beginnt. Ein erfolgreicher Login (303) zählt damit nie als Fehlversuch.
(?:\?\S*)? erlaubt optionale Parameter hinter /login.
Das Muster für /login matcht nur im Log von auth.example.com, weil dort sonst niemand POST /login aufruft. Die beiden anderen Muster greifen im Log von matrix.example.com.
Wenn du die Admin-API nicht von außen brauchst, sperr /_synapse/admin in nginx besser ganz (location /_synapse/admin { deny all; }), statt sie nur zu überwachen.
Schritt 5: Jail anlegen
Das Jail verbindet Filter, Logdateien und Firewall. Lege /etc/fail2ban/jail.d/matrix-login.conf an:
[matrix-login]
enabled = true
filter = matrix-login
logpath = /var/log/nginx/auth.example.com.access.log
/var/log/nginx/matrix.example.com.access.log
backend = auto
port = http,https
banaction = nftables-multiport
maxretry = 5
findtime = 10m
bantime = 1h
ignoreip = 127.0.0.1/8 ::1 192.168.0.0/24
Die Einstellungen:
Option Bedeutung logpathBeide Access-Logs, je eine Datei pro Zeile backend = autoLiest die Logdateien direkt. Manche Distributionen setzen systemd als Default, dann würden die nginx-Dateien nie gelesen. port = http,httpsGesperrt werden Port 80 und 443. Da nginx direkt auf dem Host läuft, greift die Regel in der normalen INPUT-Kette. banactionnftables-multiport für nftables, iptables-multiport für klassisches iptablesmaxretry / findtime5 Fehlversuche innerhalb von 10 Minuten führen zur Sperre bantimeDauer der Sperre ignoreipDiese Netze werden nie gesperrt. Trag hier dein Heimnetz ein, damit du dich nicht selbst aussperrst.
Willst du einen Angreifer auf allen Ports sperren, nimm nftables-allports bzw. iptables-allports und lass port weg.
Schritt 6: Testen und aktivieren
Filter gegen das echte Log testen
Mach vorher in Element einen falschen und einen richtigen Login, damit beide Fälle im Log stehen. Dann:
fail2ban-regex --print-all-matched \
/var/log/nginx/auth.example.com.access.log \
/etc/fail2ban/filter.d/matrix-login.conf
In der Ausgabe zählt:
Failregex: 1 total (oder mehr): Der Fehlversuch wurde erkannt.
Date template hits mit mindestens einem Treffer: Das Datum wird gelesen.
Unter den gematchten Zeilen darf keine mit 303 stehen.
Konfiguration prüfen und laden
fail2ban-client -t
fail2ban-client reload
fail2ban-client status matrix-login
Unter File list müssen beide Logdateien stehen.
Live-Test
Nimm für den Test ein Gerät außerhalb von ignoreip, z. B. ein Handy über mobile Daten. Melde dich dort fünfmal mit falschem Passwort an und beobachte parallel:
tail -f /var/log/nginx/auth.example.com.access.log | grep --line-buffered 'POST /login'
fail2ban-client status matrix-login
Currently failed zählt hoch, nach dem fünften Versuch steht die IP unter Banned IP list. Das Handy erreicht deinen Server jetzt nicht mehr.
Sperre in der Firewall prüfen
Mit nftables:
nft list table inet f2b-table
Mit iptables:
iptables -L f2b-matrix-login -n
In beiden Fällen muss die gesperrte IP dort auftauchen.
Wieder entsperren
fail2ban-client set matrix-login unbanip 203.0.113.42
Fehlersuche
Symptom Ursache und Lösung Kein 403 im Log, obwohl das Passwort falsch war Normal bei MAS: Fehlversuche kommen als POST /login mit 200 (Schritt 1). fail2ban-regex zeigt Failregex: 0Logformat weicht ab. Eine echte Zeile mit dem Filter vergleichen, oft ist ein anderes log_format als combined aktiv. Date template hits ist 0Zeitstempel wird nicht erkannt. Prüfen, ob nginx das Standardformat [08/Nov/2026:18:37:45 +0100] schreibt. File list im Status ist leerlogpath falsch oder backend = systemd. Auf backend = auto setzen.Gesperrt wird immer dieselbe interne IP nginx sieht nicht die echte Client-IP. Vorgelagerten Proxy über real_ip eintragen. grep auf docker logs filtert nichtsdocker logs schreibt auf stderr: 2>&1 vor die Pipe setzen.
Weitere Härtung
Rate-Limit in MAS: MAS bremst Logins schon selbst über rate_limiting.login.per_ip. Standardmäßig sind das etwa 3 Versuche pro Minute und IP. fail2ban ergänzt das, indem es hartnäckige IPs komplett aussperrt.
Admin-API sperren: /_synapse/admin nur aus dem internen Netz erlauben.
Wiederholungstäter: Das mitgelieferte Jail recidive sperrt IPs für längere Zeit, die mehrfach gebannt wurden. Aktivieren mit [recidive] und enabled = true in einer eigenen Datei unter /etc/fail2ban/jail.d/.
Logrotate: Mit backend = auto folgt fail2ban rotierten Logs automatisch, hier ist nichts zu tun.
Erweiterung: Element Web, Element Call und weitere Synapse-Endpunkte
Der Login-Filter aus dem ersten Teil schützt die wichtigste Stelle. Ein vollständiger Matrix-Stack hat aber noch mehr Komponenten im nginx-Log, und jede braucht eine eigene Behandlung:
Komponente Domain Was dort passiert Schutz Element Web element.example.com nur statische Dateien Scanner-Filter Element Call (Web-App) call.example.com ebenfalls statisch Scanner-Filter MatrixRTC-Auth (lk-jwt-service) rtc.example.com tauscht Matrix-Token gegen LiveKit-Token Filter auf 401/403 LiveKit (Audio/Video) – WebRTC über UDP/TCP, nicht über nginx über nginx nicht filterbar MAS auth.example.com Login, ggf. Registrierung Login-Filter aus Teil 1, erweitert
Wie in Teil 1 bekommt jeder vhost sein eigenes Access-Log, z. B.:
nginx
access_log /var/log/nginx/element.example.com.access.log;
Scanner-Filter für Element Web und Element Call
Element Web und Element Call liefern nur HTML, JavaScript und Bilder aus. Niemand ruft dort legitim eine .php-Datei, ein .env oder /wp-login.php auf. Jeder solche Request stammt von einem Bot, der nach Schwachstellen sucht, und kann ohne Bedenken gesperrt werden.
Lege /etc/fail2ban/filter.d/nginx-scanner.conf an:
ini
[Definition]
# Typische Scanner-Ziele, die auf einem Matrix-Stack nie existieren
failregex = ^<HOST> \S+ \S+ \[[^\]]+\] "\S+ /(?:\S*\.(?:php\d?|aspx?|jsp|cgi)|\.env\S*|\.git/\S*|wp-(?:login|admin|content|includes)\S*|phpmyadmin\S*|cgi-bin/\S*|vendor/phpunit\S*)(?:\?\S*)? HTTP/[\d.]+" (?:400|403|404)
ignoreregex =
Dazu das Jail /etc/fail2ban/jail.d/nginx-scanner.conf:
ini
[nginx-scanner]
enabled = true
filter = nginx-scanner
logpath = /var/log/nginx/element.example.com.access.log
/var/log/nginx/call.example.com.access.log
/var/log/nginx/matrix.example.com.access.log
backend = auto
port = http,https
banaction = nftables-multiport
maxretry = 3
findtime = 10m
bantime = 1d
ignoreip = 127.0.0.1/8 ::1 192.168.0.0/24
Weil hier praktisch keine Fehlalarme möglich sind, darf maxretry niedrig und bantime lang sein.
Element Call: Token-Endpunkt absichern
Element Call holt sich für jeden Anruf ein Zugangstoken für LiveKit. Das erledigt der lk-jwt-service: Er prüft das Matrix-Token des Clients und antwortet bei einem ungültigen Token mit 401 oder 403.
Je nach Version heißt der Endpunkt /sfu/get oder /get_token. Starte einen Anruf und schau im Log nach, welcher Pfad bei dir ankommt:
bash
tail -f /var/log/nginx/rtc.example.com.access.log
Filter /etc/fail2ban/filter.d/matrix-rtc.conf:
ini
[Definition]
failregex = ^<HOST> \S+ \S+ \[[^\]]+\] "POST /(?:sfu/get|get_token)\S* HTTP/[\d.]+" (?:401|403)
ignoreregex =
Jail /etc/fail2ban/jail.d/matrix-rtc.conf:
ini
[matrix-rtc]
enabled = true
filter = matrix-rtc
logpath = /var/log/nginx/rtc.example.com.access.log
backend = auto
port = http,https
banaction = nftables-multiport
maxretry = 10
findtime = 10m
bantime = 1h
ignoreip = 127.0.0.1/8 ::1 192.168.0.0/24
maxretry ist hier bewusst höher. Ein legitimer Client mit abgelaufenem Token kann auch mal einen 401 erzeugen, bevor er sich ein neues holt.
Die eigentlichen Audio- und Videoströme laufen direkt zu LiveKit über UDP und TCP, nicht über nginx. Diese Ports sicherst du über die Firewall und die LiveKit-Konfiguration ab, fail2ban sieht sie nicht.
Registrierung bei MAS mitüberwachen
Ist bei dir die Registrierung offen, lohnt sich derselbe Trick wie beim Login. MAS zeigt bei einem fehlgeschlagenen Versuch das Formular mit Status 200 erneut an und leitet nach Erfolg mit 303 weiter. Prüfe den genauen Pfad (/register oder /register/password) vorher im Log und erweitere dann die erste Zeile im Filter matrix-login.conf:
ini
failregex = ^<HOST> \S+ \S+ \[[^\]]+\] "POST /(?:login|register\S*)(?:\?\S*)? HTTP/[\d.]+" (?!3)\d{3}
Was du bewusst nicht filtern solltest
Einige Muster sehen verdächtig aus, sind aber normaler Matrix-Verkehr. Ein Bann darauf schadet mehr, als er nützt:
/_matrix/federation/: Fehler dort kommen von anderen Homeservern. Ein Bann kappt die Federation mit ganzen Servern und ihren Nutzern.
Status 429 auf /_matrix/client/: Synapse drosselt auch legitime Clients, z. B. beim ersten Sync nach längerer Pause. Ein Bann auf 429 trifft deine eigenen User.
/sync und Medien-Downloads: viele Requests in kurzer Zeit sind hier völlig normal.
Zusätzlich: Rate-Limiting direkt in nginx
fail2ban reagiert erst, wenn die Fehlversuche schon im Log stehen. nginx kann den Login-Pfad zusätzlich direkt drosseln, bevor die Anfrage überhaupt bei MAS ankommt.
Im http-Block (z. B. in /etc/nginx/conf.d/ratelimit.conf):
nginx
limit_req_zone $binary_remote_addr zone=mas_login:10m rate=10r/m;
Im server-Block von auth.example.com:
nginx
location = /login {
limit_req zone=mas_login burst=5 nodelay;
proxy_pass http://10.0.0.20:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Damit sind höchstens 10 Login-Anfragen pro Minute und IP möglich, mit einem kurzen Puffer von 5. Alles darüber beantwortet nginx sofort mit 503.
Und ModSecurity?
Eine Web Application Firewall wie ModSecurity mit dem OWASP Core Rule Set liegt nahe, passt aber schlecht zu Matrix. Synapse und MAS verarbeiten JSON mit verschlüsselten Inhalten, langen Tokens und Base64-Blöcken. Das Regelwerk hält vieles davon für Angriffe, und Medien-Uploads sowie lange /sync-Verbindungen sorgen für weitere Fehlalarme. Am Ende schließt man /_matrix/ und /_synapse/ fast vollständig aus, und dann schützt die WAF genau dort nicht mehr.
Wenn du ModSecurity trotzdem einsetzen willst, dann nur vor Element Web und Element Call. Dort gibt es keine komplexen Requests und kaum Fehlalarme. Starte mit SecRuleEngine DetectionOnly, beobachte das Log ein paar Tage lang und schalte erst dann auf Blockieren um.
Alles prüfen
bash
fail2ban-client -t
fail2ban-client reload
fail2ban-client status
In der Ausgabe sollten jetzt alle Jails auftauchen: matrix-login, nginx-scanner und matrix-rtc. Jeden Filter kannst du wie in Teil 1 mit fail2ban-regex gegen das passende Log testen, bevor du dich darauf verlässt.