7. Persistenz, Autorisierung, Cookies

7.4 Cross-Site Request Forgery

Wenn wir eine interaktive Web-App schreiben, dann haben wir wohl folgenden Work-Flow vor Augen:

  1. Der Nutzer in die Adresszeile den Url der Startseite ein ein
  2. Der Server liefert die Startseite. Diese enthält mehrere Möglichkeiten, mit dem Server zu interagieren - über <a>-Elemente, über form-Elemente und über Ajax-Requests, die von Javascript veranlasst werden.
  3. Die Interaktion des Nutzers mit der Startseite (oder weiteren Seiten) löst weitere Requests aus, die dann vom Server bearbeitet werden.

Das muss aber nicht immer so sein. Nichts hält mich davon ab, einen Link ins Innere einer Web-App hinein zu erstellen, beispielsweise Jakobs Speiselokal auf Facebook. Wenn Sie auf diesen Link klicken, dann werden Sie eben nicht zur Facebook-Startseite weitergeleitet, sondern direkt zu der Facebookseite des Restaurants. Und mehr noch: sollten Sie auf Facebook bereits in diesem Browser eingeloggt sein, dann erkennt das Facebook und weiß bereits, wer Sie sind.

Wann werden Cookies geschickt?

Sie haben bei dominikswebapp.de ein Zugangskonto und sind gerade eingeloggt. Der Server dominikswebapp.de hat daher Cookies gesetzt, z.B. session_id, username etc. Diese Cookies sind im Browser gespeichert. Ihr Browser schickt nun im Prinzip bei jedem Request an dominikswebapp.de die Cookies mit. Nicht aber an andere Seiten: wenn Sie eine Seite suspiciouswebsite.com aufrufen, dann werden die Cookies, die zu dominikswebapp.de gehören, nicht mitgeschickt. Ansonsten könnte der Server von suspiciouswebsite.com ja alle Session-Daten von dominikswebapp.de mitlesen.

Stellen Sie sich nun vor, sie gehen - im gleichen Browser, in einem anderen Tab - auf die Seite neutralwebsite.org, und diese Seite enthält einen Link auf dominikswebapp.de hat, so wie ich oben mit Jakobs Speiselokal auf Facebook. Wenn Sie diesen Link klicken, dann erstellt Ihr Browser ja einen Request auf dominikswebapp.de. Schickt er die entsprechenden Cookies mit? Ja. Die Richtlinie ist: geht ein Request vom Browser an Host X, dann werden alle Cookies, die für Host X gesetzt wurden (ob vom Server X selbst oder vom Browser) mitgeschickt. Probieren wir es aber aus!

Demo Für diese Demo brauchen Sie im Prinzip zwei "Hosts". Sie können es aber auch von einem Rechner aus tun, indem Sie localhost und 127.0.0.1 verwenden. Beide stellen "Ihren" Rechner dar, werden aber von Ihrem Browser cookie-technisch als zwei verschiedene Hosts wahrgenommen.

Übungsaufgabe Laden Sie sich 13-login-server.zip herunter oder zumindest die neueste Version von server.js. Dies ist der kleine Chat-Server mit Benutzerkonten aus dem vorherigen Teilkapitel. Gehen Sie in das Verzeichnis und starten Sie ihn:

node src/server.js

und gehen im Browser auf http://localhost:4013. Legen Sie ein Konto an (oder verwenden Sie einfach den Benutzernamen student mit dem Passwort blabla). Der Host http://localhost:4013 spielt in dieser Demo die Rolle der vertrauenswürdigen Seite dominikswebapp.de. Dort haben Sie ein Zugangskonto und darin haben Sie Daten gespeichert, an die andere nicht unbedingt kommen sollten.

Gehen Sie jetzt in das Verzeichnis 13-login-server/02-alien-posting/ und starten dort einen Javascript-Httpserver:

http-server .

Gehen Sie nun in einem weiteren Browser-Tab auf 127.0.0.1:8080/read-message-by-link.html. Der Host http://127.0.0.1:8080 spielt nun der Rolle der verdächtigen (oder zumindest nicht vertrauenswürdigen Webseite suspiciouswebsite.com. Öffnen Sie die Javascript-Konsole und überzeugen Sie sich, dass kein Cookie gesetzt ist:

Schauen Sie auf dem anderen Tab, das mit http://localhost:4013, nach Cookies:

Ihr Browser hat also für den Host localhost Cookies gespeichert, nicht aber für 127.0.0.1. Gut! Nun gehen Sie wieder auf das Tab mit 127.0.0.1:8080/read-message-by-link.html und klicken auf den Link learn more. Was geschieht? Der Html-Quelltext dieses Links ist <a href="http://localhost:4013/main">zum Chat-Server</a>. Wenn Sie klicken, geht ein Request an localhost. Offensichtlich werden Cookies mitgeschickt, Sie werden nämlich gleich zur Chat-Seite weitergeleitet und nicht zum Login-Bildschirm.

Demo Jetzt wiederholen wir die vorherige Übung, schauen uns aber im Network-Tab der Konsole an, was geschieht. Stellen Sie sicher, dass beide Server (an Port 4013 und an 8080) noch laufen, und Sie auf http://localhost:4013 eingeloggt sind. Öffnen Sie wieder 127.0.0.1:8080/read-message-by-link.html in einem Tab und öffnen die Konsole und dann den Reiter Network:

Laden Sie die Seite neu. Dann sehen Sie im Network-Tab nun die Requests, die das ausgelöst hat:

Ich habe die so sortiert (Reiter Waterfall), dass die ältesten unten stehen. Der Request auf read-message-by-link.html wurde gesendet, als Sie die Seite neugeladen haben. Da in dieser Html-Datei auf bootstrap.min.css referenziert wird, wurde ein zweiter Request geschickt. Wenn Sie nun auf den Request read-message-by-link.html klicken, können Sie sich die Http-Header im Quelltext ansehen:

Klicken Sie nun auf den Link "zum Chat-Server" und schauen sich den Header von main an:

Schauen wir uns die rot markierten Header an:

Cookie: username=dominik; session_id=0.7691479075795467
Host: localhost:4013
Referer: http://127.0.0.1:8080/

Wenn nun der Klick auf den Link einen Request auf einen fremden Host auslöst (der jetzige Host im Browser-Tab ist 127.0.0.1; der Host localhost ist also fremd), dann schickt der Browser das Cookie mit, teilt dem Server aber mit Referer: http://127.0.0.1:8080/ mit, dass der Request "von draußen" kam.

Übungsaufgabe Erstellen Sie eine Kopie von read-message-by-link.html, bei der der Klick Sie aus dem Login-Server auf localhost:4013 ausloggt.

Was Sie in der letzten Übung getan haben, nennt sich Cross-Site Request Forgery. Eine Seite generiert einen Request an eine andere (oder verführt den Nutzer, so einen zu generieren), daher Cross-Site. Da der Nutzer diesen Request nicht beabsichtigt, und er auch von einer Quelle stammt, die von der App nicht vorgesehene ist, reden wir von Fälschung, also Forgery. Immerhin: der Nutzer bekommt es mit, weil er ja auf die neue Seite geleitet wird.

Gefahrenquellen

Wir haben oben also gesehen: wenn eine "vertrauenswürdige" Siete wie localhost:4013 (der Login-Server) Cookies gesetzt hat und eine weitere, nicht vertrauenswürdige Seite auf localhost:4013 verlinkt, dann schickt der Browser einfach mal alle Cookies mit, falls der Nutzer diesen Link anklickt.

Gefahrenquelle 1. Eine Gefahrenquelle haben wir bereits gesehen. Ein unbedarfter Klick auf einer "dritten" Webseite (127.0.0.1:8080) hat Sie aus der "ersten" (der Login-Server auf localhost:4013) ausgeloggt. Schlimmer noch wäre es, wenn ein Klick auf so einen Link einen Beitrag auf dem Chat-Server erzeugen würde:

Ein Angreifer könnte zwar nicht an Ihre persönlichen Daten ran, kann Sie (bzw. Ihren Browser) aber zu einer Transaktion veranlassen, die Sie so nicht gewollt haben.

Übungsaufgabe Schreiben Sie eine Webseite, die eben dies tut: der Klick auf einen Link erzeugt einen neuen Beitrag im Chat-Server, falls der Nutzer dort eingeloggt ist.

