Commit Graph

4 Commits (7087ef6a2f484acc996e46ad191c97f7b2324602)

Author SHA1 Message Date
nexxo c78866e360 fix(security): Aufheben einer Sperre sagt die Wahrheit und wird nachgeholt
tests / pest (push) Has been cancelled Details
tests / assets (push) Has been cancelled Details
tests / release (push) Has been cancelled Details
Mein eigener Befund "die Zeile erneuert sich nach dem Aufheben nicht" war
falsch — eine Pruefung ueber den ganzen Weg (Modal schickt das Ereignis,
Seite faengt es) zeigt, dass die Zeile sehr wohl in den Verlauf wandert.
Dabei fiel der echte Fehler auf, der daneben lag:

HostFirewall::release() schluckt jeden Fehlschlag und meldet ihn nur ins
Log. Beide Aufrufer verwarfen den Rueckgabewert und meldeten in jedem Fall
"Sperre aufgehoben." War der Host im Moment des Aufhebens nicht erreichbar,
stand der Datensatz auf aufgehoben und die Regel noch drin: Portal und
Konsole zeigten "Aufgehoben", waehrend die Adresse weiter ausgesperrt blieb
— bis zum Ablauf der urspruenglichen Sperrzeit, ohne dass es jemand sagen
konnte.

- release() gibt zurueck, ob die Firewall schon nachgezogen hat; alle drei
  Stellen (Portal, Host-Ansicht, Kunden-Ansicht) sagen es, wenn nicht.
- releaseMany() als Gegenstueck zu blockMany(): eine SSH-Sitzung statt einer
  je Adresse.
- ScanForIntrusions gleicht jetzt in BEIDE Richtungen ab. Bisher trug er nur
  ein; nichts nahm je einen haengengebliebenen Eintrag wieder heraus. Eine
  Adresse, die eine ANDERE aktive Sperre desselben Hosts noch traegt, bleibt
  stehen.
- Jeder Loeschbefehl traegt `2>/dev/null || true`: nft scheitert am
  Loeschen eines Elements, das es nicht gibt, und weg ist genau das Ziel.
  Ohne das meldete der Abgleich bei jedem Lauf einen Fehlschlag.

8 neue Pruefungen, Suite 2625 gruen.
2026-08-03 17:28:28 +02:00
nexxo 552be46881 Was aus dem Gast kommt, ist keine Adresse, bis es geprueft ist
Fix-Welle nach dem Gesamt-Review, Punkte 1, 2, 6, 7, 8, 9 und die Haelfte von 3,
die den stillen Misserfolg sichtbar macht.

BEFEHLSEINSCHLEUSUNG ALS ROOT (Punkt 1, Blocker). `remoteAddr` ist eine
beliebige Zeichenkette aus einer Datei IM GAST. `BlockAddress::isExempt()`
prueft nur Zugehoerigkeit — bei Unsinn liefert IpUtils schlicht false, und es
ging weiter. `HostFirewall::block()` schob die Zeichenkette per sprintf in ein
nft-Kommando, und PhpseclibRemoteShell::run() reicht das an /bin/sh ALS ROOT auf
dem Proxmox-Host. Ein Semikolon darin war ein zweiter Befehl.

Dicht an BEIDEN Enden, nicht an einem:

- Im Leser jede Adresse durch filter_var(FILTER_VALIDATE_IP), alles andere
  verworfen — im Nextcloud-Zweig, der gar kein Gegenstueck hatte, UND im
  SSH-Zweig. Dessen preg_match schliesst ein Semikolon zwar aus, laesst aber
  "1.2.3" und "...." durch, und der Riegel darf nicht davon abhaengen, dass
  jemand beim naechsten Aendern des Musters daran denkt.
- In HostFirewall derselbe Riegel noch einmal, vor jedem sprintf: false zurueck,
  nichts ausgefuehrt, nicht einmal verbunden. Ein Dienst, der eine Wurzel-Shell
  fuettert, darf sich nicht darauf verlassen, dass sein Aufrufer sauber war.

