alle Artikel
8 Minuten

Fetch like a pro, Teil 4: Sicherheit beim Server State

Frank Lechner & KI ·
„Was, wenn es nicht jeder gut mit deiner API meint? Teil 4 zeigt CSRF-Schutz bei Cookie-Auth, warum dangerouslySetInnerHTML zur offenen Tür wird, und wie Zod kompromittierte API-Antworten abfängt.”
Fetch like a pro, Teil 4: Sicherheit beim Server State
Dieser Text wurde mit Hilfe von KI erstellt.

Teil 1 dieser Serie war Mechanik: useQuery, useMutation, Caching. Teil 2 war Produktionsreife: Auth, SSR, Persistenz, Serverlast, Ordnerstruktur. Teil 3 stellt eine unbequemere Frage: Was passiert, wenn jemand nicht wohlwollend mit deiner API interagiert?

Nicht jedes Sicherheitsthema gehört in einen Fetch-Post — SQL-Injection, Dependency-Scanning oder eine vollständige Content-Security-Policy sind eigene, große Themen für sich. Hier geht's gezielt um die Schnittstelle zwischen React/TanStack Query und dem Backend: die Stellen, an denen queryFn, mutationFn und der httpClient aus Teil 2 tatsächlich Angriffsfläche bieten.

CSRF-Schutz bei Mutations

In Teil 2 stand die Empfehlung, Auth-Tokens in httpOnly-Cookies statt in localStorage zu speichern, um XSS-Diebstahl zu verhindern. Der Preis dafür: Cookies werden vom Browser automatisch bei jedem Request an die Domain mitgeschickt — auch bei einem, den eine bösartige Seite in einem versteckten Formular oder Fetch-Call auslöst. Das ist Cross-Site Request Forgery: Der Nutzer ist bei deiner App eingeloggt, besucht eine andere Seite, und diese Seite schickt in seinem Namen einen POST-Request an deine API, ohne dass er es merkt.

Bearer-Token-im-Header-Ansätze sind davon nicht betroffen, weil eine fremde Seite den Header nicht setzen kann, ohne den Token zu kennen. Bei Cookie-Auth brauchst du dagegen einen expliziten CSRF-Schutz. Das gängigste Pattern ist Double-Submit-Cookie: Der Server setzt neben dem Auth-Cookie ein zweites, für JavaScript lesbares CSRF-Token-Cookie, das der Client bei jedem mutierenden Request zusätzlich als Header mitschickt. Der Server vergleicht beide — eine fremde Seite kann das zweite Cookie zwar mitschicken lassen, aber nicht auslesen und als Header setzen.

// lib/httpClient.ts — Erweiterung aus Teil 2
function getCsrfTokenFromCookie(): string | null {
  const match = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
  return match ? decodeURIComponent(match[1]) : null;
}

async function httpClient<T>(path: string, options: RequestInit = {}): Promise<T> {
  const isMutation = options.method && options.method !== "GET";
  const csrfToken = isMutation ? getCsrfTokenFromCookie() : null;

  const res = await fetch(`${API_BASE_URL}${path}`, {
    ...options,
    credentials: "include", // Cookies werden mitgeschickt
    headers: {
      "Content-Type": "application/json",
      ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
      ...options.headers,
    },
  });

  if (!res.ok) {
    throw new HttpError(res.status, `Request fehlgeschlagen: ${res.status}`);
  }

  return res.json();
}

Für useMutation-Aufrufe in der Codebase ändert sich dadurch nichts — der Schutz sitzt zentral im httpClient, genau wie schon der Auth-Header in Teil 2. Das ist der eigentliche Vorteil der zentralen httpClient-Schicht: Ein sicherheitsrelevanter Fix an einer Stelle schützt automatisch jede Mutation im Projekt.

XSS-Sanitizing für Server-Daten

TanStack Query liefert Daten so, wie der Server sie schickt — ungefiltert. Enthält ein Artikel Rich-Text-Content, der als HTML gerendert wird, ist dangerouslySetInnerHTML der naheliegende, aber gefährliche Weg:

// So NICHT:
function ArticleContent({ article }: { article: Article }) {
  return <div dangerouslySetInnerHTML={{ __html: article.content }} />;
}

Enthält article.content ein eingeschleustes <script>-Tag — etwa weil ein anderer Nutzer es über ein Kommentarfeld oder einen kompromittierten Editor eingeschleust hat —, wird es bei jedem Aufruf der Seite ausgeführt. Genau darüber ließe sich dann auch ein in localStorage liegender Token stehlen, den Teil 2 aus diesem Grund schon kritisch gesehen hat.

Die Lösung ist Sanitizing vor dem Rendern, idealerweise mit einer etablierten Library wie DOMPurify:

npm install dompurify
npm install -D @types/dompurify
import DOMPurify from "dompurify";

function ArticleContent({ article }: { article: Article }) {
  const cleanHtml = DOMPurify.sanitize(article.content);
  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}

