Skip to main content
Web Uygulama Güvenliği · 46 dk okuma

GraphQL Güvenliğinde Gözden Kaçan 6 Nokta · GraphQL Güvenliği

GraphQL güvenliği introspection'ı kapatmak veya query depth belirlemekle tamamlanmaz. Asıl risk resolver authorization, execution cost, batching, transport, subscription ve federation sınırlarında ortaya çıkar.

SX

SECNODEX Güvenlik Ekibi

Ofansif Güvenlik

Production ortamındaki bir GraphQL endpoint'inde introspection kapalı. GraphiQL yayında değil. Query depth sekiz ile sınırlandırılmış. JWT gateway üzerinde doğrulanıyor. Güvenlik tarafında ilk bakışta belirgin bir eksik görünmüyor.

Fakat düşük yetkili bir kullanıcı invoice(id: ...) resolver'ına başka müşterinin ID değerini gönderdiğinde faturayı okuyabiliyor. Dört seviyeden daha derin olmayan bir query, alias ve yüksek first değerleri nedeniyle binlerce resolver çalıştırıyor. Tek HTTP request içindeki elli verifyOtp alias'ı, gateway'in request bazlı rate limit'ini yalnızca bir kez tüketiyor. WebSocket bağlantısı kullanıcı logout olduktan sonra event almaya devam ediyor. Federation yapısındaki subgraph ise router dışında doğrudan erişilebildiği için gateway'deki authorization policy tamamen atlanabiliyor.

Bu örnekte klasik GraphQL hardening kontrollerinin çoğu vardır. Yine de sistem güvenli değildir.

Çünkü GraphQL saldırı yüzeyi tek bir endpoint'ten ibaret görünse de arka planda şema, resolver'lar, business logic, database, downstream API'ler, cache, WebSocket ve federated subgraph'lar birlikte çalışır. İstemci yalnızca hangi veriyi istediğini söylemez. Bir ölçüde server'ın hangi execution path'i kuracağını da belirler.

Kısa cevap

GraphQL güvenliği, schema discovery'yi zorlaştırmaktan çok daha fazlasıdır. Her node ve edge üzerinde authorization uygulanmalı, query'nin gerçek execution cost'u sınırlandırılmalı, alias ve batching semantik işlem sayısına göre kontrol edilmeli, trusted documents ile APQ birbirinden ayrılmalı, HTTP ve WebSocket lifecycle'ı birlikte korunmalı, federated subgraph'lar ayrı birer trust boundary olarak değerlendirilmelidir.

Bu yazıda sık tekrarlanan genel önerileri listelemek yerine, production incelemelerinde en fazla gözden kaçan altı noktaya odaklanacağız. Her noktada saldırının neden çalıştığını, zayıf implementation örneğini, güvenli tasarımı, source code review sırasında bakılacak alanları ve sızma testinde üretilecek kanıtı birlikte ele alacağız.

GraphQL güvenliği neden klasik API checklist'iyle bitmez?

REST API'de farklı business capability'ler çoğunlukla farklı endpoint'lere dağılır. GraphQL'de ise birçok query, mutation ve subscription aynı transport endpoint'i üzerinden yürür:

POST /graphql

WAF veya API gateway açısından iki request aynı URL'ye ve aynı HTTP method'a gidebilir. Fakat ilk request yalnızca kullanıcının adını okurken ikinci request yüzlerce object yükleyebilir, başka bir tenant'a ait veriyi isteyebilir veya ödeme workflow'unu değiştiren mutation çalıştırabilir.

query CurrentUser {
  me {
    displayName
  }
}
mutation ApprovePayment($id: ID!) {
  approvePayment(paymentId: $id) {
    id
    state
  }
}

Network katmanında ikisi de POST /graphql olarak görünür. Güvenlik kararı operation type, selected fields, arguments, identity, object ownership, current workflow state ve resolver'ın çağırdığı downstream kaynaklar anlaşılmadan verilemez.

Bir GraphQL request'i hangi katmanlardan geçer?

Basitleştirilmiş akış şöyledir:

HTTP veya WebSocket request
        ↓
Authentication ve request policy
        ↓
Parse
        ↓
GraphQL validation
        ↓
Custom depth, breadth ve cost validation
        ↓
Execution plan
        ↓
Root resolver
        ↓
Nested field resolver'ları
        ↓
Business service, repository, cache ve downstream API
        ↓
Error formatting ve response

GraphQL'in type validation aşaması query'nin şemaya uygun olup olmadığını kontrol eder. Kullanıcının istediği faturayı görmeye yetkili olup olmadığını, bir yorum alanının güvenli HTML içerip içermediğini veya aynı mutation'ın yüz kez çağrılmasının normal olup olmadığını kendiliğinden belirlemez.

Bu nedenle şu üç kavramı ayırmak gerekir:

KontrolCevapladığı soruTek başına çözemediği konu
Schema validationOperation şemaya ve type kurallarına uygun mu?Object ownership ve business authorization
AuthenticationRequest hangi subject'e ait?Subject'in belirli field veya object üzerindeki yetkisi
AuthorizationSubject bu action'ı bu object üzerinde yapabilir mi?Query'nin CPU, database ve downstream maliyeti
Demand controlOperation ne kadar kaynak tüketebilir?Veriye erişimin iş açısından doğru olup olmadığı
Transport securityRequest güvenli channel ve policy ile geldi mi?Resolver içindeki access control

Gözden kaçan altı noktanın özeti

NoktaSık görülen yanlış güvenGerçek risk
1. Node, edge ve resolver authorization“JWT doğrulandı, kullanıcı giriş yaptı”IDOR, field-level data exposure, cross-tenant access
2. Execution cost“Depth limit var”Alias breadth, list multiplication, expensive resolver ve downstream fan-out
3. Alias ve batching“Rate limit request sayıyor”OTP brute force, enumeration ve mutation abuse'un tek request'e sığması
4. Introspection ve persisted operation“Introspection kapalı, APQ açık”Schema inference, arbitrary operation registration ve excessive error disclosure
5. HTTP ve subscription lifecycle“CORS var, connection başında token kontrol ediliyor”CSRF, XS-Search, stale WebSocket authorization ve event leakage
6. Federation“Router güvenli, dolayısıyla supergraph güvenli”Doğrudan subgraph erişimi, identity header spoofing ve entity authorization bypass

1. Node, edge ve resolver authorization'ı ayrı ayrı doğrulamamak

GraphQL'de authentication context'inin bulunması authorization'ın tamamlandığı anlamına gelmez. Asıl soru, authenticated subject'in istenen object'e hangi ilişki üzerinden ulaştığı ve dönen her field'ı görmeye yetkili olup olmadığıdır.

Şu schema'yı düşünelim:

type Query {
  invoice(id: ID!): Invoice
  customer(id: ID!): Customer
  node(id: ID!): Node
}

type Customer implements Node {
  id: ID!
  name: String!
  invoices(first: Int = 20): InvoiceConnection!
}

type Invoice implements Node {
  id: ID!
  customerId: ID!
  total: Money!
  pdfUrl: String
  internalRiskNote: String
}

Uygulama yalnız customer query'sinde tenant kontrolü yapıp invoice, node veya Customer.invoices resolver'larında aynı policy'yi uygulamıyorsa aynı object'e alternatif graph path üzerinden ulaşılabilir.

Root resolver'da IDOR

Riskli resolver:

const resolvers = {
  Query: {
    invoice: async (_parent, { id }, ctx) => {
      requireAuthenticated(ctx.principal);

      return ctx.db.invoice.findUnique({
        where: { id }
      });
    }
  }
};

Bu kod kullanıcının login olduğunu doğrular. Fakat invoice ile kullanıcının tenant'ı, account'u veya contract'ı arasındaki ilişkiyi doğrulamaz. ID'nin UUID veya base64 encoded global ID olması erişim kontrolü değildir.

Daha güvenli yaklaşım object'i authorization scope içinde sorgular:

type Principal = {
  subjectId: string;
  tenantId: string;
  permissions: Set<string>;
};

async function findVisibleInvoice(
  db: Database,
  principal: Principal,
  invoiceId: string
) {
  if (!principal.permissions.has("invoice:read")) {
    throw new ForbiddenError();
  }

  return db.invoice.findFirst({
    where: {
      id: invoiceId,
      tenantId: principal.tenantId,
      customer: {
        memberships: {
          some: {
            userId: principal.subjectId,
            status: "ACTIVE"
          }
        }
      }
    }
  });
}

Burada tenantId client'tan alınmıyor. Authentication sonrasında oluşturulan server-side principal üzerinden geliyor. Object ownership ve membership aynı query içinde enforce edildiği için önce object'i yükleyip sonra erişim kontrolü yaparken oluşabilecek side channel ve unutulmuş code path riski de azalıyor.

“Kayıt bulunamadı” ile “erişiminiz yok” response'larının ayrılması object enumeration'a yardımcı olabiliyorsa dışarıya aynı hata modeli uygulanabilir. İç log ise gerçek nedeni ve policy kararını saklamalıdır.

IDOR'un neden rastgele ID kullanmakla çözülmediğini IDOR açığı neden hâlâ bu kadar yaygın? yazımızda daha geniş biçimde ele alıyoruz.

Edge authorization atlandığında ne olur?

