Eigentlich wollte ich nur ein paar Sicherheitslücken schließen. Am Ende stand ein halber Relaunch: eine zweisprachige Seite, ein neuer Blog mit Titelbildern, ein eigener Editor, lokale Schriften, Versionierung – und drei Fehler, mit denen ich mich zwischendurch selbst aus dem Admin-Panel ausgesperrt habe. Genau die sind eigentlich das Interessanteste an diesem Post.
Ausgangslage
frings.net läuft als schlanker PHP-Stack in einem Docker-Container hinter dem Nginx Proxy Manager. Kein CMS, keine Datenbank – Beiträge liegen in einer JSON-Datei, das Admin-Panel ist per IP-Whitelist und MFA geschützt. Das ist bewusst klein gehalten, heißt aber auch: Jede Sicherheitsfunktion habe ich selbst gebaut. Und selbst gebaute Sicherheit verdient regelmäßig einen zweiten Blick.
Teil 1: Security-Audit
Der Audit hat einiges zutage gefördert – nichts Dramatisches, aber genug, um es ernst zu nehmen:
- Login & MFA: strengere Prüfungen beim Einrichten und Ändern des zweiten Faktors, Replay-Schutz für TOTP-Codes, kürzere Gültigkeit von Zwischenschritten.
- Reverse Proxy: Der Container ist nur noch über das Docker-Netz des Proxys erreichbar, nicht mehr über einen veröffentlichten Port. Der
X-Real-IP-Header wird nur noch vom Proxy akzeptiert. - Abhängigkeiten: Eine ungenutzte Composer-Bibliothek mit bekannten CVEs flog komplett raus, PHPMailer ist aktuell.
- Webroot: read-only gemountet,
inc/,vendor/und Metadateien per Apache gesperrt, eigene 403/404-Seiten statt nackter Fehlertexte.
Teil 2: Deutsch und Englisch
Die Seite ist jetzt komplett zweisprachig: Sprachumschalter mit Flagge, eigene URLs (/blog/… und /en/blog/…), hreflang-Tags für Suchmaschinen. Beim ersten Besuch entscheidet die Browsersprache, danach ein Cookie.
Beiträge schreibe ich weiterhin auf Deutsch. Beim Speichern entsteht die englische Fassung automatisch – und zwar so, dass Code nie angefasst wird: Code-Blöcke verlassen den Server gar nicht erst, Inline-Code, URLs und Bot-Befehle werden vor der Übersetzung geschützt.
DeepL oder Azure?
Geplant war DeepL. Der aktuelle Developer-Plan hat aber ein einmaliges Guthaben von einer Million Zeichen – ohne monatlichen Reset. Für einen Blog, der jahrelang laufen soll, war mir das zu wackelig.
Gewechselt bin ich auf Azure Translator im Free-Tarif (F0):
| DeepL Developer | Azure Translator F0 | |
|---|---|---|
| Kontingent | 1 Mio. Zeichen einmalig | 2 Mio. Zeichen pro Monat |
| Bei Überschreitung | Guthaben weg | Anfragen werden abgelehnt, keine Kosten |
| Qualität DE→EN | sehr gut | gut |
Zusätzlich gibt es ein Übersetzungsgedächtnis: Jeder Absatz wird nur ein einziges Mal übersetzt. Ändere ich einen Satz, geht auch nur dieser Satz erneut an die API. DeepL bleibt per .env umschaltbar.
Teil 3: Neuer Blog, neuer Editor
Optisch bleibt alles im Terminal-Look, aber die Beiträge haben jetzt Titelbilder – entweder ein eigenes Bild (wird automatisch zugeschnitten, EXIF-Daten werden entfernt) oder ein generiertes Cover mit Schlagwort, Kategorie und Prompt-Zeile. Dazu kommen Inhaltsverzeichnis, Code-Blöcke mit Kopier-Button und Syntax-Highlighting, Hinweis-Boxen wie diese hier und eigene Seiten pro Thema.
Für geteilte Links erzeugt der Server aus dem Auto-Cover ein Vorschaubild (1200×630 PNG, per GD gerendert und gecacht). Wer einen Beitrag auf LinkedIn oder Discord teilt, sieht jetzt also ein Bild statt eines leeren Kastens.
Der neue Editor im Admin-Panel bringt:
- DE/EN-Tabs, Live-Vorschau und Markdown-Werkzeugleiste
- Autosave alle fünf Sekunden im Browser – ein abgestürzter Tab kostet keinen Text mehr
- Versionen: die letzten 20 Stände jedes Beitrags, mit einem Klick zurückholbar
- Papierkorb: gelöschte Beiträge lassen sich wiederherstellen
Außerdem neu: RSS-Feed (/feed.xml, /en/feed.xml) und eine Sitemap mit Sprachvarianten.
Teil 4: Drei Fehler, aus denen ich gelernt habe
1. Der Proxy, dem niemand vertraut hat
Nach dem Umbau war der ADMIN-Link plötzlich weg – obwohl ich im freigegebenen Netz saß. Die Ursache stand in einer einzigen Zeile: In der Liste der vertrauenswürdigen Proxys stand die IP des Website-Containers selbst, nicht die des Proxys. Die Seite hat den X-Real-IP-Header deshalb ignoriert und jeden Besucher als Proxy-Adresse gesehen.
Die eigentliche Lehre: Container-IPs sind keine stabile Konfiguration. Inzwischen trägt die .env den Containernamen des Proxys ein, den Docker im gemeinsamen Netz zur aktuellen IP auflöst:
TRUSTED_PROXY_IPS=nginx_proxy_manager-nginx-proxy-1Damit gilt genau dieser eine Container als Proxy – auch nach einem Neustart mit neuer IP.
2. PHP 8.5 zeigt Fehler im Browser
Nach dem Update standen auf der Startseite plötzlich Deprecated-Meldungen – samt Serverpfad. Der Auslöser war harmlos (imagedestroy() ist seit PHP 8.5 veraltet), die eigentliche Ursache nicht: Das offizielle php:apache-Image bringt keine php.ini mit. Ohne Konfiguration steht display_errors auf On.
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /dev/stderrSeitdem landen Warnungen nur noch in docker logs, wo sie hingehören.
php -r 'var_dump(ini_get("display_errors"));' im Container. Kommt "1" zurück, sieht jeder Besucher eure Fehlermeldungen.3. Die Login-Mail, die den Login blockiert hat
Neu ist eine Mail bei jedem erfolgreichen Admin-Login. Nach dem Deployment hing ich dann auf der MFA-Seite fest – und wer den Code ungeduldig noch einmal abschickte, landete wieder beim Login.
Der Ablauf war: Code korrekt → Session-ID wird aus Sicherheitsgründen erneuert → dann Mail verschicken → erst danach die Weiterleitung senden. Hat der Mailserver getrödelt, wartete der Browser. Ein zweiter Klick kam mit der alten, inzwischen ungültigen Session an.
Die Lösung: erst antworten, dann mailen.
session_write_close();
header('Location: admin.php', true, 302);
header('Content-Length: 0');
header('Connection: close');
flush();
notify_admin_login($method); // laeuft jetzt nach der Antwort
exit;Dazu verhindert die MFA-Seite jetzt doppeltes Absenden. Kleine Ursache, großer Effekt – und ein gutes Beispiel dafür, dass Nebenwirkungen nie im Request-Pfad eines Logins hängen sollten.
Kleinkram mit Wirkung
- Schriften lokal: Orbitron und Share Tech Mono kommen jetzt vom eigenen Server statt von Google. Keine Besucher-IP mehr an Dritte, eine Domain weniger in der Content-Security-Policy.
- Whitelist nur noch per DynDNS: Statische IPs sind optional. Einträge, ohne die meine aktuelle IP gesperrt wäre, lassen sich gar nicht erst löschen – Selbstaussperrung per Klick ist damit ausgeschlossen.
- Einheitliche Oberfläche: Login, MFA, Passwort-Reset und Editor haben jetzt denselben Terminal-Look wie der Rest des Admin-Panels.
Fazit
Ohne Framework hat man jede Zeile selbst in der Hand – im Guten wie im Schlechten. Die meisten Probleme dieses Umbaus waren keine Programmierfehler im engeren Sinn, sondern Annahmen: dass eine Container-IP stabil bleibt, dass ein Docker-Image sichere Defaults mitbringt, dass ein Mailserver schnell antwortet.
Wer selbst eine kleine PHP-Seite in Docker betreibt: Prüft display_errors, prüft, wem euer Proxy-Header vertraut, und hängt nichts Langsames in euren Login. Das sind drei Minuten Arbeit, die einem eine Menge Ärger ersparen.
Diesen Beitrag gibt es übrigens auch auf Englisch – automatisch übersetzt, versteht sich.