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.
