Tutorial: Ein minifiziertes Fremdskript lesen, bevor es auf die Seite darf

Inhalt
Ein Anbieter schickt einen Codeschnipsel, jemand fügt ihn in den Tag-Manager ein, und von diesem Augenblick an läuft eine Datei, die niemand gelesen hat, auf jeder Seite mit vollem Zugriff auf alles, was die Seite enthält.
Diese Datei ordentlich zu lesen ist die Arbeit einer Sicherheitsprüfung. Gut genug zu lesen, um zu entscheiden, sind sechs Suchläufe durch eine formatierte Kopie und kostet etwa zwanzig Minuten.

Was Formatieren wiederherstellt und was nicht
Minifizierung entfernt Zeilenumbrüche und Einrückung, kürzt lokale Variablennamen auf ein bis zwei Zeichen und wirft Kommentare weg. Formatieren macht das Erste rückgängig und sonst nichts.
Das genügt, denn die Teile, auf die es ankommt, behalten ihre Namen. Eine Eigenschaft eines Browserobjekts – localStorage, fetch, navigator – lässt sich nicht umbenennen, sonst erkennt der Browser sie nicht mehr. Jede Fähigkeit eines Skripts steht deshalb weiterhin ausgeschrieben da, umgeben von Variablen namens a, t und e.
# eine Kopie holen und formatieren
curl -s https://cdn.anbieter.example/widget.js -o widget.min.js
npx js-beautify widget.min.js -o widget.js
# Groesse vorher und nachher sagt, wie stark verdichtet war
wc -c widget.min.js widget.js
Der Browser kann dasselbe ohne Werkzeug: Der Quellenbereich hat eine Formatierschaltfläche, die die Datei an Ort und Stelle aufklappt – der schnellere Weg für einen kurzen Blick und der falsche für eine dokumentierte Prüfung, denn es bleibt keine Datei übrig, gegen die sich beim nächsten Mal vergleichen lässt.
Die sechs Dinge, nach denen sich suchen lohnt
Jeder dieser Läufe beantwortet eine Frage, und zusammen beschreiben sie, was das Skript zu tun vermag.
# 1 sendet es etwas hinaus?
grep -nE 'fetch\(|XMLHttpRequest|sendBeacon|new Image|WebSocket' widget.js
# 2 schreibt es etwas dauerhaft?
grep -nE 'localStorage|sessionStorage|document\.cookie|indexedDB' widget.js
# 3 laedt es weiteren Code nach?
grep -nE "createElement\(['\"]script|\\.src\\s*=|import\\(" widget.js
# 4 liest es Eingaben mit?
grep -nE "addEventListener\\(['\"](input|keydown|keyup|change|submit)" widget.js
# 5 erhebt es Geraetemerkmale?
grep -nE 'getContext|AudioContext|navigator\.(plugins|hardwareConcurrency)|RTCPeerConnection' widget.js
# 6 wohin geht es?
grep -oE 'https?://[a-z0-9.-]+' widget.js | sort -u
Die ersten beiden entscheiden, ob überhaupt eine Einwilligungskategorie nötig ist. Ein Skript, das weder etwas speichert noch etwas sendet, ist eine Darstellungshilfe; eines, das beides tut, ist eine Datenverarbeitung – und dieser Unterschied gehört in die Datenschutzerklärung und nicht in ein Ticket.
Der vierte verdient besondere Aufmerksamkeit bei allem, was in der Nähe eines Formulars eingebunden ist. Ein Zuhörer auf input ist nicht automatisch eine Tastaturaufzeichnung – ein Chatfenster hört berechtigt auf sein eigenes Feld -, aber die Prüfung lautet, an welches Element er gebunden ist, und das steht wenige Zeilen weiter unten.
Der fünfte ist die Gruppe der Wiedererkennungsmerkmale. Ein Canvas-Aufruf in einem Widget, das etwas zeichnet, ist erwartbar; ein Canvas-Aufruf in einem Analyseschnipsel, der nichts Sichtbares darstellt, hat einen Zweck – und dieser Zweck hat Folgen für die Einwilligung, ganz gleich, wie der Anbieter ihn nennt.
Minifiziert von verschleiert unterscheiden
Das sind verschiedene Dinge, und der Unterschied ist ein Entscheidungspunkt und keine Feinheit.
Minifizierung ist eine Größenoptimierung und lässt lesbare Struktur zurück. Verschleierung ist darauf angelegt, das Lesen zu verhindern, und hat erkennbare Merkmale: eine große Liste hexadezimal kodierter Zeichenketten am Dateianfang, eine Indexfunktion, die darin nachschlägt, String.fromCharCode in einer Schleife, atob um etwas, das kein Bild ist, und vor allem eval oder new Function auf einer zusammengesetzten Zeichenkette.
grep -nE 'eval\(|new Function\(|atob\(|String\.fromCharCode|\\\\x[0-9a-f]{2}' widget.js
Ein Treffer hier beweist keine böse Absicht – manche Anbieter verschleiern, um die eigene Logik zu schützen. Er bedeutet aber, dass die sechs Suchläufe oben die Frage nicht mehr beantworten, denn die Fähigkeiten lassen sich zur Laufzeit aus Zeichenketten zusammensetzen, die so in der Datei nicht stehen.
Die praktische Folge: Ein verschleiertes Fremdskript lässt sich durch Lesen nicht prüfen. Übrig bleibt das Verhalten – in einer Testseite laden, Netzwerk- und Speicherbereich aufzeichnen und mit dem vergleichen, was der Anbieter angibt. Ist das nicht hinnehmbar, lautet die Antwort nicht „besser suchen”, sondern „anderer Anbieter”.
Die Hostliste herausziehen
Der sechste Lauf liefert die unmittelbar nützlichste Ausgabe: jede Domain, die die Datei nennt. Diese Liste gehört in die Sicherheitsrichtlinie, und sie aus der Datei zu bauen statt aus der Dokumentation fängt die Endpunkte ab, die die Dokumentation vergessen hat.
grep -oE 'https?://[a-z0-9.-]+' widget.js | sort -u
https://cdn.anbieter.example
https://api.anbieter.example
https://events.anbieter.example
https://fonts.gstatic.com
Zwei der vier sind die interessanten. Ein Host, der nur in einem Kommentar oder einer Dokumentationszeichenkette vorkommt, ist Rauschen; ein Host in einem fetch-Aufruf ist ein Ziel. Zu prüfen, welcher was ist, kostet je Host einen weiteren Suchlauf und ist der Unterschied zwischen einer Richtlinie, die funktioniert, und einer, die aus einem Blogbeitrag abgeschrieben ist.
Content-Security-Policy:
script-src 'self' https://cdn.anbieter.example;
connect-src 'self' https://api.anbieter.example https://events.anbieter.example;
font-src 'self' https://fonts.gstatic.com;
Zuerst im Nur-Bericht-Modus zu starten ist die zusätzliche Woche wert. Die Meldungen nennen jeden Host, den die Untersuchung übersehen hat – und es gibt immer einen, denn ein Skript, das ein zweites Skript nachlädt, erbt dessen Ziele, und die stehen in der ersten Datei nicht.
Festnageln, und wo das scheitert
Alles Bisherige beschreibt eine Fassung einer Datei. Der Anbieter kann sie morgen ersetzen, und nichts am Auftritt würde es bemerken.
Bei einer statischen Datei schließt Subresource Integrity diese Lücke: eine Prüfsumme im Skript-Tag, und der Browser verweigert eine Datei, die nicht dazu passt. Eine geänderte Datei lädt dann nicht mehr, statt ungeprüft zu laufen – und das ist das richtige Scheitern.
<script src="https://cdn.anbieter.example/widget.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>
Bei einem Tag-Container funktioniert das nicht, und der Grund ist grundsätzlicher Art: Der Container soll sich ändern, sobald jemand veröffentlicht. Eine Prüfsumme müsste bei jeder Veröffentlichung nachgezogen werden, was niemand tut – ein Container wird also ohne Integritätsprüfung geladen oder gar nicht.
Was dort an die Stelle tritt, ist eine Frage der Zuständigkeit und keine technische: Wer darf veröffentlichen, und sieht eine zweite Person auf die Änderung. Das ist die ehrliche Antwort, und sie lohnt sich als solche aufzuschreiben, statt so zu tun, als deckte ein Header sie ab.
Was nur die Überwachung zeigt
Drei Dinge entgehen dem Lesen der Datei, und alle drei tauchen in der Überwachung auf statt in der Analyse.
Das erste ist das nachgeladene Skript. Ein Lader, der seine eigentliche Nutzlast zur Laufzeit holt, enthält keine der Fähigkeiten, die diese Nutzlast hat – die sechs Suchläufe kommen sauber zurück auf einer Datei, die nichts tut, außer den interessanten Teil herunterzuladen. Eine script-src-Anweisung ohne Platzhalter macht das sichtbar, denn die zweite Datei braucht einen eigenen Eintrag.
Das zweite ist bedingtes Verhalten. Ein Skript, das nur auf bestimmten Seiten, in bestimmten Ländern oder ab einem bestimmten Datum erhebt, ist vollständig lesbar und gibt keinen Anlass, ausgerechnet den entscheidenden Zweig anzusehen. Nur die Beobachtung über die Zeit findet das, und ein wöchentlicher Abgleich gegen die gespeicherte Kopie ist die günstigste Form davon.
curl -s https://cdn.anbieter.example/widget.js | sha256sum
# gegen den gespeicherten Wert vergleichen, woechentlich
Das dritte ist, was der Anbieter mit den Daten nach ihrer Ankunft tut, und das beantwortet keine Datei auf dieser Seite. Das gehört in den Vertrag – und die technische Prüfung ist das, was den Vertrag genau genug macht, um ihn unterschreiben zu wollen: Die Prüfung benennt die Endpunkte, die Speicherschlüssel und die Ereignisse und macht aus einer allgemeinen Zusicherung eine Liste, an der sich jemand festhalten lässt.
Fragen und Antworten
Schützt Subresource Integrity auch das Skript, das ein Lader zur Laufzeit nachholt?
Nein. Die Prüfsumme gilt nur für das eine Skript-Tag, in dem sie steht. Legt der Lader zur Laufzeit ein neues Skript-Element an, prüft der Browser dessen Datei nur dann, wenn der Lader selbst ein integrity-Attribut mitgibt, und das tut er in aller Regel nicht. Der festgenagelte Lader kann also unverändert bleiben, während die Nutzlast, die er holt, jeden Tag eine andere sein kann.
Die Lücke schließt an dieser Stelle die Sicherheitsrichtlinie, nicht die Prüfsumme: Eine script-src-Anweisung ohne Platzhalter lässt die zweite Datei nur von einem ausdrücklich eingetragenen Host laden. Die Nutzlast ist dann eine eigene Datei, die dieselben sechs Suchläufe durchläuft wie der Lader, und ihre Prüfsumme kommt in den wöchentlichen Abgleich.
Wozu dient das Attribut crossorigin im Skript-Tag mit der Prüfsumme?
Eine Datei von einem fremden Host lässt sich nur prüfen, wenn der Browser sie über CORS abruft; ohne diesen Modus bleibt ihr Inhalt für die Prüfung unzugänglich, und der Browser verweigert das Skript. crossorigin="anonymous" schaltet diesen Modus ein und schickt dabei keine Cookies mit. Voraussetzung ist, dass der Anbieter die Datei mit einem passenden Access-Control-Allow-Origin-Header ausliefert; fehlt er, lädt das Skript trotz richtiger Prüfsumme nicht.
Was ist zu tun, wenn der wöchentliche Prüfsummenvergleich eine Abweichung meldet?
Eine abweichende Prüfsumme sagt nur, dass sich die Datei geändert hat, nicht was. Sinnvoll ist eine feste Reihenfolge:
- Die neue Fassung holen, formatieren und neben der alten ablegen. Ein zeilenweiser Vergleich der beiden formatierten Kopien hilft nur begrenzt, denn ein neuer Minifizierungslauf kann auch bei einer kleinen Änderung die kurzen Variablennamen neu vergeben und damit fast jede Zeile verändern.
- Die sechs Suchläufe und die Suche nach Verschleierung auf der neuen Fassung wiederholen und die Treffer mit denen der alten vergleichen. Eine neue Fähigkeit, etwa ein erstmals auftauchendes
sendBeaconoder ein Zugriff aufdocument.cookie, ist der Befund, auf den es ankommt. - Die Hostliste neu ziehen und mit der Sicherheitsrichtlinie abgleichen. Ein neuer Host ist entweder in die Richtlinie aufzunehmen oder mit dem Anbieter zu klären.
- Den neuen Wert speichern und festhalten, wann und warum er übernommen wurde.
Ist das Skript mit Subresource Integrity eingebunden, lädt es nach der Änderung ohnehin nicht mehr, bis die Prüfsumme im Skript-Tag nachgezogen ist. Ob sie nachgezogen wird, entscheidet dann diese Prüfung.