GraphQL’de Input Validation ve Şema Seviyesinde Doğrulama

GraphQL ile çalışmaya başladığınızda ilk etapta her şey harika görünür. Esnek sorgular, tip güvenliği, tek endpoint… Ama bir süre sonra şunu fark edersiniz: “Ben bu API’ye ne kadar güvenebilirim?” sorusu sizi rahatsız etmeye başlar. Özellikle production ortamında, kötü niyetli bir kullanıcının göndereceği malformed input’lar veya aşırı derecede iç içe geçmiş sorgular sisteminizi ciddi şekilde etkileyebilir. Bu yazıda GraphQL’de input validation ve şema seviyesinde doğrulamanın nasıl yapıldığını, hangi katmanlarda ne tür kontroller koymanız gerektiğini ve gerçek hayatta karşılaşılan sorunlara karşı nasıl savunma hattı oluşturulduğunu ele alacağız.

GraphQL Neden Farklı Bir Güvenlik Yaklaşımı Gerektirir

REST API’lerde input validation oldukça öngörülebilir bir yapıdadır. Belirli endpoint’ler, belirli HTTP metotları, belirli parametreler. Bir middleware yazarsınız, geçer gidersiniz. GraphQL’de işler biraz farklı. Tek bir endpoint üzerinden gelen sorgular dinamik yapıda olduğu için, geleneksel middleware’lerin çoğu yetersiz kalır.

GraphQL’in tip sistemi bir miktar güvenlik sağlar ama bu yanıltıcı olabilir. Int tipinde bir alan tanımladınız, bu doğru. Ama o integer’ın 1 ile 100 arasında olması gerektiğini şema nasıl bilecek? Ya da bir string alanın SQL injection içermediğini? İşte bu noktada katmanlı bir doğrulama stratejisi devreye girmek zorunda.

Şöyle düşünün: Bir e-ticaret platformunda çalışıyorsunuz. Kullanıcılar ürün araması yapabiliyor. Arama terimi için herhangi bir kısıtlama koymadıysanız, biri size 50.000 karakterlik bir string gönderebilir. Ya da limit argümanına 999999 yazabilir. Ya da iç içe geçmiş 20 katmanlı bir sorgu yaparak veritabanınızı felç edebilir.

Şema Seviyesinde Temel Doğrulama

GraphQL’in yerleşik tip sistemi aslında ilk savunma hattınızdır. Bunu doğru kullanmak önemli.

type Mutation {
  createUser(input: CreateUserInput!): UserPayload!
  updateProduct(id: ID!, input: UpdateProductInput!): ProductPayload!
}

input CreateUserInput {
  email: String!
  username: String!
  age: Int!
  role: UserRole!
}

enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}

Burada ! operatörü ile null değerleri engelliyoruz, enum ile izin verilen değerleri kısıtlıyoruz. Ama bunlar yeterli değil. email alanının gerçekten bir email formatında olup olmadığını, age‘in negatif olmadığını şema tek başına kontrol edemez.

Custom Scalar Types ile Doğrulama

Custom scalar’lar bu noktada devreye girer. graphql-scalars kütüphanesi bu iş için hazır çözümler sunar.

const { EmailAddressResolver, URLResolver, PositiveIntResolver } = require('graphql-scalars');
const { GraphQLSchema, GraphQLObjectType } = require('graphql');

const resolvers = {
  EmailAddress: EmailAddressResolver,
  URL: URLResolver,
  PositiveInt: PositiveIntResolver,
  
  Mutation: {
    createUser: async (_, { input }, context) => {
      // Bu noktaya email zaten validate edilmiş gelir
      return userService.create(input);
    }
  }
};
scalar EmailAddress
scalar URL
scalar PositiveInt

input CreateUserInput {
  email: EmailAddress!
  website: URL
  age: PositiveInt!
  username: String!
}

Custom scalar tanımladığınızda GraphQL runtime, gelen değeri parse etmeden önce sizin parseValue ve parseLiteral fonksiyonlarınızdan geçirir. Değer geçersizse zaten resolver’a ulaşamaz.

Kendi custom scalar’ınızı yazmak istiyorsanız:

const { GraphQLScalarType, Kind } = require('graphql');