Hinweis: dafür müssen Sie nicht nur die nicht vertrauenswürdige Webseite read-message-by-link.html umschreiben, sondern den Quelltext des Login-Servers in ändern.

Übungsaufgabe Erstellen Sie eine weitere Version der Seite aus der letzten Aufgabe. Bei dieser bekommt der Nutzer ja wenigstens mit, dass er betrogen wurde und unter seinem Namen ein Beitrag geschrieben wurde, weil der Browser ihn ja zum Chat-Server weiterleitet.

Schreiben Sie eine Seite, die ein <img>-Element enthält. Das src-Attribut soll aber keine Bild-Datei sein, sondern eben jener bösartige Link, der einen kompromittierenden Beitrag auf dem Chat-Server erzeugt. Funktioniert das?

Ein GET sollte nie eine "semantische" Änderung auf dem Server verursachen. /logout, /addmessage, /create-account, /transfer-money, /submit-exmatrikulation sollten alles POST-Requests sein.

Warum? Weil ein Klick auf einen Link (also ein <a>-Tag) immer einen GET-Request auslöst.

Ich habe oben von semantischer Veränderung gesprochen, denn natürlich gibt es legitime Gründe, wo ein GET-Requests den Zustand des Servers ändern kann, zum Beispiel weil der Server einfach Buch führen will und jeder GET-Request einen Zähler inkrementiert.

Übungsaufgabe Ändern Sie Ihre lokale Kopie von server.js und ändern /logout in einen POST-Request um. Dann müssen Sie natürlich auch den Logout-Knopf ändern.

Gefahrenquelle 2. Nehmen wir nun an, unsere App befolgt die Richtlinie, dass GET-Request nur lesen und nie die Daten ändern (außer für den Nutzer irrelevante wie server-seitige Statistiken). Dennoch kann es sein, dass Sie schlicht und ergreifend nicht wollen, dass Sie ein Klick auf einen Link ungefragt zum Chat-Server schickt und Sie dann gleich eingeloggt sind.

Dass ein Klick auf einen Link Sie zu einer anderen Seite weiterleitet, wollen Sie ja nicht grundsätzlich verhindern. Das ist ja gerade der Sinn des Webs. Aber Sie wollen vielleicht nicht, dass Sie gleich als "eingeloggt" behandelt werden, weil eventuell andere Leute (Arbeitskollegen etc.) Ihren Bildschirm sehen. Sie als Nutzer können da nicht viel tun (außer sich die Adresse des Links anzuschauen, bevor Sie draufklicken). Wer das verhindern kann, ist der Server. Wenn der Server nicht will, dass ein einfacher Klick Sie direkt von einer dritten Webseite auf die Chat-Seite bringt, kann er dies verhindern: der Server kann dem Cookie den Text SameSite=Strict hinzufügen und damit dem Browser signalisieren, dass Cookies nicht mitgeschickt werden sollen, wenn der Nutzer durch einen Klick auf einen Link auf die Seite gekommen ist.

Die Same-Site-Policy

Wann schickt ein Klick auf einen Link die Cookies mit? Werden Cookies mitgeschickt, wenn die Seite automatisch Resourcen lädt (wie zum Beispiel Bilder)? Das hängt von den SameSite-Einstellungen ab, die der Server den Cookies mitgegeben hat. Bei der Recherche zu der Frage, ob beim Laden einer zusätzlichen Resource, beispielsweise einer mit <img> eingebundenen Bilddatei, Cookies mitgeschickt werden, bin ich auf viele Quellen gestoßen, die behaupten Ja, die werden mitgeschickt. Allerdings sind viele dieser Quellen von 2009 und mittlerweile veraltet. Hier ein Post dazu:

This question is old, but was the first result on Google for me, so I think it's worth clarifying how this works nowadays (2021).

When bar.com sets the cookie, they can specify a SameSite attribute.

If the cookie is set with SameSite=Lax (or the SameSite attribute is not specified), then the cookie will not be sent for requests for images/iframes/etc hosted on bar.com, but will be sent if the user clicks a link on your foo.com homepage that takes them to bar.com

If the cookie is set with SameSite=Strict, the cookie will not be included in requests to bar.com that originate from another webiste, including if the user clicks a bar.com link on foo.com.

If the cookie is set with SameSite=None, the cookie will be sent to bar.com, including requests for images.

