Zabezpečení GraphQL API s Netflix DGS

Třetí díl ze sedmi v sérii “Production GraphQL with Netflix DGS”
GraphQL dává klientům mimořádnou flexibilitu — sami si volí, která pole načíst, jak hluboko zanořit dotaz a kolik operací poslat naráz. Tato flexibilita je zároveň vaším útočným povrchem. Tento článek pokrývá tři vrstvy obrany, které potřebuje každé produkční GraphQL API: autentizaci a autorizaci, sanitizaci chyb a ochranu proti zneužití dotazů.
Vrstva 1: Autentizace a autorizace
DGS běží uvnitř Spring Bootu, takže máte k dispozici kompletní toolkit Spring Security. Nejpraktičtějším vzorem jsou anotace @PreAuthorize přímo na DGS metodách.
Ochrana mutací
Každá mutace, která mění data, by měla mít explicitní autorizační anotaci:
@DgsComponent
@RequiredArgsConstructor
public class CreateProductMutation {
private final ProductService productService;
private final CurrentUserProvider currentUser;
@DgsMutation
@PreAuthorize("hasAnyRole('MERCHANT', 'ADMIN')")
public Mono<Product> createProduct(@InputArgument CreateProductInput input) {
return currentUser.getUserId().flatMap(userId -> {
return productService.create(input, userId);
});
}
}Klíčové vzory:
@PreAuthorize("isAuthenticated()")— volat může jakýkoli přihlášený uživatel.@PreAuthorize("hasRole('ADMIN')")— pouze administrátoři.@PreAuthorize("hasAnyRole('MERCHANT', 'ADMIN')")— více povolených rolí.
Tyto anotace se vyhodnocují před spuštěním těla metody. Pokud kontrola selže, Spring vyhodí AccessDeniedException dříve, než se spustí jakákoli byznys logika.
Získání aktuálního uživatele
Váš dodavatel autentizace (ať už ho implementujete jakkoli — z JWT claims, session nebo OAuth) by měl být injektován jako Spring bean:
@DgsMutation
@PreAuthorize("isAuthenticated()")
public Mono<Product> updateProduct(@InputArgument UpdateProductInput input) {
return currentUser.getUserId().flatMap(userId -> {
// userId comes from the verified JWT — never from client input
return productService.update(input, userId);
});
}Zásadní pravidlo: nikdy nedůvěřujte identitě uživatele z klientského vstupu. User ID by mělo vždy pocházet z ověřeného autentizačního kontextu (JWT claims, session), nikoli z GraphQL argumentu. Přijímání argumentu userId otevírá dveře útokům typu IDOR (Insecure Direct Object Reference).
Kontrola vlastnictví
U operací nad zdroji, které patří uživateli, ověřte vlastnictví před provedením akce:
@DgsMutation
@PreAuthorize("isAuthenticated()")
public Mono<Order> cancelOrder(@InputArgument String orderId) {
return currentUser.getUserId().flatMap(userId -> {
return orderService.findById(orderId)
.switchIfEmpty(Mono.error(new NotFoundException("Order not found")))
.flatMap(order -> {
if (!userId.equals(order.getOwnerId())) {
return Mono.error(new AccessDeniedException("Not authorized"));
}
return orderService.cancel(orderId);
});
});
}Pro znovupoužitelné kontroly vlastnictví můžete vytvořit službu a odkazovat se na ni ve SpEL výrazech:
@DgsMutation
@PreAuthorize("hasRole('ADMIN') or @ownershipService.ownsOrder(#input.id, authentication.name)")
public Mono<Order> updateOrder(@InputArgument UpdateOrderInput input) {
// ...
}Veřejné vs. autentizované dotazy
Ne všechno vyžaduje autentizaci. Vyhledávání, procházení katalogu a zobrazení detailu jsou často veřejné:
// Public — no auth required
@DgsQuery
public Mono<ProductPage> products(@InputArgument String searchText, ...) {
return productService.search(searchText, ...);
}
// Authenticated — returns user-specific data
@DgsQuery
@PreAuthorize("isAuthenticated()")
public Mono<OrderPage> myOrders(@InputArgument Integer pageNumber, ...) {
return currentUser.getUserId().flatMap(userId -> {
return orderService.findByOwner(userId, pageNumber, ...);
});
}
// Admin-only — exposes all data with optional owner filtering
@DgsQuery
@PreAuthorize("hasRole('ADMIN')")
public Mono<OrderPage> adminOrders(
@InputArgument String ownerId,
@InputArgument Integer pageNumber, ...) {
return orderService.findAll(ownerId, pageNumber, ...);
}Tento tříúrovňový vzor je praktický způsob, jak organizovat řízení přístupu s rostoucím API:
Vrstva 2: Sanitizace chyb
GraphQL chyby se vrací v těle odpovědi, nikoli jako HTTP stavové kódy. Ve výchozím nastavení putují zprávy výjimek přímo ke klientovi. To představuje značné riziko úniku informací — stack traces, názvy tříd a interní detaily chyb mohou uniknout k útočníkům.
Problém
Bez sanitizace může klient obdržet:
{
"errors": [{
"message": "org.hibernate.ObjectDeletedException: deleted instance passed to merge: [com.example.internal.model.OrderEntity#42]",
"path": ["order"],
"extensions": {
"classification": "DataFetchingException"
}
}]
}Tohle prozrazuje vaši databázovou technologii, interní datový model a chování transakcí. Útočník teď přesně ví, kam se zaměřit.
Řešení: DataFetcherExceptionHandler
DGS umožňuje zaregistrovat vlastní exception handler, který zachytí všechny chyby před tím, než dorazí ke klientovi:
@Configuration
public class GraphQLConfig {
@Bean
public DataFetcherExceptionHandler dataFetcherExceptionHandler() {
return new SafeExceptionHandler();
}
}Handler klasifikuje výjimky a vrací sanitizované zprávy:
Original message"] CLS -->|AccessDenied| S2["FORBIDDEN
'Access denied'"] CLS -->|NotFound| S3["NOT_FOUND
'Resource not found'"] CLS -->|Everything else| S4["INTERNAL_SERVER_ERROR
'An error occurred'"] S1 --> R["Sanitized GraphQL error response"] S2 --> R S3 --> R S4 --> R style E fill:#ff7043,stroke:#e64a19,color:#fff style H fill:#7c4dff,stroke:#5e35b1,color:#fff style R fill:#00bfa5,stroke:#00897b,color:#fff
public class SafeExceptionHandler implements DataFetcherExceptionHandler {
@Override
public CompletableFuture<DataFetcherExceptionHandlerResult> handleException(
DataFetcherExceptionHandlerParameters params) {
Throwable exception = params.getException();
String path = params.getPath().toString();
// Log the full error for debugging (server-side only)
logException(exception, path);
// Return a sanitized error to the client
String message = sanitize(exception);
String code = classify(exception);
GraphQLError error = GraphqlErrorBuilder.newError()
.message(message)
.path(params.getPath())
.location(params.getSourceLocation())
.extensions(Map.of("code", code))
.build();
return CompletableFuture.completedFuture(
DataFetcherExceptionHandlerResult.newResult().error(error).build());
}
}Strategie klasifikace
Namapujte typy výjimek na Apollo-kompatibilní chybové kódy. Tyto kódy se zobrazují v monitorovacích nástrojích a pomáhají kategorizovat chyby bez odhalování vnitřních detailů:
| Typ výjimky | Chybový kód | Zpráva pro klienta | Úroveň logování |
|---|---|---|---|
DomainValidationException | BAD_USER_INPUT | Původní zpráva (bezpečná z principu) | INFO |
IllegalArgumentException | BAD_USER_INPUT | “Invalid input provided” | INFO |
AccessDeniedException | FORBIDDEN | “Access denied” | WARN |
*NotFoundException | NOT_FOUND | “Resource not found” | INFO |
| Vše ostatní | INTERNAL_SERVER_ERROR | “An error occurred” | ERROR (se stack trace) |
Klíčový poznatek: validační chyby si ponechají svou původní zprávu (protože zprávy DomainValidationException jsou navrženy tak, aby byly zobrazitelné uživateli), zatímco neočekávané chyby dostanou generickou zprávu. Úplné detaily se vždy zalogují na straně serveru.
Na úrovních logování záleží
Ne každá výjimka je chyba:
- INFO — validační selhání, not-found odpovědi. To je očekávané chování. Logování na úrovni ERROR by způsobilo únavu z alertů.
- WARN — pokusy o neoprávněný přístup. Zaslouží si vyšetření, ale nejsou chybami ve vašem kódu.
- ERROR (s úplným stack trace) — neočekávané výjimky. To jsou bugy, které je třeba opravit.
private void logException(Throwable exception, String path) {
if (exception instanceof DomainValidationException) {
log.info("Validation error", kv("path", path), kv("message", exception.getMessage()));
} else if (exception instanceof AccessDeniedException) {
log.warn("SECURITY: Access denied", kv("path", path), kv("userId", getCurrentUserId()));
} else {
log.error("Unexpected error", kv("path", path), kv("errorType",
exception.getClass().getSimpleName()), exception);
}
}Allowlisting bezpečných zpráv
Některé byznys zprávy jsou určeny k zobrazení uživatelům (“Registration is closed”, “Limit reached”). Místo blokování všech zpráv můžete vytvořit allowlist vzorů, které jsou bezpečné:
private boolean isUserFacingMessage(String message) {
if (message == null) return false;
String lower = message.toLowerCase();
// Block messages that reveal internals
if (lower.contains("exception") || lower.contains("stacktrace")
|| lower.contains("null pointer") || lower.contains("class ")) {
return false;
}
// Allow known safe business messages
return lower.contains("already exists")
|| lower.contains("not found")
|| lower.contains("limit reached")
|| lower.contains("expired")
|| lower.contains("registration is closed");
}Jedná se o přístup blocklist-pak-allowlist: nejprve odmítneme cokoli, co vypadá jako implementační detail, a poté propustíme známé byznys zprávy.
Vrstva 3: Ochrana proti zneužití dotazů
Flexibilita GraphQL znamená, že jediný dotaz může být libovolně složitý. Bez limitů si útočník dokáže sestavit dotazy, které spotřebují nepřiměřené množství serverových zdrojů.
Omezení hloubky
Hluboce zanořený dotaz může způsobit exponenciální nárůst práce:
# Malicious query — each level multiplies the work
query {
users {
orders {
products {
reviews {
author {
orders {
products { ... } # Keep going...
}
}
}
}
}
}
}graphql-java poskytuje MaxQueryDepthInstrumentation pro odmítnutí dotazů, které překročí konfigurovatelnou úroveň zanoření:
@Configuration
public class GraphQLSecurityConfig {
@Value("${graphql.security.max-depth}")
private int maxQueryDepth;
@Bean
public Instrumentation maxQueryDepthInstrumentation() {
return new MaxQueryDepthInstrumentation(maxQueryDepth);
}
}Když dotaz překročí limit, klient obdrží chybu ještě před zahájením jakéhokoli načítání dat. Správná hodnota závisí na vašem schématu — zkontrolujte, jaké hloubky vaše legitimní dotazy skutečně dosahují (u typických webových aplikací obvykle 5-8 úrovní), a pak přidejte malou rezervu.
Omezení složitosti
Samotná hloubka nezachycuje celkovou náročnost. Mělký, ale široký dotaz (načítající mnoho seznamových polí) může být stejně drahý. MaxQueryComplexityInstrumentation přiřazuje polím náklady a odmítá dotazy, které překročí stanovený rozpočet:
@Value("${graphql.security.max-complexity}")
private int maxQueryComplexity;
@Bean
public Instrumentation maxQueryComplexityInstrumentation() {
return new MaxQueryComplexityInstrumentation(maxQueryComplexity);
}Výchozí bodování přiřazuje 1 bod za pole. Seznamová pole násobí odhadovanou velikostí seznamu. Bodování můžete přizpůsobit pomocí vah pro nákladné operace, ale výchozí hodnoty jsou rozumným výchozím bodem.
Konfigurovatelnost limitů
Oba limity by měly být externalizovány do konfigurace (ne natvrdo v kódu), abyste je mohli ladit pro každé prostředí zvlášť:
graphql:
security:
max-depth: ${GRAPHQL_MAX_DEPTH}
max-complexity: ${GRAPHQL_MAX_COMPLEXITY}To vám umožní provozovat přísnější limity v produkci a volnější ve vývojovém prostředí (kde jsou běžné hluboké introspekční dotazy). Nastavte hodnoty podle svých reálných dotazovacích vzorů — univerzálně správná odpověď neexistuje.
Bezpečnostní checklist
Při přidávání nové DGS operace projděte tento checklist:
| Kontrola | Pro dotazy | Pro mutace |
|---|---|---|
Anotace @PreAuthorize | Pokud je uživatelsky specifický | Vždy |
| User ID z autentizačního kontextu (ne ze vstupu) | Pokud filtrujete podle vlastníka | Vždy |
| Ověření vlastnictví | Pokud vracíte soukromá data | Pokud modifikujete vlastněné zdroje |
| Validace vstupů | Pro vyhledávací/filtrační parametry | Pro všechny vstupy |
| Chybové zprávy neprozrazují interní detaily | Přes exception handler | Přes exception handler |
| Strukturované logování pro audit trail | Volitelné | Povinné |
Co dál
Vaše API je nyní zabezpečeno proti neoprávněnému přístupu, úniku informací a zneužití dotazů. Ve čtvrtém díle prozkoumáme real-time schopnosti DGS — WebSocket subscriptions a reaktivní vzory — které vašemu API umožní posílat klientům aktualizace v okamžiku, kdy nastanou.


