Автоматизация тестирования безопасности GraphQL: Playwright и Application Security, BRUNOVSKI.COM

1. Введение (Deep Context)

Почему GraphQL стал стандартом в финтехе и Enterprise

GraphQL в 2026 — это не “модная штука из фронтенда”. В Enterprise и финтехе его выбирают из-за банальной экономики:

  • Контракт “по потребности”: мобильные клиенты не тащат лишние поля, фронтенд перестаёт упрашивать бэкенд “добавьте ещё один эндпоинт”.
  • Эволюция API без версионирования через ад: можно добавлять поля, не ломая клиентов.
  • Единая точка входа: наблюдаемость, трассировка, авторизация и лимиты *в теории* проще централизовать.
  • Data-graph поверх микросервисов: BFF/federation, когда GraphQL слой агрегирует данные из 5–30 внутренних сервисов и выдаёт “единую схему”.

Ирония в том, что те же преимущества превращаются в концентратор риска.

Главная проблема безопасности GraphQL: “один endpoint” — это не упрощение, это концентрация атаки

В REST модель угроз распределена по URL/ресурсам: разные маршруты, разные middleware, разные политики доступа. В GraphQL:

  • Один URL (/graphql) и зачастую один метод (POST) для всего.
  • Форма запроса под контролем клиента: глубина, алиасы, батчи, фрагменты.
  • Семантика вместо URL: безопасность живёт в *resolvers*, *context*, *directives*, *field policies* и в том, как вы склеили источники данных.

Если один резолвер дырявый — атакующий упакует атаку в тот же endpoint, по которому ходит легитимная мобилка. И это будет выглядеть “как нормальный GraphQL запрос” в логах.

Архитектура Security: REST vs GraphQL (таблица)

Параметр REST GraphQL
Точки входа Много URL/ресурсов Обычно один endpoint (/graphql)
Метод защиты URL + middleware + RBAC на маршруте Resolver/context/directives + field policy
Видимость API Swagger/OpenAPI часто обязателен Introspection может раскрыть всё
Сложность DoS атак Чаще очевидно (дорогие эндпоинты) Может расти экспоненциально (aliases, depth, batching, N+1)

Почему стандартные DAST-сканеры часто “тупят” на GraphQL

Я не хейчу DAST. Я хейчу ожидания, что DAST заменит мозг.

ZAP/Burp (и коммерческие аналоги) полезны для широкого покрытия, но GraphQL ломает классический подход:

  1. Схема ≠ Swagger. В REST есть OpenAPI. В GraphQL схему надо получить, актуализировать и учитывать роли. Интроспекция в проде чаще выключена (и правильно).
  1. Состояние и контекст. Реальная безопасность — это роли, тенанты, реальные данные. DAST без сценариев живёт в вакууме.
  1. Семантические баги. BOLA/IDOR и бизнес-обходы не ловятся сигнатурами. Сканер не знает, что “пользователь A не должен менять email пользователя B”.

Вывод: GraphQL Security Testing в 2026 — это Shift Left security: security checks как тесты в CI, которые фиксируют регресс.


2. Почему Playwright? (The Game Changer)

Playwright в 2026 — это не только UI. Для Application Security и QA automation он удобен, потому что позволяет тестировать GraphQL “как клиент”, но с контролем контекста и хорошей интеграцией в CI.

Преимущества Playwright для Security

  • Контексты и изоляция: несколько сессий (user A / user B / admin) параллельно, без гонок cookies и localStorage.
  • Request interception / tracing: можно перехватывать GraphQL payload’ы, логировать operationName, добавлять корреляцию к trace-id.
  • Скорость в CI: request API Playwright даёт быстрые API security tests без браузера.
  • Единый стек: если у команды Playwright для e2e, security automation встраивается рядом с регрессией.

QA vs Application Security Engineer: смена стека (таблица)

Параметр Senior QA / SDET Application Security Engineer (из QA)
Упор Functional/Regression Security: злоупотребления, границы доверия, Threat Modeling
Инструменты Playwright / API tests / CI Playwright Security Automation + Burp Suite Pro/ZAP + SAST/DAST/IAST
Ценник на рынке Хороший, но конкуренция высокая Выше за счёт редкости и ответственности (особенно финтех)
Ответственность за баг “починили — поехали” Инциденты, регуляторка, деньги; “починили” должно быть доказано тестами

Почему Playwright лучше Postman и “только сканера” именно для GraphQL

Postman хорош для ручной диагностики и небольших коллекций. Но если вы начинаете строить security regression, Postman быстро превращается в сценарный язык без нормальной архитектуры. DAST-сканеры дают coverage, но плохо ловят семантику и ownership.