Graph'ta object'e giden ilişki de korunmalıdır. Kullanıcı bir Customer object'ini görmeye yetkili olabilir fakat o customer'ın bütün invoice kayıtlarını, support ticket'larını veya employee ilişkilerini görmeye yetkili olmayabilir.

Riskli nested resolver:

const resolvers = {
  Customer: {
    invoices: async (customer, { first }, ctx) => {
      return ctx.db.invoice.findMany({
        where: { customerId: customer.id },
        take: first
      });
    }
  }
};

Resolver parent object'in daha önce doğru biçimde authorize edildiğini varsayıyor. Ancak Customer object'i başka bir resolver'dan, cache'ten, federation entity lookup'tan veya eksik field selection içeren bir code path'ten gelmiş olabilir.

Güvenli tasarımda edge policy açıkça tanımlanır:

Customer: {
  invoices: async (customer, { first = 20 }, ctx) => {
    const pageSize = Math.min(Math.max(first, 1), 50);

    await ctx.policy.assert({
      principal: ctx.principal,
      action: "invoice:list",
      resource: {
        type: "customer",
        id: customer.id,
        tenantId: customer.tenantId
      }
    });

    return ctx.invoiceRepository.listVisibleForCustomer({
      principal: ctx.principal,
      customerId: customer.id,
      limit: pageSize
    });
  }
}

Buradaki repository de tenant ve membership filter'ını uygular. Resolver'da yapılan policy check defense in depth olabilir. Tek source of truth business service veya repository katmanında tutulmalıdır. Aynı veri REST, background job veya internal RPC üzerinden çağrıldığında GraphQL resolver'ına özgü kontrolün atlanmaması gerekir.

Field-level authorization unutulabilir

Bir object'in görünür olması bütün field'larının görünür olduğu anlamına gelmez:

type Employee {
  id: ID!
  displayName: String!
  workEmail: String
  personalEmail: String
  salary: Money
  bankAccountIban: String
  disciplinaryNote: String
}

Employee listesi şirket rehberi için bütün çalışanlara açık olabilir. salary, bankAccountIban ve insan kaynakları notları ise çok daha dar policy gerektirir.

Bu nedenle schema review sırasında şu sorular sorulmalıdır:

  • Aynı type içinde farklı confidentiality seviyesine sahip field'lar var mı?
  • Nullable field gerçekten “veri yok” anlamına mı geliyor, yoksa “yetki yok” durumu da aynı null ile mi dönüyor?
  • Field authorization error'ı partial response içinde başka data'yı sızdırıyor mu?
  • Sensitive field resolver'ı parent object'i doğrudan serialize ediyor mu?
  • ORM entity'sinin tamamı default resolver tarafından istemeden açılıyor mu?
  • Interface veya union altında concrete type değiştiğinde policy korunuyor mu?

Schema'da field'ı tanımlamak authorization değildir. @auth, @requiresScopes veya benzeri directive'ler kullanılabilir fakat directive runtime'da gerçekten enforce edilmiyorsa yalnızca metadata olarak kalır.

Mutation authorization yalnız role check değildir

Şu mutation admin permission'ını kontrol ediyor olabilir:

mutation ApproveRefund($refundId: ID!) {
  approveRefund(refundId: $refundId) {
    id
    state
  }
}

Fakat güvenli karar için role dışında en az şu context gerekir:

  • Refund aynı tenant'a mı ait?
  • Request'i başlatan kişi kendi talebini approve ediyor mu?
  • Current state gerçekten PENDING_APPROVAL mı?
  • Tutar kullanıcının approval limit'i içinde mi?
  • Four-eyes rule uygulanıyor mu?
  • Aynı refund daha önce execute edildi mi?
  • Step-up authentication hâlâ geçerli mi?

Örnek business service:

async function approveRefund(input: {
  principal: Principal;
  refundId: string;
  idempotencyKey: string;
}) {
  return db.transaction(async tx => {
    const refund = await tx.queryOne<Refund>(
      `SELECT id, tenant_id, requested_by, state, amount, version
         FROM refunds
        WHERE id = $1 AND tenant_id = $2
        FOR UPDATE`,
      [input.refundId, input.principal.tenantId]
    );

    if (!refund) throw new NotFoundError();
    if (refund.state !== "PENDING_APPROVAL") throw new InvalidStateError();
    if (refund.requestedBy === input.principal.subjectId) {
      throw new SeparationOfDutiesError();
    }

    await policy.assertRefundApproval(input.principal, refund);
    await idempotency.assertUnused(tx, input.idempotencyKey, refund.id);

    return tx.queryOne<Refund>(
      `UPDATE refunds
          SET state = 'APPROVED', approved_by = $1, version = version + 1
        WHERE id = $2 AND version = $3
        RETURNING *`,
      [input.principal.subjectId, refund.id, refund.version]
    );
  });
}

Business logic açıklarını yalnız tek resolver'a bakarak yakalamak zordur. State transition, yetki, object ilişkisi ve concurrency birlikte incelenmelidir. Bu yaklaşımı kod incelemesinde business logic açıkları nasıl yakalanır? yazımızda adım adım açıklıyoruz.

DataLoader cache'i cross-tenant veri sızdırabilir

DataLoader, N+1 problemini azaltmak için aynı request içindeki yüklemeleri batch eder ve cache'ler. Kritik ayrıntı, loader'ın çoğunlukla request başına oluşturulması gerektiğidir.

Riskli global loader:

const invoiceLoader = new DataLoader(async ids => {
  return db.invoice.findMany({
    where: { id: { in: [...ids] } }
  });
});

Bu loader process boyunca paylaşılıyorsa bir tenant için cache'lenen object başka request'te aynı key ile dönebilir. Loader'ın batch function'ı da tenant veya subject context'i taşımıyor.

Daha güvenli context factory:

function createLoaders(principal: Principal, db: Database) {
  return {
    invoiceById: new DataLoader<string, Invoice | null>(async ids => {
      const rows = await db.invoice.findMany({
        where: {
          id: { in: [...ids] },
          tenantId: principal.tenantId
        }
      });

      const byId = new Map(rows.map(row => [row.id, row]));
      return ids.map(id => byId.get(id) ?? null);
    })
  };
}

async function buildContext(request: Request) {
  const principal = await authenticate(request);

  return {
    principal,
    loaders: createLoaders(principal, db)
  };
}

Cache key'in yalnız object ID olması da shared cache tasarımında yetersiz olabilir. Authorization sonucu role, tenant, region veya data classification'a göre değişiyorsa cache boundary bu context'i yansıtmalıdır.

Bu nokta nasıl test edilir?

Bir GraphQL authorization testi şu matrisi kurmalıdır:

BoyutEn az test edilmesi gerekenler
IdentityAnonymous, normal user, manager, admin, service account
TenantAynı tenant, farklı tenant, silinmiş membership, suspended tenant
Graph pathRoot query, nested edge, node, nodes, search, mutation response, subscription
FieldPublic, internal, PII, financial, administrative
Object stateActive, draft, archived, deleted, pending approval
Cacheİlk erişen yetkili, sonra yetkisiz, ters sıra, parallel request

Başarılı test yalnızca 403 görmek değildir. Yetkisiz resolver'ın database veya downstream service çağrısı yapmadığı, partial response'ta sensitive field kalmadığı ve audit event'in doğru subject, action ve object ile üretildiği de doğrulanmalıdır.

2. Yalnız depth limit uygulayıp gerçek execution cost'u ölçmemek

Depth limit yararlıdır fakat maliyetin yalnız bir boyutunu görür. Dört seviye derinliğindeki bir query, yüz alias ve yüksek pagination değerleriyle on binlerce row okuyabilir. Buna karşılık sekiz seviye derinliğindeki başka bir query cache üzerinden birkaç küçük object döndürebilir.

Şu query fazla derin değildir:

query ShallowButWide {
  a1: customer(id: "1") { invoices(first: 100) { nodes { total } } }
  a2: customer(id: "2") { invoices(first: 100) { nodes { total } } }
  a3: customer(id: "3") { invoices(first: 100) { nodes { total } } }
  a4: customer(id: "4") { invoices(first: 100) { nodes { total } } }
  a5: customer(id: "5") { invoices(first: 100) { nodes { total } } }
}

Depth limiti geçebilir. Fakat beş customer lookup, beş invoice list query'si ve en az beş yüz invoice object'i üretebilir. Her invoice için currency conversion, permission lookup veya remote tax service çağrısı varsa gerçek maliyet katlanır.

Maliyet hangi boyutlardan oluşur?

BoyutÖlçülmesi gereken örnek
Lexical sizeRequest byte, token sayısı, parser recursion
Structural depthEn uzun field path
List depthİç içe kaç list field var?
BreadthTop-level field, alias ve unique selection sayısı
Cardinalityfirst, last, limit, date range ve filter genişliği
Resolver weightDatabase scan, full-text search, export, aggregation, cryptographic işlem
Fan-outKaç downstream request veya subgraph fetch oluşuyor?
Response sizeDönebilecek object ve byte tahmini
ConcurrencyAynı subject kaç pahalı operation'ı paralel çalıştırabiliyor?

GraphQL'in resmi güvenlik rehberi depth yanında breadth, batch, pagination, rate limiting ve query complexity kontrolünü ayrı katmanlar olarak önerir. Bunun nedeni tek bir limitin bütün maliyet modellerini temsil edememesidir.