const PhoneNumberScalar = new GraphQLScalarType({
  name: 'PhoneNumber',
  description: 'Türkiye formatında telefon numarası: +90XXXXXXXXXX',
  
  serialize(value) {
    return value;
  },
  
  parseValue(value) {
    const turkishPhoneRegex = /^+90[0-9]{10}$/;
    if (!turkishPhoneRegex.test(value)) {
      throw new Error('Geçersiz telefon numarası formatı. Beklenen format: +90XXXXXXXXXX');
    }
    return value;
  },
  
  parseLiteral(ast) {
    if (ast.kind !== Kind.STRING) {
      throw new Error('PhoneNumber string tipinde olmalıdır');
    }
    const turkishPhoneRegex = /^+90[0-9]{10}$/;
    if (!turkishPhoneRegex.test(ast.value)) {
      throw new Error('Geçersiz telefon numarası formatı');
    }
    return ast.value;
  }
});

Directive Tabanlı Validation

GraphQL direktifleri, validation mantığını şemadan ayırmanın şık bir yoludur. graphql-constraint-directive paketi bu konuda oldukça kullanışlı bir çözüm sunar.

directive @constraint(
  minLength: Int
  maxLength: Int
  startsWith: String
  endsWith: String
  contains: String
  notContains: String
  pattern: String
  format: String
  min: Float
  max: Float
  exclusiveMin: Float
  exclusiveMax: Float
  multipleOf: Float
) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION

input CreateProductInput {
  name: String! @constraint(minLength: 3, maxLength: 100)
  price: Float! @constraint(min: 0.01, max: 99999.99)
  sku: String! @constraint(pattern: "^[A-Z]{2}[0-9]{6}$")
  description: String @constraint(maxLength: 2000)
  stockCount: Int! @constraint(min: 0, max: 100000)
}

Bu yaklaşımın güzel yanı şu: Validation kuralları şemada görünür ve self-documenting bir API elde edersiniz. Frontend ekibi şemaya bakarak hangi kısıtlamaların olduğunu anlayabilir.

const { constraintDirective, constraintDirectiveTypeDefs } = require('graphql-constraint-directive');
const { makeExecutableSchema } = require('@graphql-tools/schema');

let schema = makeExecutableSchema({
  typeDefs: [constraintDirectiveTypeDefs, yourTypeDefs],
  resolvers: yourResolvers
});

schema = constraintDirective()(schema);

Resolver Katmanında Validation

Şema seviyesindeki kontroller temel güvenliği sağlar ama iş mantığına özgü validasyonlar resolver katmanında yapılmalıdır. Burada birkaç yaklaşım var.

Joi veya Zod ile Validation

const Joi = require('joi');

const createOrderSchema = Joi.object({
  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().uuid().required(),
      quantity: Joi.number().integer().min(1).max(99).required()
    })
  ).min(1).max(50).required(),
  
  shippingAddress: Joi.object({
    street: Joi.string().min(5).max(200).required(),
    city: Joi.string().min(2).max(100).required(),
    postalCode: Joi.string().pattern(/^[0-9]{5}$/).required(),
    country: Joi.string().valid('TR').required()
  }).required(),
  
  couponCode: Joi.string().alphanum().length(8).optional()
});

const resolvers = {
  Mutation: {
    createOrder: async (_, { input }, context) => {
      const { error, value } = createOrderSchema.validate(input, {
        abortEarly: false,  // Tüm hataları bir seferde döndür
        stripUnknown: true  // Bilinmeyen alanları temizle
      });
      
      if (error) {
        const details = error.details.map(d => ({
          field: d.path.join('.'),
          message: d.message
        }));
        throw new UserInputError('Geçersiz sipariş verisi', { details });
      }
      
      return orderService.create(value, context.user);
    }
  }
};

abortEarly: false kullanımına dikkat edin. Production’da bunu açık bırakmak kullanıcı deneyimini ciddi iyileştirir. Tüm hataları tek request’te görürsünüz.

Yeniden Kullanılabilir Validation Middleware

Proje büyüdükçe her resolver’a ayrı ayrı validation kodu yazmak sürdürülemez hale gelir. Bir validation middleware pattern’i oluşturmak mantıklı:

const { ApolloServer } = require('@apollo/server');