Playwright выигрывает тем, что:

  • даёт вам нормальный язык (TypeScript),
  • легко делает “две идентичности” (A/B),
  • легко встроить в GitHub Actions,
  • легко прикладывать артефакты, которые не игнорируют разработчики.

3. Пять уязвимостей: ручной поиск и автоматизация (глубокий разбор)

Ниже — 5 категорий, которые я вижу чаще всего. Для каждой: где ломается современный стек (resolvers/context/directives), уязвимый код (SAST), payload, как автоматизировать, и короткое E‑E‑A‑T от первого лица.

> Важно: GraphQL часто возвращает HTTP 200 даже при ошибках. Ассертите errors/data, а не только статус.

3.1 Introspection & Schema Discovery

Где ломается стек (resolvers/context/directives)

Интроспекция “протекает” обычно в трёх местах:

  1. Конфиг сервера/gateway: introspection: true в проде “потому что удобно девам”.
  1. Context/authorization: вы проверяете роль на бизнес-резолверах, но не перекрываете системные поля (__schema, __type).
  1. Directives/policy: вы думаете, что директива @auth защищает всё, но системные поля живут вне вашего policy слоя.

Уязвимый резолвер/конфиг (Node.js/Apollo)

import { ApolloServer } from '@apollo/server';

// Уязвимость: introspection включена в проде без условий.
// Это не “удобство”. Это карта атаки.
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: true, // <-- в prod должно быть false (или только на admin network)
});

Payload (Introspection query)

query IntrospectionQuery {
  __schema {
    types {
      name
      fields { name }
    }
    mutationType {
      fields {
        name
        args { name type { name kind } }
      }
    }
  }
}

Как автоматизировать (security regression)

  • Выполнить introspection query как “обычный пользователь” (или неаутентифицированный).
  • Ожидать ошибку или data.__schema == null.
  • Если схема возвращается — тест фейлит сборку и сохраняет “обрезанный” фрагмент схемы (первые N типов), чтобы дев увидел масштаб утечки.

На практике: В финтехе я видел “частично отключённую интроспекцию”, где запретили __schema, но оставили __type(name:"User") “для дебага”. DAST это часто пропускает, потому что без знания типов и ролей он не понимает, что именно искать. А атакующий понимает — после первой утечки из мобилки/логов/доков.


3.2 BOLA/IDOR в мутациях (Broken Object Level Authorization)

Где ломается стек

BOLA в GraphQL — это чаще всего комбинация:

  • Resolver делает update по id, считая, что “токен есть → можно”.
  • Context содержит ctx.user, но ownership не проверяется.
  • Directives проверяют роль (RBAC), но не проверяют атрибуты объекта (ABAC): владение, tenant, статус.

Уязвимый резолвер (Node.js/Apollo)

export const resolvers = {
  Mutation: {
    updateUserEmail: async (_: unknown, args: { userId: string; email: string }, ctx: any) => {
      if (!ctx.user) throw new Error('UNAUTHENTICATED');

      // Уязвимость: нет check_owner(ctx.user.id, args.userId)
      // и нет tenant boundary (ctx.user.tenantId vs targetUser.tenantId).
      return ctx.db.user.update({
        where: { id: args.userId },
        data: { email: args.email },
      });
    },
  },
};

Payload (эксплуатация)

mutation UpdateUserEmail($userId: ID!, $email: String!) {
  updateUserEmail(userId: $userId, email: $email) {
    id
    email
  }
}

# variables:
# { "userId": "USER_B_ID", "email": "attacker+owned@example.com" }

Как автоматизировать (минимальная спецификация)

  • Два контекста: user A и user B (и желательно — два tenant’а).
  • Мутация токеном A с userId B.
  • Ассерт: errors содержит FORBIDDEN (или эквивалент) или data.updateUserEmail == null.
  • Post-check: убедиться, что email B не изменился (иначе вы ловите “частичный успех”).

На практике: BOLA переживает релизы. Вы можете переписать фронт, поменять gateway, перейти на federation — и BOLA останется, если ownership проверка не встроена в data layer. DAST почти всегда пропускает: сканер не умеет быть двумя пользователями и не знает, чей id “чужой”.


3.3 GraphQL Injection & Input Validation

Где ломается стек

Типизация GraphQL валидирует форму запроса. Но инъекции живут в резолверах, потому что:

  • резолвер строит SQL/NoSQL/Elastic запрос из строкового input,
  • резолвер прокидывает фильтры в downstream (Search/KYC/AML),
  • резолвер использует regex на пользовательских строках (ReDoS),
  • директивы “@auth” не спасают, потому что проблема не в доступе, а в Input Validation и обработке данных.