List multiplier hesaba katılmalıdır

Basit bir cost modeli şöyle düşünülebilir:

fieldCost = baseWeight + estimatedCardinality × childSelectionCost
operationCost = root fieldCost değerlerinin toplamı

Örneğin:

query Orders {
  orders(first: 50) {
    nodes {
      items(first: 100) {
        nodes {
          product {
            currentPrice
          }
        }
      }
    }
  }
}

Depth makul görünse de teorik cardinality 50 × 100 = 5.000 line item'dır. currentPrice remote pricing service'e gidiyorsa DataLoader çağrı sayısını azaltabilir fakat service'in işlediği item miktarını sıfırlamaz.

Schema seviyesinde her list field için default ve hard maximum tanımlanmalıdır:

type Query {
  orders(first: PageSize = 20, after: Cursor): OrderConnection!
}

scalar PageSize

Custom scalar tek başına yeterli değildir. Resolver ve repository de sınırı enforce etmelidir:

function normalizePageSize(input: number | null | undefined): number {
  if (input == null) return 20;
  if (!Number.isInteger(input) || input < 1 || input > 100) {
    throw new UserInputError("Invalid page size");
  }
  return input;
}

Client'ın first göndermemesi unlimited sonuç anlamına gelmemelidir. 0, negative value, çok büyük integer, variable coercion ve alias üzerinden farklı pagination değerleri test edilmelidir.

Static cost tahmini ile runtime budget birlikte kullanılmalıdır

Static analysis request execution başlamadan ucuz biçimde karar verir. Fakat data-dependent maliyeti her zaman bilemez. search(term: "a") ile search(term: exactReference) aynı field weight'ine sahip görünürken database tarafında çok farklı planlar üretebilir.

Bu nedenle iki budget yararlıdır:

  1. 1Pre-execution budget

Parse sonrasında depth, alias, root field, estimated cardinality ve schema weight üzerinden hesaplanır. Limit aşılırsa resolver çalışmadan request reddedilir.

  1. 1Runtime budget

Resolver sayısı, database row, downstream call, elapsed time ve response byte gibi gerçek tüketimi izler. Budget bittiğinde kalan iş iptal edilir veya güvenli biçimde sonlandırılır.

Architecture-neutral TypeScript örneği:

class ExecutionBudget {
  private remaining: number;

  constructor(limit: number) {
    this.remaining = limit;
  }

  consume(cost: number, label: string) {
    if (!Number.isFinite(cost) || cost < 0) {
      throw new Error("Invalid resolver cost");
    }

    this.remaining -= cost;

    if (this.remaining < 0) {
      throw new GraphQLError("Operation budget exceeded", {
        extensions: {
          code: "OPERATION_BUDGET_EXCEEDED",
          label
        }
      });
    }
  }
}

const resolvers = {
  Query: {
    searchOrders: async (_parent, args, ctx) => {
      const limit = normalizePageSize(args.first);
      ctx.budget.consume(20 + limit * 3, "searchOrders");

      return ctx.orderSearch.findVisible({
        principal: ctx.principal,
        query: args.query,
        limit,
        signal: ctx.abortSignal
      });
    }
  }
};

Bu örnekte client'a internal cost ayrıntısı dönmemek tercih edilebilir. label internal telemetry içinde tutulup public response'tan çıkarılabilir.

Timeout tek başına kaynak tüketimini durdurmayabilir

Reverse proxy beş saniye sonra connection'ı kapatsa bile backend query, downstream HTTP request veya background promise çalışmaya devam edebilir. Client response'u görmez fakat sistem kaynak tüketir.

Timeout tasarımı şu katmanları kapsamalıdır:

  • HTTP server request timeout
  • GraphQL execution deadline
  • Resolver bazlı timeout
  • Database statement timeout
  • Downstream HTTP deadline
  • Queue ve worker cancellation
  • Abort signal propagation
  • Response size ve serialization limiti
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3_000);

try {
  return await repository.executeExpensiveQuery({
    signal: controller.signal
  });
} finally {
  clearTimeout(timer);
}

Library veya database driver cancellation desteklemiyorsa Promise'i timeout ile yarışa sokmak alttaki işi durdurmaz. Güvenlik incelemesinde “response kesiliyor mu?” değil, “kaynak tüketimi gerçekten sonlanıyor mu?” sorusu sorulmalıdır.

Başlangıç limitleri nasıl seçilir?

İnternetten alınan maxDepth: 10 değeri güvenlik politikası değildir. Önce legitimate operation'lar staging ortamında ölçülmelidir:

  • P50, P95 ve P99 estimated cost
  • Resolver ve database call sayısı
  • Response byte
  • Execution time
  • Operation başına CPU ve memory
  • Subgraph bazlı fan-out

Sonra güvenli margin ile policy oluşturulur. İlk aşamada report-only veya warn-only çalıştırmak false positive'leri görmeye yardımcı olabilir. Enforcement tarihi ve exception'ların expiration süresi açıkça belirlenmelidir.

3. Alias ve batching'i tek HTTP request sanarak rate limit dışında bırakmak

GraphQL aynı operation içinde alias kullanarak aynı field'ı farklı argument'larla tekrar çağırabilir:

mutation VerifyMany {
  a1: verifyOtp(challengeId: "c-42", code: "000001") { success }
  a2: verifyOtp(challengeId: "c-42", code: "000002") { success }
  a3: verifyOtp(challengeId: "c-42", code: "000003") { success }
  a4: verifyOtp(challengeId: "c-42", code: "000004") { success }
}

Gateway POST /graphql için dakikada on request limiti uyguluyorsa bu operation yalnızca bir request olarak sayılabilir. Resolver ise dört ayrı OTP denemesi yapar. Yüzlerce alias kullanıldığında network katmanındaki limit ile business action sayısı arasındaki fark büyür.

İkinci mekanizma HTTP batching'dir:

[
  {
    "query": "mutation($c:String!){verifyOtp(code:$c){success}}",
    "variables": {"c":"000001"}
  },
  {
    "query": "mutation($c:String!){verifyOtp(code:$c){success}}",
    "variables": {"c":"000002"}
  }
]

HTTP batching GraphQL specification'ın zorunlu bir parçası değildir. Framework veya gateway extension'ı olarak etkinleştirilebilir. Kullanılmıyorsa kapalı tutulması saldırı yüzeyini azaltır. Apollo Server'ın güncel sürümlerinde HTTP batching'in explicit olarak açılması gerekir.

Hangi işlemler özellikle risklidir?

  • Login ve password doğrulama
  • OTP, MFA ve recovery code kontrolü
  • Password reset token doğrulama
  • Coupon ve gift card denemesi
  • Username, e-mail ve phone enumeration
  • Invite code kullanımı
  • Payment veya refund mutation'ı
  • Export ve report generation
  • Expensive search
  • node ve nodes üzerinden object enumeration

Bu field'larda limit HTTP request sayısına değil, semantik action sayısına uygulanmalıdır.

Rate limit resolver içinde tüketilmelidir

async function verifyOtpResolver(_parent, args, ctx) {
  const key = {
    action: "otp.verify",
    subjectId: ctx.principal?.subjectId ?? "anonymous",
    challengeId: args.challengeId,
    ipBucket: normalizeIp(ctx.request.ip)
  };

  await ctx.rateLimiter.consume(key, 1);

  const result = await ctx.mfa.verify({
    challengeId: args.challengeId,
    code: args.code,
    principal: ctx.principal
  });

  await ctx.audit.record({
    event: "mfa_verification",
    challengeId: args.challengeId,
    outcome: result.success ? "success" : "failure"
  });

  return result;
}

Alias yüz kez çağrılsa bile limiter yüz kez tüketilir. Key yalnız IP'ye bağlanmamalıdır. Distributed attack IP limitini aşabilir. Yalnız account'a bağlanması da saldırganın başka bir hesabı kilitlemesine izin verebilir. Subject, target challenge, account, device ve network signal'ları risk modeline göre birlikte değerlendirilmelidir.

Operation-level limit yine gereklidir

Resolver rate limit business abuse'u sınırlar fakat parser ve execution plan maliyeti daha resolver başlamadan oluşabilir. Bu nedenle ayrıca şu sınırlar gerekir:

  • Request başına document byte ve token sayısı
  • Document içindeki operation sayısı
  • Tek operation içindeki alias sayısı
  • Root field sayısı
  • HTTP batch item sayısı
  • Aynı sensitive field'ın tekrar sayısı
  • Parallel execution sınırı
  • Mutation başına idempotency ve replay policy

Sensitive mutation'larda aynı operation içinde birden fazla root mutation field'ına izin vermemek mantıklı olabilir. GraphQL mutation root field'ları serial yürütülse bile bu, business policy açısından toplu kullanımın güvenli olduğu anlamına gelmez.

WAF ve SIEM neden saldırıyı küçük görebilir?

GraphQL payload parse edilmeden yalnız URL, status code ve request count izlenirse aşağıdaki iki kullanım aynı görünür:

1 HTTP request → 1 me query
1 HTTP request → 200 aliased credential check

Telemetry şu alanları içermelidir:

  • Stable operation ID veya trusted document ID
  • Operation name
  • Operation type
  • Root field sayısı
  • Alias sayısı
  • Estimated cost
  • Executed resolver count
  • Sensitive action count
  • Batch item count
  • Subject ve tenant reference
  • Rate limit outcome
  • Error code dağılımı

