Sicherheit bei schreibenden Operationen
Ein öffentlich erreichbarer GraphQL-Endpunkt, der beliebige Mutationen akzeptiert, ist ein offenes Einfallstor für Datenmanipulation, Spam und automatisierte Angriffe. GraphQL Persisted Mutations schließen diese Lücke, indem nur eine serverseitig vorab genehmigte Liste von Operationen überhaupt ausgeführt werden darf, alles andere wird abgelehnt, bevor auch nur ein Resolver aufgerufen wird.
Inhaltsverzeichnis
- 1. Das Sicherheitsproblem schreibender GraphQL-Operationen
- 2. Persisted Queries vs. Persisted Mutations
- 3. Hash-basierte Operation-IDs: Funktionsweise
- 4. Server-seitige Allowlist implementieren
- 5. Build-Zeit-Generierung des Operations-Manifests
- 6. Schutz vor Mutation-Injection und Batching-Angriffen
- 7. Rate-Limiting und Autorisierung kombinieren
- 8. CI/CD-Workflow: Manifest deployen
- 9. Schutzmechanismen im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Sicherheitsproblem schreibender GraphQL-Operationen
Lesende GraphQL-Operationen sind riskant, weil sie Daten offenlegen können, schreibende Operationen sind riskant, weil sie den Zustand des Systems verändern. Ein öffentlicher GraphQL-Endpunkt mit freier Mutation-Ausführung erlaubt jedem, der die Schema-Struktur kennt oder über Introspection erschließt, beliebige Mutationen mit beliebigen Argumenten abzusetzen. GraphQL Persisted Mutations begegnen genau diesem Problem, indem der Client keine freie Query-Syntax mehr an den Server sendet, sondern nur noch einen Hash, der auf eine serverseitig registrierte, exakt geprüfte Operation verweist.
Der Unterschied zu klassischer Input-Validierung ist fundamental: Input-Validierung prüft die Werte innerhalb einer Mutation, verhindert aber nicht, dass überhaupt eine unbekannte oder böswillig konstruierte Mutation ausgeführt wird. GraphQL Persisted Mutations setzen eine Ebene früher an, sie verhindern die Ausführung jeder Operation, die nicht exakt einer vom eigenen Frontend-Build stammenden, freigegebenen Mutation entspricht. Ein Angreifer, der versucht, eine neue, im Schema technisch mögliche, aber nie im Frontend verwendete Mutation abzusetzen, etwa deleteAllOrders ohne Berechtigungsprüfung im Resolver, scheitert bereits am fehlenden Hash in der Allowlist.
Besonders kritisch ist dieses Muster bei GraphQL-APIs, die von mobilen Apps oder Single-Page-Applications öffentlich erreichbar sind, dort lässt sich der API-Key oder das Auth-Token durch Reverse Engineering extrahieren, die Query-Struktur selbst bleibt aber ohne GraphQL Persisted Mutations vollständig frei formulierbar.
2. Persisted Queries vs. Persisted Mutations
Persisted Queries sind in der GraphQL-Community bereits etabliert, primär als Performance-Optimierung, die Query-Größe im Request auf einen kurzen Hash reduziert. GraphQL Persisted Mutations nutzen dasselbe technische Muster, verfolgen aber ein anderes Primärziel: nicht Bandbreite sparen, sondern Angriffsfläche minimieren. Der Unterschied zeigt sich in der Fehlerbehandlung: Bei reinen Persisted Queries ist ein unbekannter Hash meist ein harmloser Fehler, das Frontend sendet einfach die volle Query nach. Bei Persisted Mutations muss ein unbekannter Hash zwingend zur vollständigen Ablehnung führen, ein Fallback auf freie Mutation-Syntax würde die gesamte Schutzwirkung aufheben.
Diese Unterscheidung ist der Grund, warum viele Teams Queries und Mutations unterschiedlich behandeln: Persisted Queries mit Fallback für bessere Performance, Persisted Mutations ohne Fallback als harte Sicherheitsgrenze. Wer GraphQL Persisted Mutations einführt, sollte diesen Unterschied explizit im Server-Code abbilden, statt beide Operationstypen über denselben Mechanismus zu behandeln.
3. Hash-basierte Operation-IDs: Funktionsweise
Der Kern von GraphQL Persisted Mutations ist ein deterministischer Hash über den normalisierten Operations-Text, üblicherweise SHA-256. Beim Build des Frontends wird jede verwendete Mutation extrahiert, ihr Hash berechnet und in einem Manifest gespeichert. Zur Laufzeit sendet der Client statt der vollständigen Mutation nur noch diesen Hash zusammen mit den Variablen, der Server schlägt im Manifest nach, findet die zugehörige, vorab geprüfte Operation und führt ausschließlich diese aus.
// Build-time: compute a stable hash for each mutation document
import { createHash } from "node:crypto";
function hashOperation(source) {
// Normalize whitespace before hashing so formatting changes
// don't produce a different hash for the same operation
const normalized = source.replace(/\s+/g, " ").trim();
return createHash("sha256").update(normalized).digest("hex");
}
const updateCustomerAddress = `
mutation UpdateCustomerAddress($id: ID!, $input: AddressInput!) {
updateCustomerAddress(id: $id, input: $input) {
id
street
city
}
}
`;
console.log(hashOperation(updateCustomerAddress));
// e.g. "a3f1c9..." — this hash becomes the operation's public identifier
Weil der Hash über den Operations-Text berechnet wird, führt jede Änderung an der Mutation, ein neues Feld, ein anderes Argument, automatisch zu einem neuen Hash. Das erzwingt einen expliziten Build- und Deployment-Schritt für jede Schema-Änderung im Frontend, was GraphQL Persisted Mutations zusätzlich vor versehentlichem Client-Server-Schema-Drift schützt.
4. Server-seitige Allowlist implementieren
Die serverseitige Umsetzung besteht aus einer einfachen, aber strikten Lookup-Logik: Trifft ein Request mit einem Operation-Hash ein, der nicht in der Allowlist steht, wird die Anfrage sofort mit einem Fehler abgelehnt, ohne dass der GraphQL-Executor überhaupt aufgerufen wird. Das folgende Beispiel zeigt eine Middleware für Apollo Server, die genau das umsetzt.
// Apollo Server plugin enforcing a strict mutation allowlist
import { readFileSync } from "node:fs";
const manifest = JSON.parse(readFileSync("./persisted-operations.json", "utf-8"));
const persistedMutationsPlugin = {
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const isMutation = document.definitions.some(
(def) => def.kind === "OperationDefinition" && def.operation === "mutation"
);
if (!isMutation) return; // queries handled by separate, less strict policy
const hash = request.extensions?.persistedQuery?.sha256Hash;
if (!hash || !manifest[hash]) {
throw new Error("Mutation rejected: not present in persisted operations allowlist");
}
},
};
},
};
Entscheidend ist, dass diese Prüfung vor der eigentlichen Ausführung greift, nicht erst im Resolver. Würde die Prüfung erst im Resolver stattfinden, hätte der Server bereits Zeit und Ressourcen für Parsing und Validierung einer potenziell böswilligen Operation aufgewendet, ein Denial-of-Service-Vektor, den GraphQL Persisted Mutations durch die frühe Ablehnung gezielt vermeiden.
5. Build-Zeit-Generierung des Operations-Manifests
Das Manifest, eine einfache JSON-Datei, die Hashes auf Operations-Texte abbildet, entsteht automatisiert im Build-Prozess des Frontends, meist über ein Babel-Plugin oder einen ESBuild-Loader, der alle gql-Tagged-Templates im Quellcode einsammelt. Kein Entwickler pflegt dieses Manifest manuell, jede neue Mutation im Code erscheint automatisch beim nächsten Build.
{
"a3f1c9d2e8b47600...": {
"operationName": "UpdateCustomerAddress",
"type": "mutation",
"source": "mutation UpdateCustomerAddress($id: ID!, $input: AddressInput!) { ... }"
},
"7be2f0a91c3d5642...": {
"operationName": "AddToCart",
"type": "mutation",
"source": "mutation AddToCart($sku: String!, $qty: Int!) { ... }"
}
}
Dieses generierte Manifest wird als eigenständiges Artefakt im CI-Prozess veröffentlicht und muss vor dem Frontend-Deployment auf dem GraphQL-Server verfügbar sein, sonst würde ein neu deploytes Frontend Mutationen senden, die der Server noch nicht kennt. Die Reihenfolge im Deployment, erst Server-Manifest, dann Frontend, ist bei GraphQL Persisted Mutations keine Option, sondern eine harte Voraussetzung.
6. Schutz vor Mutation-Injection und Batching-Angriffen
Ohne Allowlist kann ein Angreifer, der Zugriff auf den Endpunkt und ein gültiges Token hat, beliebige im Schema technisch erlaubte Mutationen konstruieren, auch solche, die nie für den öffentlichen Zugriff vorgesehen waren, etwa administrative Mutationen, die aus Versehen nicht mit einer zusätzlichen Rollenprüfung abgesichert wurden. GraphQL Persisted Mutations verhindern das strukturell: Selbst eine im Schema vorhandene, aber nie im Frontend genutzte Mutation kann nicht ausgeführt werden, weil ihr Hash niemals im Manifest landet.
Ein zweiter Angriffsvektor ist Query-Batching, bei dem ein einzelner HTTP-Request ein Array vieler Operationen enthält, um Rate-Limits zu umgehen, die pro Request statt pro Operation zählen. Persisted Mutations mindern dieses Risiko zusätzlich, weil jede Operation im Batch einzeln gegen die Allowlist geprüft wird und die Anzahl möglicher Mutationstypen durch die begrenzte Manifest-Größe klar begrenzt bleibt, ein Angreifer kann nicht beliebig neue Mutationsvarianten erfinden, um Erkennungsmuster zu umgehen.
7. Rate-Limiting und Autorisierung kombinieren
GraphQL Persisted Mutations sind kein Ersatz für Autorisierung, sondern eine zusätzliche Schutzschicht davor. Ein Nutzer mit gültigem Token kann weiterhin nur die für ihn erlaubten Mutationen mit seinen eigenen Daten ausführen, das muss weiterhin im Resolver geprüft werden. Kombiniert mit Rate-Limiting pro Operation-Hash lassen sich zusätzlich Missbrauchsmuster erkennen, etwa ungewöhnlich viele AddToCart-Aufrufe von einer einzelnen IP-Adresse in kurzer Zeit.
// Combine persisted mutation allowlist with per-operation rate limiting
import { RateLimiterMemory } from "rate-limiter-flexible";
const limiters = new Map();
function getLimiterFor(operationHash) {
if (!limiters.has(operationHash)) {
limiters.set(operationHash, new RateLimiterMemory({ points: 20, duration: 60 }));
}
return limiters.get(operationHash);
}
async function checkMutationRateLimit(operationHash, clientId) {
const limiter = getLimiterFor(operationHash);
await limiter.consume(clientId); // throws when the limit is exceeded
}
Diese Kombination aus Allowlist, Autorisierung und Rate-Limiting pro Hash bildet die vollständige Verteidigungslinie: GraphQL Persisted Mutations beschränken, welche Operationen überhaupt möglich sind, Autorisierung beschränkt, wer sie mit welchen Daten ausführen darf, und Rate-Limiting beschränkt, wie oft.
8. CI/CD-Workflow: Manifest deployen
Der vollständige Deployment-Workflow für GraphQL Persisted Mutations besteht aus vier automatisierten Schritten, die sich problemlos in eine bestehende CI-Pipeline integrieren lassen.
# .github/workflows/deploy-persisted-operations.yml
name: Deploy Persisted Operations Manifest
on:
push:
branches: [main]
jobs:
publish-manifest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract operations and build manifest
run: npm run generate:persisted-manifest
- name: Upload manifest to GraphQL server storage
run: |
curl -X PUT "$GRAPHQL_SERVER_URL/admin/persisted-operations" \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @dist/persisted-operations.json
# Server must confirm the manifest is active before frontend deploy proceeds
- name: Verify manifest is active
run: npm run verify:manifest-active
Der letzte Schritt, die Verifizierung, ist kein optionaler Komfort, sondern verhindert eine Race Condition: Ohne diese Prüfung könnte das Frontend-Deployment starten, bevor der Server das neue Manifest tatsächlich geladen hat, was zu fehlschlagenden Mutationen für alle Nutzer während des Rollouts führen würde.
9. Schutzmechanismen im Vergleich
Die folgende Tabelle ordnet Persisted Mutations gegenüber verwandten Schutzmechanismen ein.
| Mechanismus | Schützt vor | Verhindert unbekannte Mutationen? |
|---|---|---|
| Kein Schutz | Nichts | Nein |
| Persisted Queries (mit Fallback) | Bandbreite, nicht Sicherheit | Nein |
| Automatic Persisted Queries (APQ) | Payload-Größe, Caching | Nein |
| Query Complexity Limits | Ressourcenerschöpfung bei Queries | Nein |
| GraphQL Persisted Mutations | Mutation-Injection, ungeprüfte Schreibzugriffe | Ja, strikt ohne Fallback |
Mironsoft
GraphQL-Sicherheit, API-Härtung und Schreibzugriffs-Schutz
Schreibende GraphQL-Operationen wirklich absichern?
Wir implementieren Persisted Mutations für euren GraphQL-Endpunkt, inklusive Build-Pipeline, Server-Allowlist und kombiniertem Rate-Limiting, damit nur eure eigenen Frontends schreiben können.
Security-Audit
Bestehende GraphQL-Mutationen auf ungeschützte Schreibzugriffe prüfen
Persisted-Mutations-Setup
Manifest-Generierung, Server-Allowlist und CI-Deployment einrichten
Rate-Limiting
Missbrauchserkennung pro Operation und pro Client aufbauen
10. Zusammenfassung
GraphQL Persisted Mutations verschieben die Sicherheitsentscheidung von "welche Werte sind gültig" zu "welche Operation darf überhaupt ausgeführt werden". Über einen deterministischen Hash pro Mutation und eine strikte, fallback-freie serverseitige Allowlist wird jede Operation abgelehnt, die nicht exakt aus dem eigenen, kontrollierten Frontend-Build stammt. Das schließt Mutation-Injection, versehentlich ungeschützte administrative Mutationen und einen großen Teil automatisierter Missbrauchsversuche, noch bevor der GraphQL-Executor überhaupt aktiv wird.
Entscheidend für den produktiven Einsatz ist die saubere Trennung zwischen Persisted Queries mit Fallback für Performance und Persisted Mutations ohne Fallback für Sicherheit, sowie ein verlässlicher CI/CD-Workflow, der das Server-Manifest immer vor dem zugehörigen Frontend-Deployment aktiviert. In Kombination mit klassischer Autorisierung und Rate-Limiting pro Operation-Hash entsteht so eine mehrschichtige Verteidigung, die deutlich robuster ist als Input-Validierung allein.
GraphQL Persisted Mutations — Das Wichtigste auf einen Blick
Kein Fallback
Anders als bei Persisted Queries darf ein unbekannter Hash bei Mutations niemals zur freien Ausführung führen.
Build-Zeit-Manifest
Hashes werden automatisiert aus dem Frontend-Build extrahiert, kein manuelles Pflegen einer Liste.
Deployment-Reihenfolge
Server-Manifest muss vor dem Frontend live sein, sonst schlagen Mutationen während des Rollouts fehl.
Zusätzliche Schicht
Ersetzt keine Autorisierung, ergänzt sie um eine strukturelle Grenze gegen unbekannte Operationen.