Уязвимый резолвер (Python/Graphene + сырая строка в SQL)

import graphene
from sqlalchemy import text

class Query(graphene.ObjectType):
    users = graphene.List(lambda: UserType, search=graphene.String(required=False))

    def resolve_users(root, info, search=None):
        # Уязвимость: строка search подставляется в SQL напрямую.
        q = f"SELECT id, email FROM users WHERE email LIKE '%{search or ''}%'"
        rows = info.context.db.execute(text(q)).fetchall()
        return [UserType(id=r[0], email=r[1]) for r in rows]

Payload (эксплуатация)

GraphQL не “ломается” кавычкой сам по себе. Но если резолвер потом делает SQL строкой — payload работает.

query SearchUsers($q: String) {
  users(search: $q) { id email }
}

# variables:
# { "q": "%' OR '1'='1" }

Как автоматизировать (чтобы не утонуть в ложных срабатываниях)

  • Не делайте “универсальный SQLi сканер” в GraphQL тестах. Это шум.
  • Делайте regression tests по конкретным резолверам:

- проверяйте отсутствие 500, - проверяйте предсказуемую ошибку валидации, - проверяйте, что результат не “внезапно расширился” (например, поиск не вернул всех пользователей).

На практике: Самый частый “инъекционный” инцидент в GraphQL, который я видел, — это не SQLi, а “умный поиск” (Elastic query_string), куда пробросили пользовательский input без экранирования. В итоге атакующий делает data exposure через DSL. DAST такое не ловит, потому что payload зависит от конкретного backend и резолвера. GraphQL — просто транспорт.


3.4 Batching attacks & Resource Exhaustion

Где ломается стек

  • Transport принимает массив операций (batching) без лимитов.
  • Execution не имеет complexity/depth анализа и body size limit.
  • Resolvers делают N+1 и дорого ходят в downstream без timeouts/circuit breakers.

Уязвимый “batch endpoint” (псевдокод)

app.post('/graphql', async (req, res) => {
  const body = req.body;
  const operations = Array.isArray(body) ? body : [body];

  // Уязвимость: operations.length может быть 1000.
  // Нет batch size limit, нет complexity limit, нет rate limiting по стоимости.
  const results = await Promise.all(
    operations.map(op => executeGraphQL(op.query, op.variables, buildContext(req)))
  );

  res.json(Array.isArray(body) ? results : results[0]);
});

Payload (batch)

[
  { "operationName": "GetBalance", "query": "query GetBalance { account { id balance } }", "variables": {} },
  { "operationName": "GetBalance", "query": "query GetBalance { account { id balance } }", "variables": {} }
]

Как автоматизировать (это не нагрузка)

  • Цель: доказать наличие предохранителей (Rate Limiting, batch size limit, complexity limit).
  • Ассерт: “злой” batch должен быть отклонён предсказуемо (400/429/GraphQL error code), а не выполняться и отдавать 200 через таймаут.

На практике: Я видел batching DoS, который выглядел как “всего 1 rps”, но каждый запрос был дорогим и убивал DB/CPU. Мониторинг видел нормальный трафик, но сервис горел. DAST не ловит, потому что у него нет задачи “проверить предохранители”. А вам она нужна.


3.5 Alias Overloading / Deep Nesting / Circular Queries

Где ломается стек

  • Execution: нет depth/complexity ограничений (или они выставлены “для галочки”).
  • Schema: циклические связи без защиты (User -> friends -> friends -> ...).
  • Resolvers: дорогие вызовы без caching/dataloader.

Уязвимый резолвер (дорогой downstream)

export const resolvers = {
  Query: {
    customerRiskScore: async (_: unknown, args: { customerId: string }, ctx: any) => {
      if (!ctx.user) throw new Error('UNAUTHENTICATED');

      // Уязвимость: можно дергать 50 раз через aliases в одном запросе.
      // Нет complexity guard и operation-level rate limiting.
      return ctx.riskService.getScore(args.customerId);
    },
  },
};

Payload (alias overloading)

query RiskBomb($id: ID!) {
  a1: customerRiskScore(customerId: $id)
  a2: customerRiskScore(customerId: $id)
  a3: customerRiskScore(customerId: $id)
  a4: customerRiskScore(customerId: $id)
  a5: customerRiskScore(customerId: $id)
  a6: customerRiskScore(customerId: $id)
  a7: customerRiskScore(customerId: $id)
  a8: customerRiskScore(customerId: $id)
  a9: customerRiskScore(customerId: $id)
  a10: customerRiskScore(customerId: $id)
}