NUR OEFFENTLICHE ADRESSEN IM INSTANZ-ZWEIG (Punkt 2, Blocker). TRUSTED_PROXIES
steht in der .env des Gastes nie, waehrend Traefik auf dem Host sitzt: was
Nextcloud protokolliert, ist entweder die Bruecken-adresse des Hosts (dann
zaehlen ALLE Kunden unter einer Adresse und ein echter Angreifer nie) oder ein
X-Forwarded-For, das der Client SELBST waehlt (dann macht sich ein Angreifer mit
10.66.0.1 dauerhaft ausnahmefaehig und sperrt mit einer fremden Adresse gezielt
Unbeteiligte aus). Beide Schadensfaelle sind privat oder reserviert, ein echter
Angreifer ist es nicht — also FILTER_FLAG_NO_PRIV_RANGE|NO_RES_RANGE, aber nur
hier. Im Host-Zweig stammt die Adresse aus dem SSH-Journal und ist echt.

Absichtlich ZWEI getrennte Pruefungen mit zwei getrennten Kommentaren an
derselben Stelle, nicht ein zusammengefasster Aufruf: das eine ist ein Riegel
gegen Einschleusung, das andere eine Vorsichtsmassnahme. Verschmolzen haette die
naechste Person beim Entfernen der Vorsicht den Riegel mitgenommen. Der Kommentar
sagt auch, was die Vorsicht abloest — die Vertrauenskette zum Gast sauber setzen
und BEWEISEN.

EINE VERBINDUNG JE HOST STATT EINE JE SPERRE (Punkt 7). reapplyActiveBlocks()
rief block() je aktiver Sperre, und jede baute ihre eigene SSH-Sitzung auf: bei
dreissig Sperren dreissig Handshakes pro Minute, seriell, auf dem einzigen
Provisionierungs-Arbeiter — also genau WAEHREND eines Angriffs auf demselben
Arbeiter, der die bezahlte Kundenbereitstellung faehrt. Neu ist blockMany(), das
mehrere Elemente in einen nft-Aufruf legt; block() ruft es mit einem einzigen
Eintrag und behaelt seine Signatur. Hoechstens zwei Befehle, weil v4 und v6 in
getrennten Mengen leben, aber nur eine Verbindung.

Die Zusicherung "Restlaufzeit statt Ursprungsdauer" ist dabei ausdruecklich
verteidigt: blockMany() nimmt die Sekunden JE ADRESSE entgegen. Eine gemeinsame
Dauer fuers Buendel waere bequemer gewesen und haette genau das verwaessert,
wofuer es einen eigenen Test gibt. Der bestehende Restlaufzeit-Test laeuft
unveraendert, und der neue Buendel-Test prueft drei verschiedene Restlaufzeiten
in einem Aufruf.

Dabei aufgefallen: dieselbe Adresse kann an zwei Subjekten desselben Hosts
gesperrt sein (Host und Instanz, oder zwei Instanzen), und in der nftables-Menge
ist das EIN Element. Beim Buendeln gewinnt die laengere Restlaufzeit — die
kuerzere zuletzt einzutragen wuerde die laengere Sperre verkuerzen.

EINE SPERRE, DIE NUR IN DER DATENBANK STEHT, DARF NICHT SCHWEIGEN (Punkt 3,
erste Haelfte). Auf einem Host, der noch das Regelwerk ohne die Mengen traegt,
scheitert nft add element bei JEDEM Versuch, und beide Aufrufer verwarfen den
Rueckgabewert: die Sperre stand in Datenbank, Portal, Konsole und in der Mail an
den Kunden als aktiv und in der Firewall nie. Der report() sitzt deshalb in
HostFirewall::apply() selbst, nicht an den Aufrufern — an der Stelle, an der der
Misserfolg anfaellt, kann ihn kein dritter Aufrufer wieder vergessen. Wie man
so einen Host nachzieht, kommt im naechsten Commit.

DER RING UM scanHosts() (Punkt 6). Die Nachbarmethode hat ihn seit Fix-Runde 2,
diese ging leer aus. FailedLoginReader::fromHost() faengt seine eigene
SSH-Ausnahme zwar ab, aber alles danach nicht: SecurityBlock::create(),
Settings::set(), Spaties PermissionDoesNotExist, $host->save(). Wirft eines
davon, bricht der ganze Lauf ab — und vor allem laeuft reapplyActiveBlocks()
nicht, jede Minute erneut.

