Sichere deine Website mit Subresource Integrity

Avatar of Khari McMillian
Khari McMillian am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Wenn du eine Datei von einem externen Server lädst, vertraust du darauf, dass der Inhalt, den du anforderst, auch tatsächlich der ist, den du erwartest. Da du den Server nicht selbst verwaltest, bist du auf die Sicherheit einer weiteren Drittpartei angewiesen und erhöhst die Angriffsfläche. Dem Vertrauen in eine Drittpartei ist per se nichts Falsches, aber es sollte sicherlich im Kontext der Sicherheit deiner Website berücksichtigt werden.

Ein reales Beispiel

Dies ist keine rein theoretische Gefahr. Das Ignorieren potenzieller Sicherheitsprobleme kann bereits zu schwerwiegenden Folgen geführt haben und hat dies auch getan. Am 4. Juni 2019 gab Malwarebytes die Entdeckung eines bösartigen Skimmers auf der Website NBA.com bekannt. Aufgrund eines kompromittierten Amazon S3 Buckets konnten Angreifer eine JavaScript-Bibliothek manipulieren, um Kreditkarteninformationen von Kunden zu stehlen.

Nicht nur JavaScript ist Anlass zur Sorge. CSS ist ebenfalls eine Ressource, die gefährliche Aktionen wie den Diebstahl von Passwörtern ausführen kann, und es braucht nur einen einzigen kompromittierten Drittanbieterserver, um eine Katastrophe auszulösen. Aber sie können unschätzbare Dienste leisten, auf die wir nicht einfach verzichten können, wie z. B. CDNs, die die Bandbreitennutzung einer Website reduzieren und Dateien aufgrund von standortbasiertem Caching viel schneller für den Endbenutzer bereitstellen. Es steht also fest, dass wir uns manchmal auf einen Host verlassen müssen, über den wir keine Kontrolle haben, aber wir müssen auch sicherstellen, dass der Inhalt, den wir von ihm erhalten, sicher ist. Was können wir tun?

Lösung: Subresource Integrity (SRI)

SRI ist eine Sicherheitsrichtlinie, die das Laden von Ressourcen verhindert, die nicht mit einem erwarteten Hash übereinstimmen. Dadurch wird, wenn ein Angreifer Zugriff auf eine Datei erlangt und deren Inhalt mit bösartigem Code verändert, der Hash nicht mit dem erwarteten übereinstimmen und der Code gar nicht erst ausgeführt.

Schützt HTTPS nicht schon davor?

HTTPS ist großartig für die Sicherheit und ein Muss für jede Website. Obwohl es ähnliche Probleme (und viel mehr) verhindert, schützt es nur vor Manipulationen während der Übertragung. Wenn eine Datei auf dem Host selbst manipuliert würde, würde die bösartige Datei trotzdem über HTTPS gesendet werden, was den Angriff nicht verhindert.

Wie funktioniert Hashing?

Eine Hashing-Funktion nimmt Daten beliebiger Größe als Eingabe und gibt Daten fester Größe als Ausgabe zurück. Hashing-Funktionen hätten idealerweise eine gleichmäßige Verteilung. Das bedeutet, dass für jede Eingabe, x, die Wahrscheinlichkeit, dass die Ausgabe, y, irgendein spezifischer möglicher Wert ist, ähnlich der Wahrscheinlichkeit ist, dass sie irgendein anderer Wert im Bereich der Ausgaben ist.

Hier ist eine Metapher

Stell dir vor, du hast einen 6-seitigen Würfel und eine Liste von Namen. Die Namen wären in diesem Fall die „Eingabe“ der Hash-Funktion und die gewürfelte Zahl die „Ausgabe“ der Funktion. Für jeden Namen in der Liste würfelst du und notierst dir, welchem Namen jede Zahl entspricht, indem du die Zahl neben den Namen schreibst. Wenn ein Name mehr als einmal als Eingabe verwendet wird, ist die entsprechende Ausgabe immer die gleiche wie beim ersten Mal. Für den ersten Namen, Alice, würfelst du 4. Für den nächsten, John, würfelst du 6. Dann erhältst du für Bob, Mary, William, Susan und Joseph 2, 2, 5, 1 und 1. Wenn du „John“ erneut als Eingabe verwendest, ist die Ausgabe wieder 6. Diese Metapher beschreibt im Wesentlichen, wie Hash-Funktionen funktionieren.

