root@frings-net:~# < zurück zur Übersicht
DEEN
DOCKER

Relaunch unter der Haube: frings.net wird zweisprachig – und deutlich sicherer

Security-Audit, DE/EN mit automatischer Übersetzung, neuer Blog mit Titelbildern und ein Editor, der nichts mehr verliert. Was ich umgebaut habe – und welche drei Fehler mich dabei selbst ausgesperrt haben.

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.
Die beste Lücke ist Code, der gar nicht erst da ist. Eine Bibliothek, die niemand mehr aufruft, kann trotzdem angegriffen werden – solange sie im Webroot liegt.

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 DeveloperAzure Translator F0
Kontingent1 Mio. Zeichen einmalig2 Mio. Zeichen pro Monat
Bei ÜberschreitungGuthaben wegAnfragen werden abgelehnt, keine Kosten
Qualität DE→ENsehr gutgut

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.

Englische Fassungen, die ich von Hand nachbessere, werden danach nie wieder automatisch überschrieben.

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:

BASH
TRUSTED_PROXY_IPS=nginx_proxy_manager-nginx-proxy-1

Damit 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.

INI
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /dev/stderr

Seitdem landen Warnungen nur noch in docker logs, wo sie hingehören.

Wer ein offizielles PHP-Docker-Image nutzt, sollte genau das prüfen: 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.

PHP
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.