Как автоматизировать

  • Завести фиксированный “evil query” (aliases или deep nesting) как regression test.
  • Ассерт: сервер должен отклонить запрос по лимиту сложности/глубины и не выполнять весь граф.

На практике: Alias overloading часто начинается не с атакующего, а с багнутого клиента. Поэтому complexity limits — это не только security, это reliability. DAST редко генерирует alias payload’ы, потому что ему нужна схема и понимание стоимости полей.


4. Практический код (TypeScript + Playwright)

Ниже — два примера. Они специально “приземлены”: HTTP-запрос на /graphql, ассерт на payload, минимум магии.

> Примечание: используем request API Playwright — это быстрее, чем браузер, и стабильнее для API security tests.

Скрипт 1: проверка Introspection Query

import { test, expect, request } from '@playwright/test';

const GRAPHQL_URL = process.env.GRAPHQL_URL ?? 'https://example.com/graphql';
const AUTH_TOKEN = process.env.AUTH_TOKEN ?? ''; // токен обычного пользователя (не админа)

const INTROSPECTION_QUERY = `
  query IntrospectionQuery {
    __schema {
      queryType { name }
      mutationType { name }
      types { name kind }
    }
  }
`;

test('GraphQL Security Testing: introspection must be disabled for non-admin', async () => {
  const api = await request.newContext({
    extraHTTPHeaders: AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {},
  });

  const res = await api.post(GRAPHQL_URL, {
    data: { operationName: 'IntrospectionQuery', query: INTROSPECTION_QUERY, variables: {} },
  });

  const body = await res.json().catch(() => null);
  expect(body).toBeTruthy();

  const hasSchema =
    body?.data?.__schema &&
    Array.isArray(body?.data?.__schema?.types) &&
    body.data.__schema.types.length > 0;

  expect(hasSchema).toBeFalsy();
  expect(Array.isArray(body?.errors) || body?.data?.__schema == null).toBeTruthy();
});

Скрипт 2: тест BOLA на updateUserEmail

import { test, expect, request } from '@playwright/test';

const GRAPHQL_URL = process.env.GRAPHQL_URL ?? 'https://example.com/graphql';
const TOKEN_USER_A = process.env.TOKEN_USER_A ?? '';
const USER_B_ID = process.env.USER_B_ID ?? '';

const MUTATION = `
  mutation UpdateUserEmail($userId: ID!, $email: String!) {
    updateUserEmail(userId: $userId, email: $email) {
      id
      email
    }
  }
`;

test('GraphQL Security Testing: BOLA must block updateUserEmail for чужой userId', async () => {
  expect(TOKEN_USER_A).not.toEqual('');
  expect(USER_B_ID).not.toEqual('');

  const api = await request.newContext({
    extraHTTPHeaders: { Authorization: `Bearer ${TOKEN_USER_A}` },
  });

  const attackerEmail = `bola-test+${Date.now()}@example.com`;

  const res = await api.post(GRAPHQL_URL, {
    data: {
      operationName: 'UpdateUserEmail',
      query: MUTATION,
      variables: { userId: USER_B_ID, email: attackerEmail },
    },
  });

  const body = await res.json().catch(() => null);
  expect(body).toBeTruthy();

  const updated = body?.data?.updateUserEmail;
  const hasErrors = Array.isArray(body?.errors) && body.errors.length > 0;

  expect(hasErrors || updated == null).toBeTruthy();

  if (updated) {
    throw new Error(
      `BOLA detected: updateUserEmail succeeded for чужой userId=${USER_B_ID}. Returned id=${updated?.id} email=${updated?.email}`
    );
  }
});

5. DevSecOps и CI/CD

Если тест не живёт в CI — он живёт в Confluence. То есть не живёт.

Как встроить эти тесты в GitHub Actions

Рекомендация:

  • pull_request: быстрый набор (introspection + критичные BOLA сценарии)
  • schedule: ночной прогон (batching/alias/depth + расширенные payload packs)
  • push main: контроль main
name: graphql-security-tests

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  security:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps
      - name: Run GraphQL security tests
        env:
          GRAPHQL_URL: ${{ secrets.GRAPHQL_URL }}
          AUTH_TOKEN: ${{ secrets.AUTH_TOKEN }}
          TOKEN_USER_A: ${{ secrets.TOKEN_USER_A }}
          USER_B_ID: ${{ secrets.USER_B_ID }}
        run: npx playwright test --grep \"GraphQL Security Testing\"
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

Как генерировать отчеты, которые не будут игнорировать разработчики

