Intranet mit WordPress absichern: die totale Abschottung einrichten
Veröffentlicht am 08.12.2019
Dies ist ein Beitrag aus der Serie Intranet mit WordPress, in der ich beschreibe, wie ein Intranet-System mit WordPress aufgebaut werden kann, das mehr als ein Blog ist. Hier findet ihr den Ankündigungs-Beitrag. Hier findet ihr die Übersicht aller Beiträge der Serie.
Ein Intranet ist schon dem Begriff nach für interne Zwecke konzipiert. Dementsprechend werden Intranet-Systeme in der Regel zugriffsbeschränkt aufgesetzt. Dies kann über verschiedene Techniken geschehen, die Vor- und Nachteile mit sich bringen:
- Umsetzung als lokales System, das nur im Unternehmens-Netzwerk erreichbar ist. In diesem Fall können Mitarbeiter/Benutzer eben nur vom Arbeitsplatz aus darauf zugreifen. Ein Funnel-System wäre möglich, um von außerhalb zuzugreifen, würde aber den lokalen Gedanken ad absurdum führen.
- Umsetzung mit Passwort-Schutz auf Webserver-Ebene mit htpasswd. Dies fordert eine Benutzerauthentifizierung bei Aufrufen der Seite. Wer keine Zugangsdaten hat, kriegt vom Webserver grundlegend keine Antwort. Eine einfache und sichere Möglichkeit. Bei vielen Benutzern müssten entweder Zugangsdaten synchronisiert werden, oder ein allgemeines (weniger unsicher) genutzt werden.
- Umsetzung mit Passwort-Schutz auf Website-Ebene. Dies ist mit WordPress recht simpel, da das Benutzer- und Rechte-Management schon vorhanden und gut nutzbar ist. Nachteil: in diesem Fall müssen z.B. Dateien im Intranet noch gegen Zugriff von außerhalb abgesichert werden, z.B. mit htaccess.
Da ich letzteren Fall umgesetzt habe, möchte ich hier das Vorgehen und die Maßnahmen aufzeigen, mit denen das WordPress-Intranet abgesichert wurde.
Schritt 1: Freie Registrierung deaktivieren
Die erste Maßnahme ist recht offensichtlich. In den WordPress-Einstellungen unter Allgemein deaktivieren wir die Option „Mitgliedschaft: Jeder kann sich registrieren“. Wir werden später alle Benutzer manuell anlegen, eine eigenständige Registrierung ist nicht gewollt. Somit kann schon mal nur ein Benutzerprofil haben, wen wir aktiv in das Intranet eingeladen haben.
Schritt 2: Sichtbarkeit für Suchmaschinen deaktivieren
Ebenfalls offensichtlich, aber gern vergessen: In den WordPress-Einstellungen unter Einstellungen > Lesen sollte die „Sichtbarkeit für Suchmaschinen“ deaktiviert werden. So stellen wir sicher, dass das gesamte System nicht öffentlich in Suchmaschinen auftaucht.
Schritt 3: Nicht angemeldete Benutzer zur Anmelde-Seite umleiten
Hier kommt schon ein Mini-Code-Schnipsel in’s Spiel. Da wir nicht wollen, dass nicht angemeldete Benutzer irgendetwas vom Intranet sehen können, leiten wir Benutzer immer zur Anmelde-Seite um, wenn sie nicht angemeldet sind.
if ( !is_user_logged_in() ) {
auth_redirect();
}
Genau diesen Zweck erfüllt auth_redirect, wie auch in der WordPress Code Reference beschrieben.
Dieses simple If-Statement sollte beim Aufbau einer Seite sehr früh platziert werden. Es bietet sich z.B. an, es an erste Stelle einer header.php (insofern vorhanden) zu platzieren, so dass es vor allem anderen steht und auf allen Seiten eingebunden ist.
Schritt 3.1: Nach der Anmeldung zur Startseite umleiten
Dies ist ebenfalls optional und/oder subjektiv. In meinem konkreten Fall war gewünscht, das Backend (WordPress-Administrationsbereich) so weit wie möglich für normale Benutzer zu vermeiden. Daher leiten wir hier immer auf die Frontend-Startseite um, wenn sich ein Benutzer angemeldet hat.
function cr_login_redirect_home( $url, $request, $user ) {
if( $user && is_object( $user ) && is_a( $user, 'WP_User' ) ) {
if( $user->has_cap( 'administrator' ) ) {
$url = admin_url();
} else {
$url = home_url();
}
}
return $url;
}
add_filter( 'login_redirect', 'cr_login_redirect_home', 10, 3 );
Was passiert hier? Der filter login_redirect
wird durchlaufen, wenn sich ein Benutzer anmeldet. Übergeben wird die Herkunfts-URL, die Anfrage und der Benutzer. Wir prüfen also, ob der Benutzer ein gültiger Benutzer des Systems ist, und leiten ihn auf die home_url()
um, wenn dem so ist. That’s basically it.
Kleiner Twist: In meinem konkreten Fall waren alle Benutzer als Redakteure angelegt. Administratoren haben wir beim Login-Redirect daher ausgenommen, da dies meist System-relevante Zugriffe sind. Immer in’s Frontend umgeleitet zu werden, wenn man eigentlich in’s Backend will, war auf Dauer etwas nervig. Daher dieser kleine if-else-Eingriff.
Schritt 3.2: Gültigkeitsdauer des Anmelde-Cookies anpassen
Zugegeben, das ist auch optional, aber meines Erachtens macht es Sinn die Gültigkeitsdauer des Anmelde-Cookies festzulegen, damit eine regelmäßige erneute Anmeldung notwendig ist. So wird sichergestellt, dass Sessions regelmäßig enden und nicht ggfs. Tage später ein Unbefugter über einen Browser zugreift, bei dem die Abmeldung vergessen wurde. In meinem Fall wurde sich für eine Gültigkeitsdauer von 15 Stunden entschieden, was ungefähr eine tägliche Anmeldung bedeutet: Wird sich morgens um 8 Uhr angemeldet, ist die Session bis 23 Uhr gültig. Wer sich später am Tag anmeldet, muss sich trotzdem meist am nächsten Morgen neu anmelden.
function cr_auth_cookie_expiration( $expiration, $user_id, $remember ) {
if( $remember ) {
return 60*60*15; // 15 hours
}
return $expiration;
}
add_filter( 'auth_cookie_expiration', 'cr_auth_cookie_expiration', 10, 3 );
Schritt 4: Login-Versuche einschränken, z.B. mit Limit Login Attempts
Hier greifen ich ehrlich gesagt gern auf ein Plugin zurück, namentlich Limit Login Attempts Reloaded. Hierüber lassen sich sehr komfortabel Zugriffsversuche beschränken, IPs blocken und auch IPs whitelisten (falls z.B. das Unternehmen einen eigenen IP-Block hat). Da das Plugin seinen Job sehr gut erledigt, verliere ich hier keine großen Worte mehr. Wer diese Funktionalität benötigt, kann guten Gewissens auf das Plugin zurückgreifen.
Schritt 5: Feeds, Embeds, XML-RPC deaktivieren
Um nach außen weiter „abzuschließen“ wurde sich entschieden die Feeds zu deaktivieren, Embeds zu deaktivieren sowie die XML-RPC-Schnittstelle abzuschalten.
Feeds lassen sich über folgendes Snippet deaktivieren:
function sp_disable_feeds() {
wp_die( 'Kein Feed verfügbar.' );
}
add_action( 'do_feed', 'sp_disable_feeds', 1 );
add_action( 'do_feed_rdf', 'sp_disable_feeds', 1 );
add_action( 'do_feed_rss', 'sp_disable_feeds', 1 );
add_action( 'do_feed_rss2', 'sp_disable_feeds', 1 );
add_action( 'do_feed_atom', 'sp_disable_feeds', 1 );
add_action( 'do_feed_rss2_comments', 'sp_disable_feeds', 1 );
add_action( 'do_feed_atom_comments', 'sp_disable_feeds', 1 );
remove_action( 'wp_head', 'feed_links_extra', 3 );
remove_action( 'wp_head', 'feed_links', 2 );
Embeds deaktiviere ich in der Regel mit dem Plugin Disable Embeds, da mir dies als einfachste und zukunftssicherste Variante erscheint. Im Grunde könnte man den Code auch in die functions.php packen, aber wenn sich dann in Zukunft etwas ändern würde, müsste man es in jedem je ausgelieferten Theme anpassen. Daher setze ich hier gern auf ein Plugin aus dem Repository.
Die XML-RPC Schnittstelle kann – wenn sie nicht verwendet wird – über einen einfachen Filter deaktiviert werden:
add_filter( 'xmlrpc_enabled', '__return_false' );
Die Gefahren dieser Schnittstelle sind zwar sehr gering – man erhält in der Regel „nur“ versuchte Bot-Zugriffe – aber ich handle hier gern nach der Devise „Weg, was nicht gebraucht wird“.
Schritt 6: Admin-Bar verstecken, WP-Logo verstecken, Generator verstecken
Folgende Filter und Snippets setze ich ein, um …
… für Benutzer die Admin-Bar im Frontend zu verstecken:
add_filter( 'show_admin_bar', '__return_false' );
… das WordPress-Logo in der Admin-Bar (falls jemand im Backend landet) zu verstecken:
function cr_remove_toolbar_menu_node() {
global $wp_admin_bar;
$wp_admin_bar->remove_menu( 'wp-logo' );
}
add_action( 'wp_before_admin_bar_render', 'cr_remove_toolbar_menu_node', 999 );
… WordPress als Generator der Seite zu verstecken (auch wenn niemand ohne Anmeldung dies einsehen kann):
remove_action( 'wp_head', 'wp_generator' );
Schritt 7: Inhalte durch einfache Permalinks verschleiern
In einer normalen Web-Umgebung wäre es absolut nicht empfehlenswert, in einer geschlossenen Umgebung absolut korrekt: Stellt die Permalink-Struktur unter Einstellungen > Permalinks auf „Einfach“. Dadurch geben geteilte oder irgendwie publik gewordene Links wenigstens keine Hinweise auf den Inhalt preis. Ein https://intranet.com/?p=123
sagt viel weniger aus als ein https://intranet.com/geheimnis-trump-ist-doof/
.
Schritt 8: Zugriffe über htaccess beschränken
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP_REFERER} !^http(s)?://(www\.)?intranet.com [NC]
RewriteRule \.(jpg|jpeg|png|gif|doc|docx|xls|xlsx|ppt|pptx|pdf)$ - [NC,F,L]
Options -Indexes
</IfModule>
Hier kommt nun etwas Webserver-Technik in’s Spiel. Dies variiert je nach eingesetzter Technik (NGINX, Apache, etc.) und ist daher nicht universell.
Was passiert hier? Zunächst setzen wir das mod_rewrite
Modul von Apache ein. Wir prüfen, ob der HTTP_REFERER
das System selbst ist. Wenn das nicht der Fall ist, werden Zugriffe auf Datei-Typen wie jpg, doc und pdf gesperrt. Der Webserver gibt dann einen 403 „Forbidden – You don’t have permission to access this resource.“ Fehler zurück. Dateien können also nur aus dem Netzwerk selbst, nicht über direkte Links aufgerufen werden. Zu guter letzt deaktivieren wir über Options -Indexes
die Verzeichnis-Listung, so dass nicht direkt auf Ordner wie die WordPress-eigenen Uploads (z.B. /wp-content/uploads/2019/) zugegriffen werden kann.
That’s it – das sind die Grundlagen, nach denen man WordPress nach außen hin abschotten kann. Natürlich gibt es noch weitere Wege und Facetten. Mach einer möchte auch komplett verschleiern, dass WordPress eingesetzt wird – was ich persönlich für unnötig halte.
Demnächst geht es hier weiter mit Intranet-Themen wie ACF, Frontend-Posting, Einstellungsseiten für Benutzer, Like-System Kalender-Modulen und mehr. Schaut einfach in die Liste aller Intranet-Beiträge.
Kommentare (4)
Ralf
Das sind alles sehr hilfreiche Tipps. Ich vermisse jedoch noch eine Schutzmaßnahme, wie man das direkte Aufrufen von Links innerhalb des uploads Verzeichnisses verhindert. Ist zwar sehr schwierig für einen Eindringling so einen kompletten Pfad als ganzes zu erraten, doch wenn, dann ist das bei WordPress erstmal nicht geschützt. Die oben gezeigten .htaccess Teile reichen dafür nicht aus. Es sollte beim Link-Aufruf irgendwie der Status der (WordPress-)Authentifizierung des Aufrufers geprüft werden können. Also nur authentifizierte User sollten einen erratenen Dateipfad öffnen dürfen. Gibt es hierfür eine Lösung? Danke für Tipps.
Christoph
Das ist richtig, wenn korrekt geraten wird (und der Referrer gefälscht wird, ohne geht es auch nicht), können die Dateien aufgerufen werden. In meinem konkreten Fall wurde das als unwahrscheinlich gesehen, daher haben wir das so hingenommen.
Über WordPress und die Benutzer-Authentifizierung den direkten Zugriff zu kontrollieren wird schwer, da ein direkter Datei-Aufruf nicht über PHP läuft, wo WordPress eingreifen könnte, sondern direkt über den Webserver.
Zwei Möglichkeiten für noch mehr Zugriffssicherheit, die ich spontan sehe:
(1) Ein Skript entwickeln/einsetzen, das Dateien ansteuert (so etwas wie example.com/download.php?file=123), und eben nur auf die Datei weiterleitet, wenn der Benutzer angemeldet ist. Hier müsste man dann über htaccess die direkten Dateizugriffe komplett unterbinden und alles über das Skript regeln. WordPress müsste man mit ein paar Filtern anpassen, um Datei-Links umzuschreiben.
(2) Den Uploads-Ordner umbenennen (zufällige Zeichenfolge), und wahlweise auch Dateien beim Upload zufällig umbenennen. Dann ist alles weiter normal über WordPress zu verwalten, aber so gut wie nicht mehr zu erraten. Nicht 100% sicher, aber doch zumindest 99,99%. Wer errät schon example.com/7xGux6/d7fjE2.jpg?
Ralf
Danke Christoph für Deine Antwort. Mittlerweile habe ich hier eine Lösung zu dem geschilderten Problem gefunden: https://www.pipeten.com/support/applications/protecting-wordpress-uploads . Habe es getestet und meine, das sollte die uploads effektiv gegen Zugriffe von außen (nicht eingeloggte Benutzer) „abschließen“.
Christoph
Sehr gut, kannte ich noch nicht – das ist genau der in (1) beschriebene Weg 🙂