DIE AUSNAHME FUER DEN EIGENEN SERVER GRIFF NUR BEI EINER IP (Punkt 8).
Str::beforeLast(':') lieferte bei "vpn.clupilot.com:51820" einen Hostnamen, und
IpUtils::checkIp() sagt dazu immer false — in der Form, die .env.example
dokumentiert, war die eigene Adresse still NICHT ausgenommen, der Server konnte
sich also selbst aussperren. Bei IPv6 blieben zusaetzlich die Klammern stehen.
hostPartOf() kennt jetzt die drei Formen, die wirklich vorkommen, ein Name wird
aufgeloest, und ALLE Ergebnisse werden ausgenommen (nur die erste zu nehmen
hiesse, sich bei einem Wechsel im Round-Robin doch wieder auszusperren).
Schlaegt die Aufloesung fehl, faellt der Eintrag weg wie bei leerem Endpunkt.

App\Support\DnsLookup als eigene Klasse, damit ein Test sie im Container
ersetzen kann: ein Test, der fuer seine Zusicherung echtes DNS braeuchte,
prueft das Netz des Rechners, auf dem er gerade laeuft, nicht die Regel. Sie
holt A UND AAAA — gethostbynamel() allein kennt nur A, ein reiner
AAAA-Endpunkt waere sonst still nicht ausgenommen gewesen, derselbe Fehler eine
Adressfamilie weiter. Die Liste wird je BlockAddress-Instanz einmal aufgeloest
und gemerkt; ein Lauf holt sich den Dienst einmal und fragt damit jede
gesichtete Adresse.

DER ERSTE LAUF LIEST NICHT MEHR JEDES PROTOKOLL VON VORN (Punkt 9). Ist der
gespeicherte Versatz 0, wird stat -c %s als Startwert genommen und gar nichts
gelesen. Gesperrt wuerde dabei ohnehin nichts, weil das Zehn-Minuten-Fenster
jede aeltere Zeile wegwirft — es waere eine grosse Uebertragung durch den
Gastagenten, alle Instanzen im selben Auftrag, fuer ein garantiert leeres
Ergebnis. Bewusst getrennt vom Rotationsfall: dort ist die Datei KLEINER als der
Versatz und muss von vorn gelesen werden, weil das Neue am Anfang steht. Der
Code unterscheidet dafuer jetzt $stored (was in der Spalte steht) von $offset
(ab wo gelesen wird).

Der Fixture-Helfer aktiveInstanz() setzt deshalb security_log_offset auf 1: 0
heisst ab jetzt woertlich "noch nie gelesen", und die Tests dieser Datei
beschreiben den Normalfall einer Instanz, die schon einmal gelesen wurde.

Nebenbei: SecurityConsoleTests erster Fall hing 135 Sekunden am
TCP-Verbindungsaufbau, weil das Aufheben einer Sperre HostFirewall::release()
ruft und kein Fake gebunden war — mehr als der gesamte uebrige Ordner zusammen.
Ein beforeEach behebt das; am Verhalten aendert sich nichts.

Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:00 +02:00
nexxo 8f630c5093 Fix-Runde 1: strukturelle Pruefung statt Zaehler, und der nicht erreichbare Host bekommt einen Test
substr_count('flags timeout') lief ueber den ganzen Text inklusive
Kommentare und belegte nur "die Phrase kommt zweimal vor", nicht "beide
set-Bloecke tragen die Ablaufzeit". Ersetzt durch je einen strukturellen
Ausdruck pro Menge; der Originalkommentar aus dem Auftragszettel kann
damit wieder wortgenau stehen. Dazu zwei neue Tests mit failConnect, die
belegen, dass block()/release() bei einem nicht erreichbaren Host false
liefern statt zu werfen - der Pfad, auf dem das Wiedereintragen in
Aufgabe 4 aufbaut.
2026-08-03 13:03:35 +02:00
nexxo 77a4c3d990 Sperrliste in der Host-Firewall, unter der Regel fuer bestehende Verbindungen
Zwei nftables-Mengen (clupilot_blocked/clupilot_blocked6, beide mit
flags timeout) im erzeugten Regelwerk, die Drop-Regel dafuer sitzt
absichtlich unter ct state established,related accept — wer drin ist,
bleibt drin, gesperrt wird nur, was neu anklopft. HostFirewall::block()/
release() tragen eine Adresse mit Ablaufzeit ein bzw. nehmen sie heraus,
ueber die WireGuard-Adresse des Hosts, und geben false statt zu werfen,
wenn der Host nicht erreichbar ist, damit eine spaetere Wiedereintrage-
Aufgabe die Sperre einfach nochmal versuchen kann.
2026-08-03 12:55:34 +02:00