Security automation умирает от шума. Поэтому:

  1. Единый формат ошибок: operationName, role, targetId, expected, actual.
  1. Артефакты: payload + ответ (обрезанный) + trace-id если есть.
  1. Ownership: тэги и владельцы доменов (@payments, @profile) — иначе “не моя зона”.

6. FAQ (8–10 сложных вопросов + дополнительные “негуглящиеся”)

6.1 Как корректно выключать интроспекцию: gateway, schema, или per-field?

Надёжнее всего — на gateway и/или в сервере. Per-field часто оставляет утечки (__type) и подсказки. В финтехе “чуть-чуть интроспекции” — это всё равно карта атаки.

6.2 Почему GraphQL типизация не спасает от инъекций и где чаще всего ломаются резолверы?

Типизация валидирует форму запроса, но резолвер строит SQL/Elastic/DSL и ломается там. Чаще всего — search/filter/orderBy и “универсальные фильтры”.

6.3 Как построить complexity/cost analysis так, чтобы не убить легитимные мобильные клиенты?

Сначала измеряйте: логируйте стоимость по operationName. Потом вводите лимиты и исключения на конкретные операции, а не “всем одинаково”. И фиксируйте это контрактом.

6.4 Где лучше реализовать авторизацию: в резолверах, директивах, policy engine (OPA), или в data layer?

Ownership (BOLA) должен быть максимально близко к данным. Директивы хороши для общего RBAC, но ABAC/ownership лучше держать в data layer guard’ах + покрывать тестами.

6.5 Сценарий тестирования multi-tenant BOLA: как проверить горизонтальный и вертикальный обход прав?

Минимум два tenant’а и два контекста идентичности. Проверяйте: user A (T1) не может читать/менять объекты T2 (горизонталь) и не может выполнять admin-only мутации в T1 (вертикаль). Не забывайте комбинированный кейс (admin T1 → T2).

6.6 Как бороться с утечкой данных через Error Messages в GraphQL production? (добавлено)

Нормализуйте ошибки наружу (коды/короткие сообщения), подробности — внутрь по trace-id. Не допускайте stack traces, различимости “не существует” vs “запрещено”, и тестируйте это.

6.7 Плюсы и минусы IAST в GraphQL пайплайнах (добавлено)

Плюсы — видит реальный data-flow в резолверах. Минусы — агент, шум, зависимость от сценариев. Ставьте IAST после базовой регрессии (BOLA + limits + валидация), иначе он превратится в очередной игнорируемый отчёт.

6.8 Как Apollo Server борется с batching DoS из коробки и где его можно обойти? (добавлено)

Можно отключить batching, поставить body size limit, complexity plugins, persisted queries. Но обходят через aliases, depth, дорогие резолверы и “универсальные” операции. Поэтому нужны тесты на предохранители.

6.9 Как выбрать Rate Limiting: по endpoint’у, по operationName или по стоимости? (добавлено)

По endpoint’у — почти всегда неправильно. Нужен rate limiting по operationName и/или по стоимости (complexity). И тесты, которые доказывают, что “дорогие” операции режутся раньше.

6.10 Как превратить security checks в регрессию без флейка?

Стабильные тестовые данные, отдельные аккаунты/tenant, ассерт на конкретную политику, артефакты. Любой флейк = тест выключат.


7. От теории к практике: Индивидуальное менторство по Application Security и автоматизации

Эта статья — надводная часть айсберга. В проде всё хуже: легаси, микросервисы, тенанты, деньги, ограничения, люди, которые выключают проверки “потому что релиз горит”.

Если вы хотите за 3 месяца перейти на уровень Application Security Engineer и претендовать на офферы от $8 000, вам нужно не “ещё один гайд”, а практика:

  • Threat Modeling по доменам (payments, profile, admin), а не “по всей схеме”.
  • Security Testing lifecycle: требования → проверки → регрессия → отчётность → контроль исправлений.
  • Shift Left security: безопасность как часть CI, а не отчёт в конце спринта.
  • Playwright Automation как регрессия безопасности (GraphQL + роли + тенанты).
  • Портфолио: тесты, отчёты, policy, CI пайплайны — то, что можно показать на интервью.

На менторстве мы работаем либо с вашими реальными проектами (под NDA), либо строим мощное портфолио с нуля на “взрослых” микросервисах (финтех-сценарии, роли, тенанты, лимиты, наблюдаемость). Не “игрушку”, а то, с чем вы реально можете претендовать на сильные офферы.

Запишись на бесплатный 15-минутный разбор в Telegram: разберём ваш текущий уровень, цель и составим конкретный roadmap (без магии и без воды).

Подробнее о моем подходе к обучению читайте в разделе Ментор по кибербезопасности.

Написать в Telegram

Читайте также