Raw variable değerleri, password, OTP, token, query içindeki personal data ve full document loglanmamalıdır. Operation fingerprint schema-aware normalization sonrasında üretilebilir.

4. Introspection'ı kapatmayı veya APQ kullanmayı operation allowlist sanmak

Introspection, client'ın schema type ve field'larını programatik biçimde öğrenmesini sağlar. Internal ve yalnız first-party client kullanan API'lerde production introspection'ını sınırlandırmak mantıklı bir surface reduction kontrolüdür. Fakat authorization açığını kapatmaz.

Introspection kapalı olsa bile attacker şu kaynaklardan schema hakkında bilgi toplayabilir:

  • Frontend JavaScript bundle içindeki operation document'ları
  • Mobile application içindeki generated query'ler
  • Error message içindeki “Did you mean” önerileri
  • Public documentation ve SDK
  • Network history ve telemetry leak'i
  • Eski schema dump'ları
  • Persisted operation manifest'i
  • Field name brute force
  • Federation ve development endpoint'leri

Bu nedenle “schema bilinmiyor” güvenlik varsayımı kurulamaz.

Excessive error schema'yı ve backend'i açığa çıkarır

Riskli response:

{
  "errors": [
    {
      "message": "column invoices.internal_risk_note does not exist",
      "path": ["invoice", "internalRiskNote"],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR",
        "stacktrace": [
          "DatabaseError: column ...",
          "at InvoiceRepository.findById (/app/src/repositories/invoice.ts:91:17)"
        ]
      }
    }
  ],
  "data": {
    "invoice": null
  }
}

Bu response schema field'ını, table ve column adını, source path'i ve technology stack'i gösterir.

Public response daha dar olmalıdır:

{
  "errors": [
    {
      "message": "The operation could not be completed",
      "path": ["invoice"],
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR",
        "traceId": "01JZ7TQYQ4F8B4K8M4T2Y6MZQ0"
      }
    }
  ],
  "data": {
    "invoice": null
  }
}

Internal log aynı traceId üzerinden original exception'a ulaşabilir. Ancak raw GraphQL variables, token ve sensitive response data log'a kopyalanmamalıdır.

Architecture-neutral error mapping:

function toPublicError(error: unknown, traceId: string): GraphQLError {
  if (error instanceof ValidationError) {
    return new GraphQLError("Invalid input", {
      extensions: { code: "BAD_USER_INPUT", traceId }
    });
  }

  if (error instanceof ForbiddenError) {
    return new GraphQLError("Forbidden", {
      extensions: { code: "FORBIDDEN", traceId }
    });
  }

  logger.error({ traceId, error }, "GraphQL execution failed");

  return new GraphQLError("The operation could not be completed", {
    extensions: { code: "INTERNAL_SERVER_ERROR", traceId }
  });
}

Production error policy hata türlerini ayırmalı fakat object existence, internal policy, SQL, stack trace, downstream URL ve secret bilgilerini client'a taşımamalıdır.

APQ ile trusted documents aynı şey değildir

Automatic Persisted Queries çoğunlukla bandwidth azaltmak için kullanılır. Client önce operation hash'ini gönderir. Server hash'i tanımıyorsa full document'ı ister ve daha sonra cache'e kaydedebilir.

Unknown hash
    ↓
Client full operation gönderir
    ↓
Server operation'ı hash ile cache'ler
    ↓
Sonraki request yalnız hash gönderir

Bu model arbitrary operation execution'ı engellemez. Attacker kendi query'sini hash'leyip registration flow'unu kullanabiliyorsa APQ güvenlik allowlist'i değildir.

Trusted document modelinde ise manifest build veya deployment aşamasında onaylanır:

{
  "sha256:7d8f...": {
    "name": "GetCurrentUser",
    "audience": "web-portal",
    "schemaVersion": "2026-08-01",
    "maxCost": 45,
    "status": "active"
  }
}

Runtime'da yalnız manifest içindeki ID çalışır. Unknown document registration kapalıdır.

Güvenli trusted document lifecycle şu kontrolleri içerir:

  • Manifest CI/CD içinde güvenilir build'den üretilir
  • Operation security review ve cost validation'dan geçer
  • Hash canonical document üzerinden hesaplanır
  • Client audience ve environment ile bağlanır
  • Unknown hash fail-closed reddedilir
  • Eski manifest için kontrollü grace period tanımlanır
  • Emergency revoke yapılabilir
  • Manifest değişikliği audit edilir
  • Trusted document olması authorization kontrolünü kaldırmaz

Public API'de üçüncü taraf client'ların operation'ları önceden bilinemez. Bu durumda trusted documents uygun olmayabilir. Authorization, demand control, rate limiting ve error masking daha güçlü uygulanmalıdır.

Operation name güvenlik kararı için kullanılamaz

Client şu adı istediği gibi belirleyebilir:

mutation HarmlessHealthCheck {
  approveRefund(refundId: "r-42") {
    id
  }
}

Gateway yalnız operationName == "ApproveRefund" ise step-up veya rate limit uyguluyorsa isim değiştirilerek kontrol atlanabilir. Güvenlik kararı parsed operation type, selected root field, schema coordinate ve resolver'daki gerçek action üzerinden verilmelidir.

5. HTTP güvenliği ile subscription lifecycle'ını birbirinden koparmak

GraphQL query ve mutation'ları çoğunlukla HTTP üzerinden, subscription'lar ise WebSocket veya streaming HTTP üzerinden yürür. Bu transport'ların authentication ve authorization davranışı aynı olmayabilir.

İki sık hata birlikte görülür:

  1. 1Cookie-authenticated GraphQL endpoint'i browser simple request'lerini kabul eder ve CSRF riski oluşur.
  2. 2WebSocket yalnız connection kurulurken authenticate edilir, token expire veya revoke olduğunda açık connection yaşamaya devam eder.

GET ile mutation çalıştırılmamalıdır

GraphQL over HTTP draft specification, GET request ile mutation çalıştırılmamasını ve böyle bir durumda 405 Method Not Allowed dönülmesini ister.

GET /graphql?query=mutation%20%7BdeleteAccount%7D HTTP/1.1
Host: api.example.com

Bu request execution'a ulaşmamalıdır. Daha sinsi problem, schema'da Query altında side effect üreten field bulunmasıdır:

type Query {
  exportInvoices(format: ExportFormat!): ExportJob!
  markNotificationRead(id: ID!): Notification!
}

GraphQL runtime bu field'ların gerçekten read-only olduğunu garanti etmez. Query resolver'ları side effect üretmemeli, state değişiklikleri Mutation altında tutulmalıdır. Aksi halde GET cache, prefetch, crawler ve CSRF varsayımları bozulur.

CORS, CSRF kontrolünün yerine geçmez

Browser bazı request'leri “simple request” kabul eder ve preflight göndermeden iletebilir. Özellikle şu media type'lar dikkat gerektirir:

  • text/plain
  • application/x-www-form-urlencoded
  • multipart/form-data

Cookie otomatik ekleniyorsa malicious site response'u okuyamasa bile mutation side effect'ini tetikleyebilir. CORS çoğunlukla response'un attacker origin tarafından okunmasını kontrol eder. Request'in gönderilmesini her durumda engellemez.

Güvenli baseline:

  • Standard GraphQL POST için Content-Type: application/json zorunlu olmalı
  • Unsupported ve eksik media type reddedilmeli
  • GET yalnız query için kullanılmalı
  • Browser GET ve multipart flow'larında preflight zorlayan custom header istenmeli
  • Credentialed CORS exact origin allowlist kullanmalı
  • Cookie Secure, HttpOnly ve uygun SameSite ile üretilmeli
  • Gerekli architecture'larda CSRF token veya Origin validation uygulanmalı
  • File upload ayrı threat model ile tasarlanmalı

Apollo Server'ın güncel CSRF koruması application/json dışındaki simple request ihtimallerini ve GET/multipart akışlarını özel header üzerinden sınırlar. Başka framework kullanıldığında aynı davranışın varlığı varsayılmamalı, configuration doğrulanmalıdır.

function requireGraphQLPreflight(req: Request) {
  const contentType = req.headers.get("content-type")?.toLowerCase() ?? "";
  const forcePreflight = req.headers.get("x-graphql-preflight");

  const isJson = contentType.startsWith("application/json");
  const isGet = req.method === "GET";

  if (!isJson && !(isGet && forcePreflight === "1")) {
    throw new HttpError(400, "Preflight requirement not satisfied");
  }
}

Bu örnek tam framework implementation'ı değildir. File upload, mobile client ve non-browser integration ihtiyaçları ayrıca ele alınmalıdır. Amaç “CORS açık” cümlesini CSRF kanıtı saymamaktır.

WebSocket authentication yalnız connection anında kalmamalıdır

Subscription connection'ı saatlerce açık kalabilir:

connection_init
      ↓
Token validation
      ↓
subscribe operation
      ↓
Event 1, Event 2, Event 3 ...
      ↓
Saatler sonra hâlâ açık connection

Bu sırada aşağıdaki event'lerden biri gerçekleşebilir:

  • Access token expire olur
  • User logout olur
  • Account disable edilir
  • Role veya tenant membership kaldırılır
  • Password reset yapılır
  • MFA kaldırılır
  • Subscription'ın bağlı olduğu object başka tenant'a taşınır
  • Kullanıcı event'i almaya yetkili olmaktan çıkar