Wir können für SameSite drei verschiedene Werte anlegen: Strict, Lax und None. Die Bedeutung ist auf developer.mozilla.org wie folgt spezifiziert:

Strict

Means that the browser sends the cookie only for same-site requests, that is, requests originating from the same site that set the cookie. If a request originates from a different domain or scheme (even with the same domain), no cookies with the SameSite=Strict attribute are sent.

Lax

Means that the cookie is not sent on cross-site requests, such as on requests to load images or frames, but is sent when a user is navigating to the origin site from an external site (for example, when following a link). This is the default behavior if the SameSite attribute is not specified.

None

means that the browser sends the cookie with both cross-site and same-site requests. The Secure attribute must also be set when setting this value, like so SameSite=None; Secure. If Secure is missing an error will be logged [...]

Hier nochmal auf Deutsch in meinen eigenen Worten:

  1. SameSite=Strict: der Browser schickt die Cookies nur dann mit, wenn die Seite, die den Request erzeugt, über den gleichen Host geladen wurde, der auch die Cookies gesetzt hat. "Gleicher Host" ignoriert hier den Port, localhost:4013 und localhost:8080 wären hier also gleiche Hosts; daher haben wir 127.0.0.1:8080 verwendet, damit es zwei verschiedene werden). Die Cookies werden natürlich auch mitgeschickt, wenn der User den URL per Hand (oder Copy-Paste) oben in die Adresszeile eingibt und Enter drückt.

  2. SameSite=Lax: wie Strict, es werden allerdings Cookies geschickt, wenn der Nutzer durch Klick auf einen Link zur Cookie-setzenden Seite gelangt. Dies ist die Grundeinstellung (gilt also, wenn das Attribut nicht gesetzt ist).

  3. SameSite=None: dann werden die Cookies bei jedem Request geschickt, egal woher, egal durch der Request ausgelöst worden ist. Allerdings kann der Server das nur setzen, wenn auch das Attribut secure gesetzt ist. Das wiederum heißt, dass das Cookie nur geschickt werden darf, wenn die Kommunikation über Https läuft. Um damit zu experimentieren, müssen wir also den Server auf Https umrüsten.

Übungsaufgabe Machen Sie den Chat-Server unsicherer, indem Sie SameSite=None setzen. Hierfür müssen Sie ihn auf Https umrüsten.

Hosten Sie nun auf 127.0.0.1:8080 eine Seite, in der ein "Bild" eingebunden ist, dessen src aber nicht auf eine Bilddatei zeigt, sondern Sie vom Chat-Server ausloggt.

Übungsaufgabe Machen Sie den Chat-Server sicherer/restriktiver, so dass der User nicht mehr über Links von Drittseiten auf das Chat-Fenster gelangen kann (sondern wie ein ausgeloggter Nutzer behandelt wird).

Wenn ich beim Login-Server SameSite=Strict gesetzt habe und nun in read-message-by-link den Link klicke, dann schickt der Browser die Cookies nicht mit. Der Server lädt also nicht /main sondern schickt mich zum Login-Bildschirm zurück, mitsamt Fehlermeldung, dass ich nicht eingeloggt bin. Allerdings interessant, was jetzt passiert, wenn ich auf dieser Seite bin:

  1. Wenn ich auf das Reload-Symbol drücke, dann werde ich immer noch wie nicht eingeloggt behandelt. Schauen Sie sich den Request im Http-Quelltext an. Da steht immer noch Referer: http://127.0.0.1:8080 drin, egal, wie oft ich auf Reload drücke. Der Browser behandelt es also immer noch als Cross-Site-Request.
  2. Wenn ich in die Adresszeile klicke und Enter drücke, dann bin ich plötzlich eingeloggt. Denn nun habe ich als Nutzer ja oben in der Zeile selbst die Seite angesteuert. Dies zählt nun also als Same-Site-Request.
  3. Wenn ich auf den Link back to main page drücke, dann zählt das auch als Same-Site-Request, weil ja die erzeugende Seite von localhost:4013 gehostet wird.

Übungsaufgabe Überprüfen Sie, ob Ihr Browser das gleiche Verhalten zeigt.