Name (Eingabe)Gewürfelte Zahl (Ausgabe)
Alice4
John6
Bob2
Mary2
William5
Susan1
Joseph1

Du hast vielleicht bemerkt, dass z. B. Bob und Mary die gleiche Ausgabe haben. Bei Hash-Funktionen nennt man das eine „Kollision“. Für unser Beispiel kommt es unweigerlich vor. Da wir sieben Namen als Eingaben und nur sechs mögliche Ausgaben haben, ist mindestens eine Kollision garantiert.

Ein bemerkenswerter Unterschied zwischen diesem Beispiel und einer Hash-Funktion in der Praxis ist, dass praktische Hash-Funktionen typischerweise deterministisch sind, d. h. sie verwenden keine Zufälligkeit wie unser Beispiel. Vielmehr ordnet sie Eingaben vorhersagbar Ausgaben zu, sodass jede Eingabe gleich wahrscheinlich jeder bestimmten Ausgabe zugeordnet wird.

SRI verwendet eine Familie von Hash-Funktionen namens Secure Hash Algorithm (SHA). Dies ist eine Familie kryptografischer Hash-Funktionen, die Varianten mit 128, 256, 384 und 512 Bit umfasst. Eine kryptografische Hash-Funktion ist eine spezifischere Art von Hash-Funktion mit den Eigenschaften, dass sie praktisch unumkehrbar ist, um die ursprüngliche Eingabe zu finden (ohne bereits die entsprechende Eingabe zu haben oder Brute-Force anzuwenden), kollisionsresistent ist und so konzipiert ist, dass eine kleine Änderung der Eingabe die gesamte Ausgabe verändert. SRI unterstützt die 256-, 384- und 512-Bit-Varianten der SHA-Familie.

Hier ist ein Beispiel mit SHA-256

Zum Beispiel ist die Ausgabe für hello

2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

Und die Ausgabe für hell0 (mit einer Null anstelle eines O) ist

bdeddd433637173928fe7202b663157c9e1881c3e4da1d45e8fff8fb944a4868

Du wirst bemerken, dass die kleinste Änderung an der Eingabe eine Ausgabe erzeugt, die völlig anders ist. Das ist eine der Eigenschaften kryptografischer Hashes, die zuvor aufgeführt wurden.

Das Format, das du am häufigsten für Hashes sehen wirst, ist Hexadezimal, das alle Dezimalziffern (0-9) und die Buchstaben A bis F umfasst. Einer der Vorteile dieses Formats ist, dass jedes Zeichenpaar ein Byte darstellt und die Gleichmäßigkeit für Zwecke wie die Farbfomattierung nützlich sein kann, bei der ein Byte jede Farbe darstellt. Das bedeutet, dass eine Farbe ohne Alpha-Kanal mit nur sechs Zeichen dargestellt werden kann (z. B. Rot = ff0000)

Diese Platzersparnis ist auch der Grund, warum wir Hashing anstelle des direkten Vergleichs der gesamten Datei mit den erwarteten Daten jedes Mal verwenden. Obwohl 256 Bit nicht alle Daten einer Datei, die größer als 256 Bit ist, ohne Kompression darstellen können, sorgt die Kollisionsresistenz von SHA-256 (und 384, 512) dafür, dass es praktisch unmöglich ist, zwei Hashes für unterschiedliche Eingaben zu finden, die übereinstimmen. Und was SHA-1 betrifft, so ist es nicht mehr sicher, da eine Kollision gefunden wurde.

Interessanterweise ist die Attraktivität der Kompaktheit wahrscheinlich einer der Gründe, warum SRI-Hashes *kein* hexadezimales Format verwenden, sondern stattdessen Base64. Das mag auf den ersten Blick eine seltsame Entscheidung sein, aber wenn wir bedenken, dass diese Hashes im Code enthalten sein werden und Base64 die gleiche Datenmenge wie Hexadezimal vermitteln kann, während es 33 % kürzer ist, ergibt das Sinn. Ein einzelnes Zeichen von Base64 kann 64 verschiedene Zustände annehmen, was 6 Bit Daten entspricht, während Hexadezimal nur 16 Zustände oder 4 Bit Daten darstellen kann. Wenn wir also beispielsweise 32 Byte Daten (256 Bit) darstellen wollen, bräuchten wir 64 Zeichen in Hexadezimal, aber nur 44 Zeichen in Base64. Wenn wir längere Hashes wie sha384/512 verwenden, spart Base64 viel Platz.

Warum funktioniert Hashing für SRI?

Stellen wir uns vor, es gäbe eine JavaScript-Datei, die auf einem Drittanbieterserver gehostet wird, den wir in unsere Webseite eingebunden haben und für die wir Subresource Integrity aktiviert haben. Wenn nun ein Angreifer die Daten der Datei mit bösartigem Code modifiziert, würde der Hash nicht mehr mit dem erwarteten Hash übereinstimmen und die Datei würde nicht ausgeführt werden. Erinnern wir uns daran, dass jede kleine Änderung an einer Datei ihren entsprechenden SHA-Hash vollständig verändert und dass Hash-Kollisionen mit SHA-256 und höher zum Zeitpunkt der Erstellung dieses Textes praktisch unmöglich sind.

Unser erster SRI-Hash

Es gibt also ein paar Methoden, mit denen du den SRI-Hash einer Datei berechnen kannst. Eine Methode (und vielleicht die einfachste) ist die Verwendung von srihash.org, aber wenn du eine programmatischere Methode bevorzugst, kannst du Folgendes verwenden:

sha384sum [filename here] | head -c 96 | xxd -r -p | base64
  • sha384sum Berechnet den SHA-384-Hash einer Datei
  • head -c 96 Schneidet alle bis auf die ersten 96 Zeichen des Strings ab, der an es übergeben wird
    • -c 96 Gibt an, alle bis auf die ersten 96 Zeichen abzuschneiden. Wir verwenden 96, da dies die Zeichenlänge eines SHA-384-Hashs im hexadezimalen Format ist
  • xxd -r -p Nimmt Hex-Eingaben, die an es übergeben werden, und konvertiert sie in Binärform
    • -r Weist xxd an, Hex-Eingaben zu empfangen und in Binärform zu konvertieren
    • -p Entfernt die zusätzliche Ausgabeformatierung
  • base64 Konvertiert einfach die Binärausgabe von xxd in Base64

Wenn du dich entscheidest, diese Methode zu verwenden, sieh dir die folgende Tabelle an, um die Längen jedes SHA-Hashs zu sehen.

Hash-AlgorithmusBitsBytesHex-Zeichen
SHA-2562563264
SHA-3843844896
SHA-51251264128

Für den Befehl head -c [x] ist x die Anzahl der Hex-Zeichen für den entsprechenden Algorithmus.

MDN erwähnt auch einen Befehl zur Berechnung des SRI-Hashs

shasum -b -a 384 FILENAME.js | awk '{ print $1 }' | xxd -r -p | base64

awk '{print $1}' Findet den ersten Teil eines Strings (getrennt durch Tab oder Leerzeichen) und übergibt ihn an xxd. $1 repräsentiert das erste Segment des übergebenen Strings.

Und wenn du Windows verwendest

@echo off
set bits=384
openssl dgst -sha%bits% -binary %1% | openssl base64 -A > tmp
set /p a= < tmp
del tmp
echo sha%bits%-%a%
pause
  • @echo off verhindert, dass die ausgeführten Befehle angezeigt werden. Dies ist besonders hilfreich, um sicherzustellen, dass das Terminal nicht überladen wird.
  • set bits=384 setzt eine Variable namens bits auf 384. Diese wird später im Skript verwendet.
  • openssl dgst -sha%bits% -binary %1% | openssl base64 -A > tmp ist komplexer, also zerlegen wir sie in Teile.
    • openssl dgst berechnet eine Digest einer Eingabedatei.
    • -sha%bits% verwendet die Variable bits und kombiniert sie mit dem Rest des Strings zu einem der möglichen Flag-Werte: sha256, sha384 oder sha512.
    • -binary gibt den Hash als Binärdaten aus, anstatt als String-Format, wie z. B. Hexadezimal.
    • %1% ist das erste Argument, das dem Skript beim Ausführen übergeben wird.
    • Der erste Teil des Befehls hasht die Datei, die dem Skript als Argument übergeben wurde.
    • | openssl base64 -A > tmp konvertiert die Binärausgabe, die durch sie geleitet wird, in Base64 und schreibt sie in eine Datei namens tmp. -A gibt die Base64-Ausgabe in einer einzigen Zeile aus.
    • set /p a= <tmp speichert den Inhalt der Datei tmp in einer Variable a.
    • del tmp löscht die Datei tmp.
    • echo sha%bits%-%a% gibt den Typ des SHA-Hashs zusammen mit der Base64-Darstellung der Eingabedatei aus.
    • pause Verhindert, dass das Terminal geschlossen wird.

SRI in Aktion

Nachdem wir nun verstehen, wie Hashing und SRI-Hashes funktionieren, versuchen wir es mit einem konkreten Beispiel. Wir erstellen zwei Dateien

// file1.js
alert('Hello, world!');

und

// file2.js
alert('Hi, world!');

Dann berechnen wir die SHA-384 SRI-Hashes für beide

DateinameSHA-384 Hash (Base64)
file1.js3frxDlOvLa6GGEUwMh9AowcepHRx/rwFT9VW9yL1wv/OcerR39FEfAUHZRrqaOy2
file2.jshtr1LmWx3PQJIPw5bM9kZKq/FL0jMBuJDxhwdsMHULKybAG5dGURvJIXR9bh5xJ9

Dann erstellen wir eine Datei namens index.html

<!DOCTYPE html>
<html>
  <head>
    <script type="text/javascript" src="./file1.js" crossorigin="anonymous"></script>
    <script type="text/javascript" src="./file2.js" crossorigin="anonymous"></script>
  </head>
</html>

Lege alle diese Dateien im selben Ordner ab und starte einen Server innerhalb dieses Ordners (führe z. B. npx http-server im Ordner mit den Dateien aus und öffne dann eine der Adressen, die von http-server oder einem Server deiner Wahl bereitgestellt werden, z. B. 127.0.0.1:8080). Du solltest zwei Alert-Dialogfelder sehen. Das erste sollte "Hello, world!" und das zweite "Hi, world!" anzeigen.

Wenn du den Inhalt der Skripte änderst, wirst du feststellen, dass sie nicht mehr ausgeführt werden. Das ist Subresource Integrity in Aktion. Der Browser erkennt, dass der Hash der angeforderten Datei nicht mit dem erwarteten Hash übereinstimmt und weigert sich, ihn auszuführen.

Wir können auch mehrere Hashes für eine Ressource einfügen, und der stärkste Hash wird gewählt, so

<!DOCTYPE html>
<html>
  <head>
    <script
      type="text/javascript"
      src="./file1.js"
       crossorigin="anonymous"></script>
    <script 
      type="text/javascript"
      src="./file2.js"
      crossorigin="anonymous"></script>
  </head>
</html>

Der Browser wählt den Hash, der als der stärkste gilt, und vergleicht den Hash der Datei damit.

Warum gibt es das Attribut „crossorigin“?

Das Attribut crossorigin teilt dem Browser mit, wann die Benutzeranmeldedaten mit der Anfrage für die Ressource gesendet werden sollen. Es gibt zwei Optionen zur Auswahl:

Wert (crossorigin=)Beschreibung
anonymDie Anfrage hat den Anmeldemodus auf same-origin und den Modus auf cors eingestellt.
use-credentialsDie Anfrage hat den Anmeldemodus auf include und den Modus auf cors eingestellt.

Erwähnte Anmeldedatenmodi der Anfrage

AnmeldemodusBeschreibung
same-originAnmeldedaten werden bei Anfragen an Same-Origin-Domains gesendet und Anmeldedaten, die von Same-Origin-Domains gesendet werden, werden verwendet.
includeAnmeldedaten werden auch an Cross-Origin-Domains gesendet und von Cross-Origin-Domains gesendete Anmeldedaten werden verwendet.

Erwähnte Anfrage-Modi

Anfrage-ModusBeschreibung
corsDie Anfrage wird eine CORS-Anfrage sein, die erfordert, dass der Server eine definierte CORS-Richtlinie hat. Wenn nicht, wird die Anfrage einen Fehler auslösen.

Warum ist das Attribut „crossorigin“ für Subresource Integrity erforderlich?

Standardmäßig können Skripte und Stylesheets Cross-Origin geladen werden, und da Subresource Integrity das Laden einer Datei verhindert, wenn der Hash der geladenen Ressource nicht mit dem erwarteten Hash übereinstimmt, könnte ein Angreifer Cross-Origin-Ressourcen massenhaft laden und testen, ob das Laden mit bestimmten Hashes fehlschlägt, und dadurch Informationen über einen Benutzer ableiten, die er sonst nicht erhalten könnte.

Wenn du das Attribut crossorigin einfügst, muss die Cross-Origin-Domain entscheiden, ob sie Anfragen von dem Ursprung erlaubt, von dem die Anfrage gesendet wird, damit die Anfrage erfolgreich ist. Dies verhindert Cross-Origin-Angriffe mit Subresource Integrity.

Subresource Integrity mit Webpack verwenden

Es klingt wahrscheinlich nach viel Arbeit, die SRI-Hashes jeder Datei jedes Mal neu zu berechnen, wenn sie aktualisiert werden, aber glücklicherweise gibt es eine Möglichkeit, dies zu automatisieren. Lass uns ein Beispiel gemeinsam durchgehen. Du benötigst ein paar Dinge, bevor du anfängst.

Node.js und npm

Node.js ist eine JavaScript-Laufzeitumgebung, die uns zusammen mit npm (seinem Paketmanager) die Verwendung von webpack ermöglicht. Um es zu installieren, besuche die Node.js-Website und wähle den Download, der deinem Betriebssystem entspricht.

Projekt einrichten

Erstelle einen Ordner und gib ihm einen beliebigen Namen mit mkdir [Name des Ordners]. Gib dann cd [Name des Ordners] ein, um dorthin zu wechseln. Jetzt müssen wir das Verzeichnis als Node-Projekt einrichten. Gib also npm init ein. Es werden dir ein paar Fragen gestellt, aber du kannst sie mit Enter überspringen, da sie für unser Beispiel nicht relevant sind.

webpack

webpack ist eine Bibliothek, die es dir ermöglicht, deine Dateien automatisch zu einer oder mehreren Bündeln zu kombinieren. Mit webpack müssen wir die Hashes nicht mehr manuell aktualisieren. Stattdessen fügt webpack die Ressourcen mit den Attributen integrity und crossorigin in das HTML ein.

webpack installieren

Du musst webpack und webpack-cli installieren

npm i --save-dev webpack webpack-cli 

Der Unterschied zwischen den beiden ist, dass webpack die Kernfunktionalitäten enthält, während webpack-cli für die Kommandozeilenschnittstelle gedacht ist.

Wir werden unsere package.json bearbeiten, um einen scripts-Abschnitt hinzuzufügen, wie folgt:

{
  //... rest of package.json ...,
  "scripts": {
    "dev": "webpack --mode=development"
  }
  //... rest of package.json ...,
}

Dies ermöglicht uns, npm run dev auszuführen und unser Bundle zu erstellen.

Webpack-Konfiguration einrichten

Als Nächstes richten wir die webpack-Konfiguration ein. Dies ist notwendig, um webpack mitzuteilen, mit welchen Dateien es arbeiten soll und wie.

Zuerst müssen wir zwei Pakete installieren: html-webpack-plugin und webpack-subresource-integrity

npm i --save-dev html-webpack-plugin webpack-subresource-integrity style-loader css-loader
PaketnameBeschreibung
html-webpack-pluginErstellt eine HTML-Datei, in die Ressourcen eingefügt werden können
webpack-subresource-integrityBerechnet und fügt Subresource-Integrity-Informationen in Ressourcen wie <script> und <link rel=…> ein
style-loaderWendet die CSS-Styles an, die wir importieren
css-loaderErmöglicht es uns, CSS-Dateien in unseren JavaScript-Code zu importieren

Konfiguration einrichten

const path              = require('path'),
      HTMLWebpackPlugin = require('html-webpack-plugin'),
      SriPlugin         = require('webpack-subresource-integrity');

module.exports = {
  output: {
    // The output file's name
    filename: 'bundle.js',
    // Where the output file will be placed. Resolves to 
    // the "dist" folder in the directory of the project
    path: path.resolve(__dirname, 'dist'),
    // Configures the "crossorigin" attribute for resources 
    // with subresource integrity injected
    crossOriginLoading: 'anonymous'
  },
  // Used for configuring how various modules (files that 
  // are imported) will be treated
  modules: {
    // Configures how specific module types are handled
    rules: [
      {
        // Regular expression to test for the file extension.
        // These loaders will only be activated if they match
        // this expression.
        test: /\.css$/,
        // An array of loaders that will be applied to the file
        use: ['style-loader', 'css-loader'],
        // Prevents the accidental loading of files within the
        // "node_modules" folder
        exclude: /node_modules/
      }
    ]
  },
  // webpack plugins alter the function of webpack itself
  plugins: [
    // Plugin that will inject integrity hashes into index.html
    new SriPlugin({
      // The hash functions used (e.g. 
      // <script ...
      hashFuncNames: ['sha384']
    }),
    // Creates an HTML file along with the bundle. We will
    // inject the subresource integrity information into 
    // the resources using webpack-subresource-integrity
    new HTMLWebpackPlugin({
      // The file that will be injected into. We can use 
      // EJS templating within this file, too
      template: path.resolve(__dirname, 'src', 'index.ejs'),
      // Whether or not to insert scripts and other resources
      // into the file dynamically. For our example, we will
      // enable this.
      inject: true
    })
  ]
};

Vorlage erstellen

Wir müssen eine Vorlage erstellen, um webpack mitzuteilen, in welche Datei das Bundle und die Subresource-Integrity-Informationen eingefügt werden sollen. Erstelle eine Datei namens index.ejs

<!DOCTYPE html>
<html>
  <body></body>
</html>

Erstelle nun eine index.js im Ordner mit dem folgenden Skript

// Imports the CSS stylesheet
import './styles.css'
alert('Hello, world!');

Bundle erstellen

Gib npm run build im Terminal ein. Du wirst feststellen, dass ein Ordner namens dist erstellt wird und darin eine Datei namens index.html, die ungefähr so aussieht:

<!DOCTYPE HTML>
<html><head><script defer src="bundle.js" crossorigin="anonymous">
</script></head>
  <body>
  </body>
</html>

Die CSS wird als Teil der Datei bundle.js enthalten sein.

Dies funktioniert nicht für Dateien, die von externen Servern geladen werden, und sollte es auch nicht, da Cross-Origin-Dateien, die ständig aktualisiert werden müssen, mit aktivierter Subresource Integrity nicht funktionieren würden.

Danke fürs Lesen!

Das war's für dieses Mal. Subresource Integrity ist eine einfache und effektive Ergänzung, um sicherzustellen, dass du nur das lädst, was du erwartest, und deine Benutzer schützt. Und denk daran: Sicherheit ist mehr als nur eine Lösung. Sei also immer auf der Suche nach weiteren Möglichkeiten, deine Website sicher zu halten.