Connection başındaki identity snapshot'ı sonsuza kadar geçerli kabul edilirse stale authorization oluşur.

Subscription'da üç ayrı authorization anı vardır

  1. 1Connection authorization

Origin, protocol, credential ve connection limit kontrol edilir.

  1. 1Subscribe authorization

Subject'in belirli subscription field'ını ve requested object'i dinlemeye yetkili olduğu doğrulanır.

  1. 1Event delivery authorization

Her event'in hâlâ o subject'e görünür olup olmadığı veya policy version'ın değişip değişmediği kontrol edilir.

Protocol-agnostic örnek:

async function authorizeSubscriptionStart(input: {
  token: string;
  origin: string | null;
  operation: ParsedOperation;
}) {
  assertAllowedOrigin(input.origin);

  const principal = await tokenVerifier.verify(input.token);
  await sessionPolicy.assertActive(principal.sessionId);
  await operationPolicy.assertSubscriptionAllowed(principal, input.operation);

  return {
    principal,
    authenticatedAt: Date.now(),
    policyVersion: await policyStore.currentVersion(principal.subjectId),
    absoluteExpiresAt: principal.expiresAt
  };
}

async function filterEvent(connection, event) {
  if (Date.now() >= connection.absoluteExpiresAt) return false;
  if (await revocationStore.isRevoked(connection.principal.sessionId)) return false;

  return eventPolicy.canReceive({
    principal: connection.principal,
    event
  });
}

Revocation kontrolü her event için remote store'a gitmek zorunda değildir. Short-lived authorization cache, policy version, pub/sub revoke event'i veya bounded revalidation interval kullanılabilir. Önemli olan iptal süresinin tanımlı ve test edilebilir olmasıdır.

Subscription resource limitleri unutulmamalıdır

  • User ve connection başına aktif subscription sayısı
  • IP ve tenant başına connection sayısı
  • connection_init timeout
  • Message byte ve operation token limiti
  • Ping/pong ve idle timeout
  • Absolute connection lifetime
  • Event rate ve outbound queue limiti
  • Slow consumer backpressure
  • Reconnect storm kontrolü
  • Logout ve revocation sonrası close süresi

WebSocket session lifecycle'ının HTTP oturumundan kopmaması gerektiğini oturum yönetiminde en sık yapılan hatalar rehberimizde daha geniş biçimde anlatıyoruz.

6. Federation'da router'ı tek güven sınırı kabul etmek

Federated GraphQL architecture çoğunlukla şu yapıya sahiptir:

Client
  ↓
Router veya Gateway
  ├── Users subgraph
  ├── Orders subgraph
  ├── Payments subgraph
  └── Inventory subgraph

Router authentication, operation planning, schema composition, demand control ve bazı authorization kararlarını uygular. Ancak subgraph endpoint'leri doğrudan erişilebiliyorsa attacker router'ı atlayabilir.

Doğrudan subgraph erişimi neden daha tehlikelidir?

Subgraph schema'sı public supergraph'ta görünmeyen veya yalnız router'ın kullanması beklenen alanlar taşıyabilir. Federation entity resolution için _service ve _entities gibi özel capability'ler bulunabilir. Router'ın query planı güvenli bir path üretse bile doğrudan subgraph client'ı farklı operation gönderebilir.

Riskli varsayım:

X-User-Id header varsa request router'dan gelmiştir

Attacker subgraph'a ulaşabiliyorsa header'ı kendisi ekleyebilir:

POST /graphql HTTP/1.1
Host: payments-subgraph.internal.example
X-User-Id: admin-1
X-Tenant-Id: tenant-a
Content-Type: application/json

Internal network, private DNS veya tahmin edilmesi zor hostname authentication kanıtı değildir.

Router ile subgraph arasındaki güven nasıl kurulmalıdır?

Architecture'a göre şu kontroller birlikte kullanılabilir:

  • Subgraph ingress yalnız router workload identity'sine açık olmalı
  • Router ve subgraph arasında mTLS kullanılmalı
  • Cloud-native workload identity veya signed request uygulanmalı
  • Her subgraph için ayrı audience ve credential tanımlanmalı
  • Client'tan gelen identity header'ları router tarafından silinip yeniden üretilmeli
  • Internal security context signature, aud, iat, exp ve replay açısından doğrulanmalı
  • Subgraph kendi object ve tenant authorization'ını korumalı
  • Secret rotation ve emergency revoke planı olmalı

Signed context örneği:

{
  "iss": "graphql-router",
  "aud": "payments-subgraph",
  "sub": "user-42",
  "tenant_id": "tenant-a",
  "session_id": "session-correlation-ref",
  "permissions": ["payment:read"],
  "iat": 1785578400,
  "exp": 1785578460,
  "jti": "ctx-7e35"
}

Bu token end-user access token'ının olduğu gibi bütün subgraph'lara forward edilmesi yerine router'ın dar audience ve kısa lifetime ile ürettiği internal security context olabilir. Hangi model seçilirse seçilsin subgraph iss, aud, signature, lifetime ve gerekli claim'leri doğrulamalıdır. JWT validator'ın algorithm ve key rotation kontrolleri için JWT kullanan uygulamalarda kritik yapılandırma hataları yazımıza bakabilirsiniz.

Entity resolver authorization bypass üretebilir

Federation entity lookup yaklaşık olarak şu reference üzerinden çalışabilir:

{
  "__typename": "Invoice",
  "id": "inv-42"
}

Riskli entity resolver:

Invoice: {
  __resolveReference: async reference => {
    return db.invoice.findUnique({
      where: { id: reference.id }
    });
  }
}

Bu resolver yalnız ID ile object döndürüyor. Router'ın reference'ı yalnız authorized path'ten ürettiği varsayılıyor. Direct subgraph access, farklı query plan veya başka bir subgraph'taki authorization hatası bu varsayımı bozabilir.

Daha güvenli yaklaşım:

Invoice: {
  __resolveReference: async (reference, ctx) => {
    return ctx.invoiceRepository.findVisibleById({
      principal: ctx.principal,
      invoiceId: reference.id
    });
  }
}

Reference içindeki tenantId, ownerId veya isAdmin gibi alanlar trusted kabul edilmemelidir. Authoritative value database veya güvenilir business service'ten alınmalıdır.

Composition authorization drift üretebilir

Bir field bir subgraph'ta authorization directive taşırken başka subgraph'ta farklı tanımlanabilir. Schema composition sonucu beklenmeyen AND, OR veya inheritance davranışı oluşabilir. Root type seviyesinde tanımlanan directive başka subgraph'ların field'larını da etkileyebilir.

Review sırasında yalnız composed supergraph schema'ya değil şunlara birlikte bakılmalıdır:

  • Her subgraph SDL
  • Composed schema
  • Router authorization configuration
  • Generated query plan
  • Entity resolver'lar
  • Header propagation rules
  • Direct subgraph network policy
  • Subgraph error masking
  • Per-subgraph demand limit
  • Schema registry access policy

Federation security testi iki ayrı route kullanmalıdır:

1. Client → Router → Subgraph
2. Test client → Subgraph doğrudan

İkinci route başarısız olmalıdır. Network seviyesinde ulaşılamıyorsa bu sonuç kaydedilir. Ulaşılabiliyor fakat shared secret, mTLS veya workload identity ile reddediliyorsa credential olmadan ve spoofed identity header'larla tekrar doğrulanır.

Altı nokta birleştiğinde nasıl bir attack path oluşur?

Bu riskler birbirinden bağımsız değildir. Gerçek etki çoğu zaman iki veya daha fazlasının birleşmesiyle ortaya çıkar.

Örnek bir attack chain:

Frontend bundle içinden operation ve field adları çıkarılır
        ↓
Introspection kapalı olsa da schema path'leri anlaşılır
        ↓
node(id) resolver'ında cross-tenant authorization eksikliği bulunur
        ↓
Alias kullanılarak tek request içinde yüzlerce ID denenir
        ↓
Gateway request bazlı rate limit saldırıyı bir request sayar
        ↓
Partial error davranışı geçerli ve geçersiz object'leri ayırır
        ↓
Bulunan object ID'leri subscription'a verilerek yeni event'ler izlenir

Başka bir federation zinciri:

Subgraph hostname'i certificate transparency veya deployment artifact'ından bulunur
        ↓
Endpoint router dışında doğrudan erişilebilirdir
        ↓
X-User-Id ve X-Tenant-Id header'ları spoof edilir
        ↓
__resolveReference yalnız ID ile entity döndürür
        ↓
Router'daki scope directive ve demand control tamamen atlanır

Bu nedenle raporda her zayıflığı ayrı düşük önem derecesine bölmek gerçek riski küçültebilir. Örneğin schema suggestion açık olması tek başına sınırlı bilgi ifşasıdır. Fakat aynı sistemde IDOR ve batching varsa enumeration attack path'inin reconnaissance adımı haline gelir.

GraphQL source code review'a nereden başlanır?

Controller veya /graphql route'u yalnız giriş noktasıdır. İnceleme schema ile başlayıp business service ve data source'a kadar devam etmelidir.

1. Schema inventory çıkarın