Gefahrenquelle 3. Es gibt eine weitere Sicherheitslücke. Eine Webseite kann automatisch weiterleiten auf eine andere. Wenn es zum Beispiel auf Ihrem Server eine Datei /public/main-old.html liegt, dann wird die ja vom Server "statisch" bedient, denn server.js enthält die Codezeile

     app.use(express.static(path.join(__dirname, "..", "public")));

Es wird also nicht mit app.get("/public/main-old.html") oder so eine Funktion definiert, die Cookies überprüfen könnte. Nein, die Seite wird statisch geladen (so wie beispielsweise auch public/styles.css). Das Problem ist jetzt allerdings, wenn aus irgendeinem Grund die Seite /public/main-old.html einen automatischen Weiterleitungsbefehl auf /main gibt. Denn /public/main-old.html kann ohne Cookies geladen werden; leitet dann weiter auf eine Seite im "Cookie-Bereich"; da /public/main-old.html aber bereits vom Host localhost:4013 geladen wird, zählt die Weiterleitung als Same-Site-Request, und der Browser schickt die Cookies mit. Und sofort werden die Nachrichten angezeigt:

Übungsaufgabe Legen Sie im Verzeichnisbaum vom Chat-Server eine Datei /public/main-old.html an, die den Nutzer auf /main weiterleitet. Dann erstellen Sie im Verzeichnis, wo 127.0.0.1:8080 läuft, eine Seite suspicious-link-to-main-old.html, die einen Link auf /public/main-old.html enthält. Gehen Sie auf 127.0.0.1:8080/suspicious-link-to-main-old.html und klicken Sie auf den Link. Funktioniert die automatische Weiterleitung? Werden Cookies mitgeschickt?

Wenn Sie also SameSite=Strict haben wollen, dann stellen Sie bitte sicher, dass Ihre "ungeschützten" Seiten (für die der Server nicht die Cookies überprüft) nicht irgendwie auf die geschützten Seiten weiterleiten; denn sonst könnte man von außen doch den Browser dazu bringen, die Cookies mitzusenden.

Wir haben bis jetzt drei Gefahrenquellen identifiziert. Als erstes die, dass der Nutzer durch unbedarftes Link-Klicken einen GET-Request veranlasst und dieser Server Nutzerdaten ändert; dies können wir verhindern, indem wir serverseitig alle verändernden Requests als POST definieren. Zweitens, dass ein Link-Klick über einen GET-Request womöglich private Inhalte auf den Bildschirm lädt und somit physisch anwesende dritte Personen Dinge sehen, die sie nicht sehen sollen. Das Szenario verhindern Sie, indem Sie serverseitig SameSite=Strict einstellen. Drittens, dass intern auf den Seiten im Server irgendwo eine automatische Weiterleitung eingerichtet ist, von einer nicht-Cookie-geschützten Seite auf eine Cookie-geschützte, und ein Cross-Site-Request somit einen Same-Site-Request auslöst, der dann alle Cookies mitschickt.

Alle Gefahrenquellen drehen sich also um Cross-Site-Requests und die daran haftenden Cookies. Neben dem Klicken auf Links, dem Reload-Knopf, der Adresszeile, <img>-Elementen und Weiterleitungsbefehlen gibt es ja noch weitere Mechanismen, die einen Request auslösen können. Schauen wir uns die doch mal an.

Requests, die durch eine form ausgelöst werden

Übungsaufgabe Legen Sie im Verzeichnis, wo 127.0.0.1:8080 läuft, eine Datei an, wo über eine form ein POST an http://localhost:4013/add-message geschickt wird. Sie können den kompromitierenden Inhalt sogar verbergen, indem Sie dem input-Element die Attribute style="display:none" und value="Ihre gewünschte Fake-Nachricht geben.

Sie werden bei der letzten Übung scheitern. Die form schickt die Cookies nicht mit. Warum? Es ist wohl so wie bei img, dass Cookies nicht mitgeschickt werden, wenn sameSite=Lax oder sameSite=Strict gesetzt ist.

Übungsaufgabe Geben Sie dem Chat-Server eine Sicherheitslücke, indem Sie ihn mit sameSite=None starten. Denken Sie an Https! Können Sie von der fremden Seite auf 127.0.0.1:8080 per Knopfdruck Nachrichten Ihrer Wahl auf localhost:4013 veröffentlichen?

Ajax-Requests auf eine dritte Seite