Noch robuster: Das Sanitizing zusätzlich serverseitig durchführen, bevor der Content überhaupt in der Datenbank landet — Client-seitiges Sanitizing schützt nur die eine Stelle, an der du daran gedacht hast; ein zweiter Rendering-Pfad (z. B. eine mobile App, die dieselbe API nutzt) wäre sonst ungeschützt. Faustregel: Sanitizing gehört so nah wie möglich an die Quelle des Inhalts, Client-seitiges Sanitizing ist die zweite Verteidigungslinie, nicht die einzige.

Validierung der API-Antwort

TypeScript prüft zur Compile-Zeit, ob dein Code zum deklarierten Typ passt — nicht, ob die tatsächliche Server-Antwort zur Laufzeit dazu passt. Ändert sich ein Feld im Backend, ohne dass der Frontend-Typ synchron aktualisiert wird, oder liefert eine kompromittierte Zwischenstation manipulierte Daten, merkt TypeScript davon nichts. Das Ergebnis sind oft erst weit entfernt vom eigentlichen Fehlerort auftretende undefined-Zugriffe.

Zod schließt diese Lücke, indem es zur Laufzeit prüft, was tatsächlich ankommt:

npm install zod
import { z } from "zod";

const articleSchema = z.object({
  id: z.string(),
  title: z.string(),
  content: z.string(),
  publishedAt: z.string().datetime(),
});

const articlesResponseSchema = z.array(articleSchema);

type Article = z.infer<typeof articleSchema>;

async function fetchArticles(): Promise<Article[]> {
  const data = await httpClient<unknown>("/articles");
  return articlesResponseSchema.parse(data); // wirft bei Abweichung
}

Schlägt parse() fehl, landet die Query automatisch im isError-Zustand aus Teil 1 — der Fehler ist explizit und an der richtigen Stelle sichtbar, statt sich als kryptischer Rendering-Fehler drei Komponenten weiter unten zu zeigen. Der Article-Typ wird dabei direkt aus dem Schema abgeleitet (z.infer), sodass Typ und Laufzeit-Prüfung nie auseinanderlaufen können.

CORS kurz eingeordnet

CORS ist kein TanStack-Query-Thema, taucht aber in der Praxis fast immer als erstes Sicherheits-verwandtes Problem auf, an dem Auth-Flows hängen bleiben. Kurz eingeordnet: CORS ist eine Server-seitige Konfiguration (Access-Control-Allow-Origin und verwandte Header), die festlegt, von welchen Domains aus der Browser Requests an die API zulässt. TanStack Query bekommt einen CORS-Fehler nur als generischen, wenig aussagekräftigen Network-Error zu sehen — der eigentliche Fix passiert nie im React-Code, sondern in der Server-Konfiguration. Landet ein CORS-Fehler im onError-Handler aus Teil 1, lohnt sich ein Hinweis in der Fehlermeldung, dass es sich um eine Server-Konfigurationsfrage handelt, damit nicht in der falschen Codebasis gesucht wird.

Sichere Fehlerbehandlung & Logging

Die globale Fehlerbehandlung aus Teil 1 (QueryCache.onError, MutationCache.onError) ist ein guter zentraler Ort für Client-seitiges Error-Logging an Dienste wie Sentry — aber genau deshalb auch eine Stelle, an der versehentlich sensible Daten nach außen gelangen können:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      // Nicht: Tokens, komplette Request-Bodies oder Nutzer-IDs ungefiltert mitloggen
      logToSentry({
        message: error.message,
        queryKey: query.queryKey,
        // KEIN error.stack mit vollständigem Response-Body,
        // falls die API Header oder Nutzerdaten im Fehlerobjekt mitschickt
      });
    },
  }),
});

Kurze Checkliste, bevor ein Fehlerobjekt an ein externes Logging-Tool geht: Enthält es Auth-Header oder Tokens? Enthält es den vollständigen Request- oder Response-Body, der personenbezogene Daten haben könnte? Wird die Query-Key-Factory aus Teil 2 genutzt, damit im Log nachvollziehbar ist, welche Query betroffen war, ohne rohe, potenziell sensible Parameter mitzuschleifen?

Fazit

Sicherheit beim Server State ist kein Zusatzfeature, das man am Ende noch draufsetzt — die Entscheidungen aus Teil 2 (Cookie- vs. Header-Auth, was persistiert wird) und die zentrale httpClient-Schicht sind genau die Stellen, an denen sich CSRF-Schutz, Sanitizing und Validierung am saubersten verankern lassen. Der gemeinsame Nenner aller Punkte hier: Vertraue weder blind dem, was der Server schickt (Validierung, Sanitizing), noch dem, was der Browser automatisch mitschickt (CSRF-Schutz bei Cookies) — und behandle Fehlerdaten mit derselben Vorsicht wie die eigentlichen Nutzdaten.

Damit ist die Serie an dem Punkt, an dem aus einem Tutorial-Beispiel eine Codebase wird, die man einem Sicherheits-Review ohne Bauchschmerzen vorlegen kann.