Şunları makine tarafından işlenebilir biçimde listeleyin:

  • Query, Mutation ve Subscription root field'ları
  • node, nodes, search ve export entry point'leri
  • ID alan ve object döndüren field'lar
  • Sensitive input ve output type'ları
  • Custom scalar ve directive'ler
  • Deprecated fakat hâlâ executable field'lar
  • File upload scalar'ları
  • Federation entity ve key tanımları
  • List ve connection field'ları
  • Default pagination değeri olmayan field'lar

Basit bir schema coordinate listesi şu biçimde tutulabilir:

Query.invoice
Query.node
Customer.invoices
Invoice.pdfUrl
Invoice.internalRiskNote
Mutation.approveRefund
Subscription.invoiceChanged
Invoice.__resolveReference

Her coordinate için owner team, authorization policy, data classification, resolver implementation, estimated cost ve test ID eşlenmelidir.

2. Resolver map ile business service çağrılarını eşleyin

Aranacak code pattern'leri:

resolvers
resolve
__resolveReference
subscribe
context
dataSources
DataLoader
findUnique
findById
findOne
update
delete
publish
fetch
axios
httpClient

findById(args.id) ile başlayan resolver'lar özellikle object authorization açısından incelenmelidir. Client'tan gelen tenantId, organizationId, ownerId, role, status ve userId değerlerinin authoritative kabul edildiği noktalar bulunmalıdır.

GraphQL schema strongly typed olsa da String olarak gelen URL'nin SSRF'ye, filter object'inin NoSQL injection'a veya free-text alanının unsafe HTML'e dönüşmesini engellemez. Resolver'ın çağırdığı interpreter veya downstream sink izlenmelidir. URL alanlarının yalnız allowlist ile çözülemeyen network risklerini SSRF'yi kapatmak için yalnızca allowlist yeterli mi? yazımızda ayrıntılı ele alıyoruz.

3. Context'in nasıl üretildiğini doğrulayın

ctx.user veya ctx.principal kaynağı bulunmalıdır:

  • JWT hangi issuer, audience ve algorithm ile doğrulanıyor?
  • Cookie session server-side geçerli mi?
  • Tenant hangi trusted kaynaktan belirleniyor?
  • Authentication sonucu bütün operation boyunca immutable mı?
  • WebSocket context ile HTTP context aynı policy'yi mi kullanıyor?
  • Router'dan gelen header gerçekten doğrulanmış mı?
  • Anonymous context ile failed authentication birbirinden ayrılıyor mu?

Resolver içinde raw token decode edip claim okumak yerine merkezi verifier tarafından oluşturulmuş typed principal kullanılmalıdır.

type RequestPrincipal = Readonly<{
  subjectId: string;
  tenantId: string;
  sessionId: string;
  authenticationTime: number;
  assuranceLevel: "aal1" | "aal2" | "aal3";
  permissions: ReadonlySet<string>;
}>;

4. Authorization'ın nerede enforce edildiğini kanıtlayın

Directive görmek yeterli değildir. Directive wiring, middleware order ve resolver wrapper gerçekten çalışıyor mu kontrol edilmelidir.

Şu bypass ihtimalleri aranır:

  • Root query korumalı, nested resolver korumasız
  • Query korumalı, mutation korumasız
  • Resolver korumalı, DataLoader batch function korumasız
  • Public resolver korumalı, node entry point korumasız
  • Normal resolver korumalı, __resolveReference korumasız
  • HTTP korumalı, subscription event filter korumasız
  • Router korumalı, direct subgraph korumasız
  • Yeni schema field'ı default allow davranışıyla publish edilmiş

Default deny policy, yeni field eklendiğinde review eksikliğinin data exposure'a dönüşmesini azaltır.

5. Demand control'ün gerçek resolver maliyetini temsil edip etmediğini ölçün

Static weight dosyası ile production telemetry karşılaştırılmalıdır. exportTransactions field'ı weight 1 iken ortalama 25 saniye ve milyonlarca row işliyorsa cost model gerçeği yansıtmıyordur.

Ölçüm şu ilişkiyi kurmalıdır:

operation fingerprint
    ↔ estimated cost
    ↔ actual resolver count
    ↔ database time
    ↔ downstream call count
    ↔ response bytes

Estimated ve actual cost arasındaki sapma düzenli gözden geçirilmelidir.

6. Error, log ve tracing katmanını inceleyin

GraphQL response'unun 200 OK dönmesi bütün resolver'ların başarılı olduğu anlamına gelmeyebilir. data ile errors aynı response'ta bulunabilir. Monitoring yalnız HTTP status code'a bakarsa authorization failure, timeout ve partial execution görünmez hale gelir.

Log ve trace alanları operation context'i taşımalı fakat secret içermemelidir:

{
  "event": "graphql.operation.completed",
  "operationId": "sha256:7d8f...",
  "operationName": "GetInvoices",
  "operationType": "query",
  "subjectRef": "hmac:8af1...",
  "tenantRef": "hmac:20d3...",
  "estimatedCost": 74,
  "resolverCount": 21,
  "dbDurationMs": 83,
  "downstreamCalls": 2,
  "responseBytes": 12480,
  "errorCodes": ["FORBIDDEN"],
  "partialResponse": true,
  "traceId": "01JZ7TQYQ4F8B4K8M4T2Y6MZQ0"
}

Query document ve variables varsayılan olarak loglanmamalıdır. Gerekli debugging için redaction, sampling, access control ve kısa retention uygulanmalıdır.

Otomatik SAST bazı resolver pattern'lerini, hardcoded secret'ları ve insecure API kullanımını bulabilir. Ancak graph path'leri arasındaki authorization tutarsızlığı ile business state'i çoğunlukla tek başına kuramaz. Bu farkı Secure code review ile otomatik SAST taraması farkı rehberimizde açıklıyoruz.

GraphQL sızma testi nasıl planlanmalıdır?

Yalnız introspection query çalıştırıp scanner sonucu almak yeterli değildir. Test, schema görünmese bile client behavior ve runtime response üzerinden ilerlemelidir.

Aşama 1: Endpoint ve transport keşfi

  • /graphql, /api/graphql ve versioned endpoint'ler
  • GET ve POST desteği
  • JSON, form ve multipart media type davranışı
  • WebSocket ve streaming subscription endpoint'i
  • GraphiQL, Playground, Explorer ve development route'ları
  • Router, gateway ve doğrudan subgraph adresleri
  • Mobile ve web client'ın kullandığı operation document'ları

Aşama 2: Schema ve operation inventory

  • Introspection policy
  • Error suggestion davranışı
  • Frontend bundle ve mobile artifact içindeki operation'lar
  • APQ registration flow
  • Trusted document unknown-ID davranışı
  • Deprecated field'lar
  • Alternate root entry point'leri

Aşama 3: Authorization matrisi

En az iki tenant ve farklı role'lere sahip hesaplar gerekir. Her object için aynı-tenant, cross-tenant ve role sınırı test edilir.

Account A / Tenant 1
Account B / Tenant 1
Account C / Tenant 2
Privileged Account / Tenant 1

Test edilen operation yalnız request değiştirilerek değil, response'tan dönen object ID'lerinin farklı path'lerde tekrar kullanılmasıyla genişletilir.

Aşama 4: Demand ve batching testleri

  • Depth artışı
  • List depth artışı
  • Alias breadth
  • Fragment expansion
  • Çoklu root field
  • Yüksek pagination argument'ı
  • Expensive filter ve wide date range
  • HTTP batch
  • Parallel request
  • Resolver timeout ve cancellation

Production üzerinde availability riski oluşturmamak için limitler Rules of Engagement içinde belirlenmelidir. Load testi ile sızma testi aynı şey değildir. Amaç sistemi çökertmek değil, guardrail'in sınırı güvenli request sayısıyla kanıtlamaktır.

Aşama 5: Business mutation testleri

  • State transition bypass
  • Aynı mutation'ın replay edilmesi
  • Parallel mutation race condition
  • Four-eyes ve self-approval bypass
  • Client-controlled price, role veya status
  • Hidden field ve mass assignment
  • Idempotency key scope'u
  • Step-up authentication binding

API ve mobile client'ı birlikte test etmenin neden farklı kanıtlar ürettiğini API ve mobil uygulama için ayrı sızma testi gerekir mi? yazımızda ele alıyoruz.

Aşama 6: Subscription ve revocation

  • Invalid token ile connection
  • Expired token ile subscribe
  • Allowed origin dışından handshake
  • Yetkisiz object ID ile subscription
  • Logout sonrasında event delivery
  • Role ve tenant değişikliğinden sonra event delivery
  • Çok sayıda active subscription
  • Slow consumer ve reconnect behavior

Aşama 7: Federation boundary

  • Direct subgraph reachability
  • Spoofed identity header
  • Missing router credential
  • Wrong audience internal token
  • Expired internal token
  • _entities object authorization
  • Subgraph-specific introspection ve debug error
  • Router ile subgraph arasında cost farkı

Web uygulaması sızma testinde scope'un yalnız URL listesinden oluşmaması gerektiğini web uygulaması sızma testinde kapsam nasıl belirlenir? rehberimizde ayrıntılandırıyoruz.

GraphQL bulgusu nasıl raporlanmalıdır?

“GraphQL introspection açık” tek başına güçlü bir bulgu anlatımı değildir. Rapor gerçek erişim ve iş etkisini göstermelidir.

Örnek bulgu yapısı:

Başlık
Cross-tenant Invoice Access Through Query.invoice and Node Lookup

Etkilenen schema coordinate
Query.invoice
Query.node
Invoice.pdfUrl

Ön koşul
Tenant A içinde normal kullanıcı hesabı

Kanıt
Tenant B'ye ait inv-8842 ID değeri iki ayrı graph path üzerinden okunabildi

İş etkisi
Fatura tutarı, müşteri bilgisi ve PDF download URL'si açığa çıktı

Kök neden
Repository query'si yalnız invoice ID ile çalışıyor ve tenant membership filter'ı uygulamıyor

Remediation
Object'i authoritative principal içindeki tenant ve membership scope'unda sorgula
Query.invoice, Query.node ve entity resolver için aynı policy'yi uygula

Retest
Aynı-tenant erişim başarılı
Cross-tenant erişim bütün entry point'lerde reddedildi
Unauthorized resolver database query üretmedi

Request ve response PoC içinde token, personal data ve gerçek müşteri verisi sanitize edilmelidir. Bulgu yalnız scanner tahminiyle değil, kontrollü object ve test account'larıyla doğrulanmalıdır.

GraphQL security telemetry hangi sinyalleri üretmelidir?

İyi telemetry yalnız saldırıyı görmek için değil, limitleri doğru ayarlamak için de gereklidir.

SinyalNeden önemlidir?
Unknown operation ID artışıTrusted document probing veya eski client
Alias ve root field artışıBatching attack veya client regression
Estimated cost P95 değişimiYeni pahalı operation veya abuse
Actual cost ile estimate farkıCost model calibration hatası
FORBIDDEN yoğunluğuObject enumeration veya yanlış frontend flow
Partial response oranıResolver failure ve görünmeyen authorization problemi
Subscription/user sayısıResource exhaustion ve leaked connection
Revocation sonrası açık socketSession lifecycle kopukluğu
Direct subgraph rejectionRouter bypass denemesi veya yanlış deployment
_entities hata dağılımıEntity enumeration veya composition sorunu

Alert eşikleri environment ve legitimate traffic baseline'ına göre oluşturulmalıdır. Örneğin schema migration sonrasında unknown operation ID artışı saldırı değil, eski client sürümünün etkisi olabilir. Security telemetry'nin işe yarayabilmesi için owner team ve response action tanımlı olmalıdır.

Ölçülebilir acceptance criteria örneği

“GraphQL endpoint güvenli olmalı” doğrulanabilir bir requirement değildir. Aşağıdaki örnekler testin beklenen sonucunu açıklar:

GQL-01
Query.invoice, Query.node, Customer.invoices ve Invoice.__resolveReference
aynı object authorization policy'sini uygulamalıdır. Tenant dışı invoice ID
her entry point'te data döndürmeden reddedilmelidir.

GQL-02
Sensitive field authorization başarısız olduğunda field value response'a
girmemelidir. Internal exception, policy adı ve object existence bilgisi
public GraphQL error içinde bulunmamalıdır.

GQL-03
List field'larında server-side default ve hard maximum page size bulunmalıdır.
Client first değerini göndermediğinde unlimited query oluşmamalıdır.

GQL-04
Operation depth, list depth, root field, alias, parser token ve estimated cost
limitleri execution başlamadan uygulanmalıdır.

GQL-05
Timeout sonrasında database ve downstream çağrılara cancellation signal
iletilmelidir. Client connection'ının kapanması tek başına başarılı timeout
kabul edilmemelidir.

GQL-06
OTP ve credential validation limitleri HTTP request başına değil resolver
çağrısı ve target challenge başına tüketilmelidir. Alias ve batch kullanımı
deneme hakkını artırmamalıdır.

GQL-07
Production trusted document modunda manifest dışında bir operation hash'i
full document gönderilse bile execute edilmemelidir.

GQL-08
Production response'ları stack trace, SQL, internal path, downstream URL,
secret ve raw exception içermemelidir. Internal log traceId ile erişilebilir
olmalıdır.

GQL-09
GET request mutation çalıştırmamalı ve 405 dönmelidir. Browser simple request
özelliği taşıyan GraphQL operation'ları preflight requirement karşılanmadan
execute edilmemelidir.

GQL-10
Logout, account disable veya role revoke sonrasında ilgili subscription
connection en fazla 30 saniye içinde event almayı bırakmalıdır.

GQL-11
Production subgraph endpoint'leri yalnız doğrulanmış router workload identity
ile erişilebilir olmalıdır. Spoofed identity header tek başına kabul
edilmemelidir.

GQL-12
Entity resolver client veya reference içindeki tenant alanına güvenmemeli,
object'i authoritative principal ve repository filter'ı ile authorize etmelidir.

Süre ve limit değerleri örnektir. Sistemin riskine, operation profiline ve kullanıcı deneyimine göre belirlenmelidir.

Geliştirici kontrol listesi

Schema ve operation tasarımı

  • [ ] Query, Mutation ve Subscription root field inventory'si güncel
  • [ ] Sensitive schema coordinate'ler data classification ile işaretli
  • [ ] Deprecated field'ların kaldırılma tarihi var
  • [ ] node ve nodes entry point'leri authorization testine dahil
  • [ ] Query resolver'ları side effect üretmiyor
  • [ ] Mutation input type'ları yalnız gerekli field'ları içeriyor
  • [ ] Client-controlled role, owner, tenant, price ve status kabul edilmiyor
  • [ ] List field'larında default ve hard maximum page size var
  • [ ] Custom scalar business validation'ın yerine geçmiyor

Authentication ve authorization

  • [ ] Authentication merkezi middleware veya verifier içinde yapılıyor
  • [ ] Resolver'lara raw token yerine doğrulanmış principal veriliyor
  • [ ] Tenant ID client argument'ından değil authoritative context'ten geliyor
  • [ ] Root resolver object authorization uyguluyor
  • [ ] Nested edge resolver authorization uyguluyor
  • [ ] Sensitive field'lar field-level policy ile korunuyor
  • [ ] Mutation'larda current state ve transition policy kontrol ediliyor
  • [ ] __resolveReference object authorization uyguluyor
  • [ ] Directive'lerin runtime enforcement'ı test edilmiş
  • [ ] Default authorization davranışı deny

Cache ve DataLoader

  • [ ] DataLoader request başına oluşturuluyor
  • [ ] Batch function tenant ve visibility filter'ı uyguluyor
  • [ ] Shared cache key security context'i yansıtıyor
  • [ ] Unauthorized sonuç cache'e positive object olarak yazılmıyor
  • [ ] Role ve membership değişikliği cache invalidation tetikliyor

Demand control

  • [ ] HTTP body ve header boyutu sınırlı
  • [ ] Parser token ve recursion limiti var
  • [ ] Operation depth limiti var
  • [ ] List depth ayrı sınırlanıyor
  • [ ] Alias, root field ve batch item limiti var
  • [ ] Query cost list cardinality'yi hesaba katıyor
  • [ ] Expensive resolver'ların schema weight'i güncel
  • [ ] Runtime budget ve cancellation uygulanıyor
  • [ ] Database statement timeout var
  • [ ] Downstream deadline propagate ediliyor
  • [ ] Response byte limiti var

Batching ve abuse kontrolü

  • [ ] HTTP batching gerekmiyorsa kapalı
  • [ ] Sensitive field rate limit'i resolver çağrısı başına tüketiliyor
  • [ ] OTP ve password limit'i target account veya challenge ile bağlı
  • [ ] Alias kullanımı deneme hakkını artırmıyor
  • [ ] Mutation idempotency key subject, action ve object ile bağlı
  • [ ] Parallel mutation race condition testi var

Introspection, document ve error

  • [ ] Introspection policy API'nin public veya private niteliğine göre belirli
  • [ ] Production IDE erişimi sınırlandırılmış
  • [ ] APQ güvenlik allowlist'i olarak kabul edilmiyor
  • [ ] Trusted document manifest'i deployment sırasında üretiliyor
  • [ ] Unknown operation ID fail-closed reddediliyor
  • [ ] Operation name authorization kararı için kullanılmıyor
  • [ ] Stack trace ve internal error public response'ta yok
  • [ ] Raw variables ve token loglanmıyor

HTTP ve subscription

  • [ ] GET ile mutation execution reddediliyor
  • [ ] Unsupported Content-Type reddediliyor
  • [ ] Browser simple request'leri için CSRF protection var
  • [ ] Credentialed CORS exact origin allowlist kullanıyor
  • [ ] WebSocket Origin doğrulanıyor
  • [ ] connection_init timeout uygulanıyor
  • [ ] Subscribe anında object authorization yapılıyor
  • [ ] Event delivery anında visibility korunuyor
  • [ ] Token expiration ve session revocation socket'e yansıyor
  • [ ] Connection ve active subscription limitleri var
  • [ ] Backpressure ve slow consumer policy'si tanımlı

Federation

  • [ ] Subgraph ingress yalnız router'a açık
  • [ ] Router ile subgraph arasında mTLS veya workload authentication var
  • [ ] Client identity header'ları router'da silinip yeniden üretiliyor
  • [ ] Internal context doğru audience ve kısa lifetime taşıyor
  • [ ] Her subgraph internal context'i doğruluyor
  • [ ] Direct subgraph test'i release öncesi çalışıyor
  • [ ] Composed schema authorization review'den geçiyor
  • [ ] Per-subgraph cost ve timeout limiti var

