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 ломает классический подход:
- Схема ≠ Swagger. В REST есть OpenAPI. В GraphQL схему надо получить, актуализировать и учитывать роли. Интроспекция в проде чаще выключена (и правильно).
- Состояние и контекст. Реальная безопасность — это роли, тенанты, реальные данные. DAST без сценариев живёт в вакууме.
- Семантические баги. 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:
requestAPI 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)
Интроспекция “протекает” обычно в трёх местах:
- Конфиг сервера/gateway:
introspection: trueв проде “потому что удобно девам”.
- Context/authorization: вы проверяете роль на бизнес-резолверах, но не перекрываете системные поля (
__schema,__type).
- 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 с
userIdB.
- Ассерт:
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 умирает от шума. Поэтому:
- Единый формат ошибок:
operationName,role,targetId,expected,actual.
- Артефакты: payload + ответ (обрезанный) + trace-id если есть.
- 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 (без магии и без воды).
Подробнее о моем подходе к обучению читайте в разделе Ментор по кибербезопасности.