Produkty Poradenství O nás Blog Kontakt English
arrow_back Zpět na blog

Zabezpečení GraphQL API s Netflix DGS

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:

Java
@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:

Java
@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:

Java
@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:

Java
@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é:

Java
// 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:

graph LR subgraph Public A["products()"] B["product(id)"] end subgraph Authenticated C["myOrders()"] D["updateProduct()"] end subgraph Admin Only E["adminOrders()"] F["deleteUser()"] end style A fill:#00bfa5,stroke:#00897b,color:#fff style B fill:#00bfa5,stroke:#00897b,color:#fff style C fill:#4a9eff,stroke:#2171c7,color:#fff style D fill:#4a9eff,stroke:#2171c7,color:#fff style E fill:#ff7043,stroke:#e64a19,color:#fff style F fill:#ff7043,stroke:#e64a19,color:#fff

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:

JSON
{
    "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:

Java
@Configuration
public class GraphQLConfig {

    @Bean
    public DataFetcherExceptionHandler dataFetcherExceptionHandler() {
        return new SafeExceptionHandler();
    }
}

Handler klasifikuje výjimky a vrací sanitizované zprávy:

flowchart TD E["Exception thrown"] --> H["SafeExceptionHandler"] H --> LOG["Log full error server-side"] H --> CLS{"Classify exception"} CLS -->|DomainValidation| S1["BAD_USER_INPUT
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
Java
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ýjimkyChybový kódZpráva pro klientaÚroveň logování
DomainValidationExceptionBAD_USER_INPUTPůvodní zpráva (bezpečná z principu)INFO
IllegalArgumentExceptionBAD_USER_INPUT“Invalid input provided”INFO
AccessDeniedExceptionFORBIDDEN“Access denied”WARN
*NotFoundExceptionNOT_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.
Java
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é:

Java
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:

GRAPHQL
# 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í:

Java
@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:

Java
@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ášť:

YAML
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:

KontrolaPro dotazyPro mutace
Anotace @PreAuthorizePokud je uživatelsky specifickýVždy
User ID z autentizačního kontextu (ne ze vstupu)Pokud filtrujete podle vlastníkaVždy
Ověření vlastnictvíPokud vracíte soukromá dataPokud modifikujete vlastněné zdroje
Validace vstupůPro vyhledávací/filtrační parametryPro všechny vstupy
Chybové zprávy neprozrazují interní detailyPřes exception handlerPřes exception handler
Strukturované logování pro audit trailVolitelné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.


Titulní foto od FlyD na Unsplash.

Další z blogu