Monitoring ve test

  • [ ] Stable operation fingerprint üretiliyor
  • [ ] Alias, batch, resolver ve cost metric'leri izleniyor
  • [ ] Partial response ayrı ölçülüyor
  • [ ] FORBIDDEN artışı için context-aware alert var
  • [ ] Subscription revoke latency ölçülüyor
  • [ ] Direct subgraph rejection event'i izleniyor
  • [ ] Negative authorization testleri CI/CD içinde çalışıyor
  • [ ] Security exception'ların owner ve expiration tarihi var

Sonuç: Tek endpoint, tek güvenlik kontrolü anlamına gelmez

GraphQL'in güçlü tarafı client'a ihtiyacı olan veriyi açık biçimde seçme imkânı vermesidir. Aynı esneklik server'ın execution path'i, object ilişkileri ve resource tüketimi üzerinde daha ayrıntılı guardrail gerektirir.

Güvenli bir GraphQL implementation'ında:

  • Authentication sonucu typed ve trusted principal'a dönüştürülür
  • Node, edge, field ve mutation authorization ayrı ayrı uygulanır
  • Business policy yalnız resolver içinde kopyalanmaz
  • DataLoader ve cache security context'i korur
  • Depth yanında breadth, cardinality ve gerçek cost ölçülür
  • Alias ve batching semantic action limitini aşamaz
  • APQ ile trusted documents birbirine karıştırılmaz
  • Introspection kapalı olsa bile schema'nın keşfedilebilir olduğu varsayılır
  • Error response internal implementation ayrıntısı taşımaz
  • GET mutation çalıştırmaz ve browser simple request'leri kontrol edilir
  • Subscription authorization connection boyunca güncel kalır
  • Subgraph'lar router dışında erişime ve header spoofing'e karşı korunur
  • Test sonuçları measurable acceptance criteria'ya dönüşür

En kritik hata, GraphQL endpoint'inin arkasındaki bütün işlemleri tek bir request gibi görmektir. Gerçekte bir operation yüzlerce resolver, farklı data source'lar ve birden fazla security boundary üzerinden ilerleyebilir.

Doğru soru “Introspection kapalı mı?” değildir:

Not

Bu operation hangi node ve edge'lere ulaşıyor, kaç birim gerçek iş üretiyor, her resolver hangi authorization kararını veriyor ve session ya da policy değiştiğinde bütün transport'larda bu karar ne kadar sürede güncelleniyor?

SECNODEX, GraphQL ve API sızma testlerinde yalnız schema discovery veya automated scanner çıktısına dayanmaz. Farklı account, role ve tenant context'leriyle object-level authorization, mutation workflow, alias ve batching, query cost, HTTP transport, subscription lifecycle ve federation boundary'leri runtime üzerinde doğrulanır. OSCP ve OSWE sertifikalarına sahip uzmanlarımız, gerektiğinde source code içindeki resolver, business service, DataLoader ve repository akışlarını inceleyerek bulguyu reproducible evidence ve uygulanabilir remediation ile ilişkilendirir.

GraphQL API'nizin gerçek saldırı yüzeyini değerlendirmek için SECNODEX Sızma Testi hizmetini, resolver ve business logic kaynaklı sorunları incelemek için Kaynak Kod Analizi ve Secure Code Review hizmetimizi inceleyebilir veya bizimle iletişime geçebilirsiniz.

Kaynaklar

#GraphQL Güvenliği#GraphQL Security#API Security#Broken Access Control#Query Complexity#GraphQL Federation#GraphQL Subscription#Secure Code Review

Sık sorulan sorular

GraphQL güvenliği nedir?

GraphQL güvenliği, schema üzerinden sunulan query, mutation ve subscription'ların yalnız yetkili subject'ler tarafından, izin verilen object ve field'lar üzerinde, sınırlı kaynak tüketimiyle çalışmasını sağlamaktır. HTTP, WebSocket, resolver, business logic, cache, data source ve federation katmanlarının tamamını kapsar.

GraphQL, REST API'den daha mı güvensizdir?

Hayır. GraphQL doğası gereği güvensiz değildir. Fakat client'ın field seçimi ve graph traversal üzerinde daha fazla kontrol sahibi olması, authorization ve demand control hatalarının tek endpoint arkasında yoğunlaşmasına neden olabilir. Güvenlik modeli bu execution yapısına uygun kurulmalıdır.

Production ortamında introspection kapatılmalı mı?

API yalnız first-party client'lara hizmet veriyorsa introspection'ı sınırlandırmak schema discoverability'yi azaltabilir. Public GraphQL API'lerde introspection beklenen bir capability olabilir. Her iki durumda da introspection'ın kapalı olması authorization, rate limiting ve query cost kontrollerinin yerine geçmez.

GraphiQL veya Playground production'da açık olabilir mi?

Public developer platformunda bilinçli olarak sunulabilir. Internal uygulamada anonymous erişime açık IDE gereksiz schema ve operation keşfi sağlayabilir. Authentication, authorization, environment ayrımı ve telemetry ile sınırlandırılmalıdır.

Depth limit GraphQL DoS saldırılarını önler mi?

Tek başına önlemez. Shallow query çok sayıda alias, root field ve yüksek pagination değeriyle pahalı olabilir. Depth yanında breadth, list cardinality, parser limit, query cost, rate limit, timeout, cancellation ve resource quota gerekir.

GraphQL query complexity değeri kaç olmalı?

Evrensel bir sayı yoktur. Legitimate operation'ların P95 ve P99 cost değerleri, gerçek resolver ve database maliyetiyle ölçülmelidir. Limit schema ve traffic değiştikçe yeniden kalibre edilmelidir.

DataLoader authorization problemini çözer mi?

Hayır. DataLoader batching ve request-local caching ile performansı iyileştirir. Batch function object visibility filter'ı uygulamıyorsa veya loader request'ler arasında paylaşılıyorsa cross-tenant data exposure üretebilir.

GraphQL batching tamamen kapatılmalı mı?

Business ihtiyacı yoksa HTTP batching'i kapatmak mantıklıdır. Gerekliyse batch item, alias, operation ve cost limitleri uygulanmalıdır. OTP veya credential check gibi sensitive action'lar her resolver invocation için ayrıca rate limit tüketmelidir.

APQ kullanmak yalnız onaylı query'lerin çalışmasını sağlar mı?

Hayır. Automatic Persisted Queries çoğunlukla bandwidth optimization mekanizmasıdır. Unknown hash için full document registration açıksa client arbitrary operation ekleyebilir. Güvenlik için deployment öncesi onaylanan manifest ve unknown operation'ı reddeden trusted documents modeli gerekir.

GraphQL'de CSRF mümkün müdür?

Evet. Özellikle cookie authentication ve browser'ın preflight göndermediği simple request media type'ları birlikteyse mutation veya side effect üreten resolver tetiklenebilir. CORS tek başına yeterli değildir. JSON Content-Type, preflight requirement, Origin policy, SameSite ve gerektiğinde CSRF token birlikte değerlendirilmelidir.

Subscription connection başında token doğrulamak yeterli mi?

Hayır. Connection uzun ömürlü olabilir. Subscribe anında object authorization yapılmalı, token expiration ve session revocation takip edilmeli, event delivery görünürlüğü korunmalı ve policy değiştiğinde connection bounded sürede kapatılmalıdır.

Federation'da authorization router'da mı subgraph'ta mı yapılmalı?

Router erken reddetme ve merkezi policy için değerlidir. Subgraph ise direct access, yanlış query plan, header spoofing ve service reuse risklerine karşı kendi object ve tenant authorization'ını korumalıdır. Kontroller birbirini tamamlamalıdır.

GraphQL sızma testinde source code gerekli midir?

Black box test IDOR, batching, schema leakage, CSRF ve subscription sorunlarını gösterebilir. Source code ise alternatif resolver path'lerini, directive wiring'i, DataLoader scope'unu, cost model'i ve federation trust'ını daha eksiksiz doğrular. Gray box veya white box kapsam çoğu kurumsal uygulamada daha yüksek coverage sağlar.

GraphQL güvenliği SAST ile tamamen test edilebilir mi?

Hayır. SAST bazı sink'leri, injection pattern'lerini ve configuration hatalarını bulabilir. Ancak farklı role, tenant, graph path, runtime cache ve business state arasındaki authorization sonucunu çoğunlukla tam kuramaz. Manual secure code review ve runtime sızma testi birlikte daha güçlü sonuç verir.

GraphQL sızma testi için kaç kullanıcı hesabı gerekir?

En az iki tenant ve her kritik role için ayrı hesap ideal baseline'dır. Anonymous, normal user, privileged user, suspended membership ve service account gibi context'ler de scope'a göre eklenmelidir. Tek hesapla cross-tenant ve role boundary yeterince doğrulanamaz.

Siber güvenlik çalışmanızı
SECNODEX ile planlayın

İhtiyacınız tek bir uygulamanın testinden kurum genelinde bir Red Team çalışmasına kadar uzanabilir. Ekibimiz scope’u, kritik varlıkları ve beklenen çıktıları sizinle netleştirir. Ardından iş hedefinize ve risk önceliklerinize uygun, sınırları belirlenmiş bir çalışma planı sunar.