function withValidation(validationSchema, resolver) {
  return async (parent, args, context, info) => {
    const inputKey = Object.keys(args).find(k => 
      typeof args[k] === 'object' && args[k] !== null
    );
    
    if (inputKey && validationSchema) {
      const { error, value } = validationSchema.validate(args[inputKey], {
        abortEarly: false,
        stripUnknown: true
      });
      
      if (error) {
        throw new UserInputError('Validation hatası', {
          errors: error.details.map(d => ({
            field: d.path.join('.'),
            message: d.message.replace(/"/g, "'")
          }))
        });
      }
      
      args[inputKey] = value;
    }
    
    return resolver(parent, args, context, info);
  };
}

const resolvers = {
  Mutation: {
    createUser: withValidation(createUserSchema, async (_, { input }, context) => {
      return userService.create(input);
    }),
    
    updateProduct: withValidation(updateProductSchema, async (_, { id, input }, context) => {
      return productService.update(id, input, context.user);
    })
  }
};

Sorgu Karmaşıklığı ve Derinlik Sınırlaması

GraphQL güvenliğinin çok göz ardı edilen bir boyutu bu. Biri size şöyle bir sorgu gönderebilir:

query MaliciousQuery {
  users {
    orders {
      products {
        reviews {
          user {
            orders {
              products {
                reviews {
                  user {
                    orders {
                      id
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Bu sorgu çok küçük ama veritabanında yaratabileceği yük inanılmaz derecede büyük olabilir. graphql-depth-limit ve graphql-query-complexity paketleriyle bunu kontrol altına alabilirsiniz.

const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const server = new ApolloServer({
  schema,
  validationRules: [
    depthLimit(7),  // Maksimum 7 katman derinliği
    createComplexityLimitRule(1000, {
      onCost: (cost) => {
        console.log(`Sorgu maliyeti: ${cost}`);
      },
      formatErrorMessage: (cost) =>
        `Sorgu çok karmaşık (maliyet: ${cost}). Maksimum izin verilen: 1000`
    })
  ]
});

Peki bu 7 sayısını nereden bulduk? Şemanızı inceleyin, en derin meşru sorgunuzun kaç katman olduğunu bulun ve üstüne 2-3 katman ekleyin. Keyfi bir sayı koyarsanız ya gereksiz yere kullanıcıları engellersiniz ya da hiç koruma sağlamazsınız.

Sanitization: Gelen Veriyi Temizlemek

Validation’ın yanında sanitization da kritik. XSS saldırılarına karşı HTML içeriklerini temizlemek, SQL injection için özelleştirilmiş karakterleri escape etmek gerekebilir.

const DOMPurify = require('isomorphic-dompurify');
const { JSDOM } = require('jsdom');

function sanitizeInput(input) {
  if (typeof input === 'string') {
    // HTML tagları temizle
    const clean = DOMPurify.sanitize(input, {
      ALLOWED_TAGS: [],
      ALLOWED_ATTR: []
    });
    
    // Başındaki ve sonundaki boşlukları temizle
    return clean.trim();
  }
  
  if (Array.isArray(input)) {
    return input.map(sanitizeInput);
  }
  
  if (typeof input === 'object' && input !== null) {
    const sanitized = {};
    for (const [key, value] of Object.entries(input)) {
      sanitized[key] = sanitizeInput(value);
    }
    return sanitized;
  }
  
  return input;
}

// Apollo Server plugin olarak kullan
const sanitizationPlugin = {
  requestDidStart() {
    return {
      executionDidStart() {
        return {
          willResolveField({ args }) {
            if (args.input) {
              args.input = sanitizeInput(args.input);
            }
          }
        };
      }
    };
  }
};

Gerçek Dünya Senaryosu: E-Ticaret Arama API’si

Bir e-ticaret projesinde arama API’si üzerinde çalışırken şunlarla karşılaştım: Kullanıcılar arama kutusuna çok uzun stringler girebiliyor, fiyat filtresi için negatif değer gönderilebiliyor ve kategori filtresinde var olmayan değerler deneniyordu.

input ProductSearchInput {
  query: String @constraint(maxLength: 200)
  minPrice: Float @constraint(min: 0)
  maxPrice: Float @constraint(min: 0, max: 1000000)
  categoryIds: [ID!] @constraint(maxItems: 10)
  sortBy: ProductSortField
  sortOrder: SortOrder
  pagination: PaginationInput!
}

input PaginationInput {
  page: Int! @constraint(min: 1, max: 100)
  perPage: Int! @constraint(min: 1, max: 50)
}

enum ProductSortField {
  PRICE
  NAME
  CREATED_AT
  POPULARITY
}

enum SortOrder {
  ASC
  DESC
}

Resolver tarafında ek kontroller:

const searchProductsResolver = async (_, { input }, context) => {
  const { query, minPrice, maxPrice, categoryIds, pagination } = input;
  
  // Fiyat aralığı mantığını kontrol et
  if (minPrice !== undefined && maxPrice !== undefined && minPrice > maxPrice) {
    throw new UserInputError('Minimum fiyat, maksimum fiyattan büyük olamaz');
  }
  
  // Kategori ID'lerinin gerçekten var olup olmadığını kontrol et
  if (categoryIds && categoryIds.length > 0) {
    const existingCategories = await categoryService.findByIds(categoryIds);
    const existingIds = existingCategories.map(c => c.id);
    const invalidIds = categoryIds.filter(id => !existingIds.includes(id));
    
    if (invalidIds.length > 0) {
      throw new UserInputError('Geçersiz kategori ID'leri', {
        invalidIds
      });
    }
  }
  
  // Arama sorgusunu sanitize et
  const sanitizedQuery = query 
    ? query.replace(/[<>'"%;()+]/g, '').trim()
    : null;
  
  return productService.search({
    query: sanitizedQuery,
    minPrice,
    maxPrice,
    categoryIds,
    ...pagination
  });
};

Hata Mesajlarını Doğru Yönetmek

Validation hatalarını nasıl döndürdüğünüz de güvenlik açısından önemli. Stack trace, internal error mesajları veya veritabanı hataları kullanıcıya sızmamalı.

const { ApolloServerErrorCode } = require('@apollo/server/errors');

function formatError(formattedError, error) {
  // UserInputError'ları olduğu gibi döndür
  if (formattedError.extensions?.code === 'BAD_USER_INPUT') {
    return {
      message: formattedError.message,
      extensions: {
        code: formattedError.extensions.code,
        details: formattedError.extensions?.details || []
      }
    };
  }
  
  // Diğer hatalarda internal detayları gizle
  if (process.env.NODE_ENV === 'production') {
    return {
      message: 'Bir hata oluştu. Lütfen daha sonra tekrar deneyin.',
      extensions: {
        code: 'INTERNAL_SERVER_ERROR'
      }
    };
  }
  
  // Development ortamında tam hata bilgisi
  return formattedError;
}

const server = new ApolloServer({
  schema,
  formatError
});

Rate Limiting ve Input Boyutu Kontrolü

GraphQL endpoint’inize gelen request’lerin boyutunu sınırlamak da validation stratejisinin bir parçası. Biri size 10MB’lık bir JSON body gönderirse ne olur?

const express = require('express');
const rateLimit = require('express-rate-limit');

const app = express();

// Body boyutu sınırı
app.use(express.json({ limit: '100kb' }));

// Rate limiting
const graphqlLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 dakika
  max: 100,
  message: {
    errors: [{
      message: 'Çok fazla istek gönderildi. 15 dakika sonra tekrar deneyin.'
    }]
  },
  standardHeaders: true,
  legacyHeaders: false
});

app.use('/graphql', graphqlLimiter);

Authenticated kullanıcılar için farklı limit uygulayabilirsiniz:

const dynamicRateLimit = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: (req) => {
    if (req.user?.role === 'ADMIN') return 500;
    if (req.user?.id) return 200;
    return 50;  // Anonim kullanıcılar
  }
});

Sonuç

GraphQL’de güvenlik, tek bir kontrol noktasıyla değil, birden fazla katmanın birlikte çalışmasıyla sağlanır. Şema seviyesinde tipler ve direktiflerle temel kontrolleri koyun. Custom scalar’larla format validasyonunu şema içine taşıyın. Resolver katmanında iş mantığına özgü validasyonları gerçekleştirin ve Joi veya Zod gibi battle-tested kütüphanelerden faydalanın. Sorgu karmaşıklığını ve derinliğini kısıtlayın, bunu asla ihmal etmeyin. Sanitization’ı unutmayın, gelen veriyi güvenilir kaynaklardan gelse bile temizleyin. Hata mesajlarını üretim ortamında sansürleyin ve rate limiting uygulayın.

Bu katmanların hepsini aynı anda implement etmek zorunda değilsiniz. Projenizin olgunluk seviyesine göre önce şema validasyonuyla başlayın, sonra iş kurallarını ekleyin, ardından sorgu limitlerini koyun. Yavaş yavaş ilerleyin ama her katmanı mutlaka tamamlayın. GraphQL’in gücü esnekliğinden gelir, ama bu esneklik kontrolsüz bırakıldığında en büyük risk faktörünüze dönüşür.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir