Einleitung

Mit automatisierten Ende-zu-Ende-Tests lassen sich nicht nur Bugs finden, sondern auch regelmäßig die Einhaltung von Sicherheitsmaßnahmen überprüfen. Das hat eine Reihe von Vorteilen:

  • Automatisierte Security-Tests überprüfen zuverlässig, ob Sicherheitsfunktionen wie vorgesehen funktionieren.
  • Sie helfen dabei, Sicherheitsmechanismen während der Weiterentwicklung stabil zu halten und ungewollte Regressionen frühzeitig zu erkennen.
  • Beim Schreiben automatisierter Tests wird die Perspektive potenzieller Angreifer eingenommen.

In diesem Artikel zeigen wir anhand konkreter Beispiele, wie sich mit Playwright sicherheitsrelevante Aspekte wie Content Security Policy (CSP), Clickjacking oder Cross-Site Request Forgery (CSRF) zuverlässig testen lassen.

Ansatz: Playwright-Ende-zu-Ende-Security-Testing

In diesem Artikel werden wir uns auf die Überprüfung ausgewählter Sicherheitsaspekte mithilfe von automatisierten Ende-zu-Ende-Tests konzentrieren. Diese Tests können neben den Ende-zu-Ende-Tests für die Features der Anwendung implementiert werden. Sie können in der gleichen Pipeline laufen wie diese „normalen“ Tests. Daher fühlt sich ihre Entwicklung wie die Entwicklung der Tests für Anwendungsfeatures an. Wir zeigen in diesem Beispiel exemplarisch für Content Security Policy (CSP) wie man einige Aspekte mithilfe von Playwright überprüfen kann. Die CSP wird im Header einer HTML-Antwort verschickt. Sie wird während der Entwicklungsarbeiten des Frontends konfiguriert. Um die CSP zu überprüfen, bietet es sich daher an, im Rahmen eines Tests, die Seite aufzurufen und dort die Checks durchzuführen. Playwright ist für Ende-zu-Ende Tests einer Webapplikation derzeit das gängige Werkzeug. Hier werden wir speziell auf die Besonderheiten beim Testen der CSP mit Playwright eingehen. Im Großen und Ganzen können für die Sicherheitstests die gleichen Ansätze und Methoden verwendet werden wie für Ende-zu-Ende Tests für neue Features. In unseren Tests für die CSP wollen wir verschiedene Aspekte überprüfen.

Content-Security-Policy-Überprüfung

Der erste Aspekt betrifft das einfache Aufrufen der zu überprüfenden Seite. Hier wollen wir als Erstes sicherstellen, dass keine CSP durch die vorhandene Implementierung verletzt wird. Daher rufen wir die Seite auf und überprüfen, dass keine Warnung in der Konsole des Browsers erscheint. Mit einer kleinen Funktion können wir Playwright anweisen, die Fehlermeldungen der Browserkonsole, die während des Tests erzeugt werden, in ein Array zu schreiben. Dazu übergeben wir die Seite und das Array an die Funktion und deren Implementierung sorgt dafür, dass die Fehlermeldungen in unser Array geschrieben werden.

function logBrowserErrors(page: Page, errors: string[]) {
  page.on("console", (messsage) => {
    if (messsage.type() === "error") {
      errors.push(messsage.text());
    }
  });
}

Wir können daher nach dem Aufruf unserer zu überprüfenden Seite validieren, dass keine CSP-Warnungen oder andere Fehlermeldungen auf der Seite ausgelöst wurden. Die Überprüfung kann mit der expect-Funktion von Playwright vorgenommen werden.

expect(errors).toHaveLength(0);

Beim Aufrufen der Seite durch Playwright erhalten wir auch die Antwort auf diesen Aufruf. Diese enthält im Header die CSP-Attribute. Wir schreiben diese Werte in eine sogenannte Validierungsdatei. Diese wird beim ersten Durchlaufen des Tests mit den aktuellen CSP-Attributen gefüllt. Diese Werte müssen initial auf die erwarteten Werte kritisch überprüft werden. Sollte es Abweichungen zu den erwarteten Werten geben, so muss die CSP angepasst werden, damit die Werte in der Validierungsdatei mit den erwarteten Werten übereinstimmen.

Sobald die Validierungsdatei freigegeben worden ist, wird in jedem weiteren Durchlauf des Tests, ob lokal oder in einer Pipeline, der Inhalt der Datei mit den aktuell erhaltenen Attributen verglichen. Sollte eine Abweichung erkannt werden, schlägt der Test fehl. Auf diese Weise werden zuverlässig alle Änderungen an der CSP erkannt. Bei geplanten Änderungen der CSP kann die Datei angepasst werden. In den restlichen Fällen wird überprüft, warum sich die CSP geändert hat und es kann entschieden werden, ob die Änderung rückgängig gemacht werden muss oder ob sie beibehalten werden kann.

Hier ist ein Beispiel, wie der Inhalt einer solchen Validierungsdatei aussieht:

{
  "cspHeaderValues": [
    "default-src 'self'",
    "connect-src 'self'",
    "script-src 'nonce-[NONCE]' 'strict-dynamic' 'wasm-unsafe-eval'",
    "style-src-elem 'self' 'nonce-[NONCE]'",
    "style-src-attr 'unsafe-inline'",
    "img-src 'self' blob: data:",
    "font-src 'self' data:",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'"
  ]
}

Die Nonce-Werte haben wir in dieser Datei maskiert, da sie in jedem Durchlauf neu erzeugt werden und der Test daher nicht auf einen konkreten Nonce-Wert testen kann.

async function validateCSPData(
  response: Response,
  page: Page,
) {
  const cspHeaderValues =
    (await response.allHeaders())["content-security-policy"] ?? "";
  if (cspHeaderValues === "") {
    throw new Error("CSP must not be empty.");
  }
  const hasMetaCSP = await checkMetaCSP(page);
  expect(hasMetaCSP).toBeFalsy();
  const snapshot: Record<string, string[]> = {};
  snapshot.cspHeaderValues = cspHeaderValues
    .split(/;\s*/)
    .filter((str) => str !== "");
  await compareActualWithValidationFile(snapshot);
}

In der gezeigten Methode validateCSPData ist unsere Implementierung für die Validierung der CSP-Attribute zu sehen. Wir müssen der Methode lediglich die Seite (page) und die Antwort des Aufrufs der Seite (response) übergeben. Die Methode extrahiert aus der Antwort den Anteil, der die CSP betrifft. In einer ersten Validierung überprüfen wir, dass die CSP nicht leer ist. Wir führen dann eine weitere Überprüfung aus und validieren, dass keine Meta-CSP-Attribute im HTML-Teil der Antwort befindlich sind. Wir haben uns dazu entschieden als eigenen Standard keine Meta-CSP-Attribute zuzulassen und überprüfen das an dieser Stelle, um Konflikte zwischen der CSP im Header und in den Meta-Attributen zu vermeiden. Am Ende der Methode formatieren wir die CSP-Attribute und übergeben sie unserer Methode, die die Werte mit der oben erwähnten Datei vergleicht.

CSP-Warnung überprüfen

In einem weiteren Schritt manipulieren wird den HTML-Teil unserer zu überprüfenden Seite und verifizieren, dass die erwarteten CSP-Warnungen in der Konsole des Browsers erscheinen. Eine Manipulation enthält zum Beispiel folgende Zeile, die wir dem HTML-Body der Seite hinzufügen:

<script src="https://bad.test/evil.js" async=""></script>

Diese Manipulation simuliert einen Angriff per XSS (Cross-Site-Scripting). Bei einem solchen Angriff wird auf eine Website „bösartiger Code“, meist in Form von JavaScript, eingeschleust. Falls der Code zur Ausführung käme, könnten zum Beispiel sensible Daten abgegriffen werden. Daher ist es wichtig zu überprüfen, dass falls Code in die Seite eingeschleust werden sollte, dieser auf keinen Fall ausgeführt wird.

Die Manipulation des HTML-Bodys erreichen wir mithilfe der Methode route, die wir auf das page-Objekt von Playwright anwenden:

async function setupRouteWithModifiedBody(
  page: Page
) {
  await page.route(
    page.url(),
    async (route) => {
      const response = await route.fetch();
      let bodyForModification = await response.text();
      bodyForModification = bodyForModification.replace(
        "</body>",
        `<script src="https://bad.test/evil.js" async=""></script></body>`,
      );
      await route.fulfill({
        response,
        body: bodyForModification,
      });
    }
  );
}

In dieser Methode manipulieren wir den Aufruf der zu überprüfenden Seite. Wir wenden die route-Methode auf die URL der Seite an und manipulieren dabei den HTML-Body. In der route-Methode geben wir als ersten Parameter die URL an, die wir manipulieren möchten. Als zweiten Parameter definieren wir die Anweisungen, die dazu führen, dass der Body manipuliert wird. Dazu lassen wir zuerst mittels route.fetch die eigentliche Antwort auf Anfragen zu der zu testenden Seite in eine Variable speichern. Diese Antwort verändern wird dann, indem wir am Ende ein „böses“ Skript hinzufügen. Mittels route.fulfill weisen wir Playwright an, beim Aufruf der Seite den manipulierten Body zurückzugeben.

Nachdem die Methode im Test aufgerufen worden ist, wird jeder Aufruf der Seite von Playwright abgefangen und der HTML-Body der Antwort wird durch den manipulierten Body ersetzt.

Für den Fall, dass durch eine unzureichende CSP das Skript aufgerufen werden sollte, verwenden wir auch die route-Methode von Playwright. Diese leitet den Aufruf für das Skript auf ein von uns definiertes Skript um:

async function setupRouteForEvilScript(page: Page) {
  await page.route("https://bad.test/evil.js", async (route) => {
    const jsContent = `console.log("Hello world!");`;
    await route.fulfill({
      status: 200,
      contentType: "application/javascript",
      body: jsContent,
    });
  });
}

Wenn während der Testausführung die Seite mit dem manipulierten Body aufgerufen wird, wird eine Warnung in der Konsole des Browsers ausgegeben und das „böse“ Skript wird nicht geladen.

Screenshot des Browsers mit der zu testenden URL. Das Dev-Tool ist geöffnet und zeigt die Fehlermeldung: „Refused to load the script „https://evildomain.test/evil.js“ because it violates the following Content Security Policy directive: „script-src“ „nonce-N2Q4MWMmMTIyTUzMy0NmQ5LTg5MWYtZmIxZDUSMWUxZjVi“ „strict-dynamic“ „wasm-unsafe-eval““. Note that „script-src-elem“ was not explicitly set, so „script-src“ is used as a fallback.“ (Backticks und Code-Anführungszeichen wurden in diesem Alternativtext aus technischen Gründen durch deutsche Anführungszeichen ersetzt, um der HTML-Syntax im CMS genüge zu tun.)

Man kann in dem Screenshot, der während der Testausführung erstellt wurde, mehrere verletzte CSP-Regeln sehen. Diese Fehlermeldungen werden in das anfangs erwähnte Array geschrieben. Sie werden wie die CSP im Header der HTML-Antwort in einer separaten Datei validiert. Sollte sich während einer Testausführung die Fehlermeldung ändern oder ganz ausbleiben, schlägt der Test fehl und es muss nach einer Ursache sowie einer Lösung dafür gesucht werden.

Clickjacking mittels CSP verhindern

Mithilfe der CSP kann auch verhindert werden, dass „bösartige“ Websites unsere Seite mittels eines iframe Elements in ihre Website einbetten, ein sogenannter Clickjacking-Angriff. Durch die Einbettung der Website wird unsere Seite durch die bösartige Website überlagert und weder die User noch wir als Betreiber erkennen, dass ungewollt Funktionen auf der Seite ausgeführt werden. Um dies zu verhindern, wird der CSP „frame-ancestors `none`“ hinzugefügt. Dies sorgt dafür, dass die Einbettung auf anderen Websites fehlschlägt. Für unseren Test haben wir eine minimale Website erstellt, die ein iframe-Element auf unsere Seite enthält. Wir haben dazu wieder die route-Methode verwendet.

async function setupRouteForIframeSite(page: Page) {
  const body = `<!DOCTYPE html>
    <head>
      <meta charset="utf-8">
      <title>ClickJacking Test</title>
    </head>
    <body><iframe src="${page.url()}"</body>
    </html>`;
  await page.route("https://bad.test/clickjacking", (route) =>
    route.fulfill({
      contentType: "text/html;charset=utf-8",
      body,
    }),
  );
}

Die Methode setupRouteForIframeSite führt dazu, dass wenn im Test die URL „https://bad.test/clickjacking“ aufgerufen wird, die in der Methode definierte Seite aufgerufen wird. Wenn die CSP korrekt konfiguriert ist, dann funktioniert das iframe-Element nicht. Zudem wird auf der Seite eine Fehlermeldung in der Konsole ausgegeben.

Screenshot des Browsers in dem die bösartige Clickjacking-Domain geöffnet ist. Das Dev-Tool ist geöffnet und zeigt folgende Fehlermeldung: „Refused to frame „http://localhost:4002/“ because an ancestor violates the following Content Security Policy directive: „frame-ancestors „none““. (Backticks und Code-Anführungszeichen wurden in diesem Alternativtext aus technischen Gründen durch deutsche Anführungszeichen ersetzt, um der HTML-Syntax im CMS genüge zu tun.)

Das ist in dem obigen Screenshot zu sehen. In der Fehlermeldung wird auch die verletzte CSP „frame-ancestors 'none’“ angegeben. Auch diese Fehlermeldung wird wie oben beschrieben in eine Validierungsdatei geschrieben und bei jeder Ausführung des Tests überprüft.

CSRF-Angriff testen

Zum Abschluss stellen wir noch ein CSRF-Szenario vor, welches man mittels Ende-zu-Ende-Tests in Playwright überprüfen kann. In einem ersten Schritt loggt sich der Playwright Test bei der zu testenden Software ein. Wir haben für diesen Test zwei minimale Websites erstellt, die bei dem Klick auf einen Link eine Abfrage an unsere zu testende Software abschicken. Dies ist jedoch auf den ersten Blick für einen Nutzer nicht ersichtlich. Zu Demonstrationszwecken beziehungsweise Testzwecken haben wir dazu einen zustandsändernden GET-Request verwendet.

Wir testen sowohl einen Cross-Origin- als auch einen Same-Site-Fall.

Die erste Website hat eine von der zu testenden Seite unterschiedliche Domain. Die zweite Website hat eine Subdomain unserer zu testenden Seite als URL. Diese Seite ist oben abgebildet. Sie ist, wie man sieht, für den Test sehr minimal gehalten und enthält im Wesentlichen nur den bösartigen Link. Wenn Playwright im Test auf den Link klickt, überprüfen wir jeweils, dass eine Fehlermeldung beim Aufruf des Links auf unsere zu testende Software erscheint. Zusätzlich überwachen wir mittels der route-Methode von Playwright den Endpunkt, der durch die bösartigen Aufrufe, also hier das Klicken auf den Link, angegriffen wird.

async function monitorAttackedEndpoint(
  page: Page,
) {
  await page.route(attackedEndpoint, async (route) => {
    const response = await route.fetch();
    expect(response.status()).toBe(403);

    await route.fulfill({ response: response });
  });
}

Um einen solchen Angriff zu verhindern, werden zum Beispiel CSRF-Cookies verwendet. Auf diese Weise wird verhindert, dass der Endpunkt den bösartigen Request beantwortet, da die bösartige Seite keinen Zugriff auf die CSRF-Cookies hat, die für einen erfolgreichen Request mitgeschickt werden müssen. Es wird in unserer Software bei einem versuchten CSRF-Angriff ein http-403-Fehlercode zurückgegeben. Dies überprüfen wir mit der oben dargestellten Methode.

Schlussbetrachtung

Wir haben hier an einigen Beispielen dargelegt, wie sich Sicherheitsaspekte für Webanwendungen, unter anderem CSP oder CSRF, im Zusammenspiel mit Playwright durch Ende-zu-Ende-Tests automatisiert testen lassen. Es wurde prinzipiell gezeigt, wie sich einige unterschiedliche Aspekte, zum Beispiel das Vorhandensein der erwarteten CSP in der http-Antwort, testen lassen. Die Tests lassen sich an unterschiedliche Webanwendungen anpassen und können auf diese Weise projektübergreifend eingesetzt werden. Die dargestellten Tests sind nur ein kleiner Ausschnitt von möglichen automatisierbaren Sicherheitstests. Weitere Sicherheitsaspekte, wie beispielsweise Zugriffsberechtigungen oder Brute-Force-Angriffe, können auch mithilfe von Ende-zu-Ende-Tests durch Playwright automatisiert getestet werden.