GraphQL ile Dosya Yükleme: Multipart Request Yönetimi

GraphQL ile dosya yükleme konusu, ilk bakışta “ne kadar zor olabilir ki” dedirten ama üzerine oturduğunuzda birkaç farklı sorunu aynı anda çözmenizi gerektiren bir alan. REST API’lerde multipart/form-data ile dosya yüklemek neredeyse standart hale gelmiş durumda, ancak GraphQL tarafında iş biraz farklı işliyor. Özellikle güvenlik açısından bakıldığında, yanlış yapılandırılmış bir GraphQL dosya yükleme endpoint’i ciddi açıklara davetiye çıkarıyor. Bu yazıda hem teknik implementasyonu hem de güvenlik katmanını beraber ele alacağız.

GraphQL ve Dosya Yükleme: Neden Karmaşık?

GraphQL’in temel çalışma prensibi JSON üzerine kuruludur. Sorgu gönderiyorsun, JSON yanıt alıyorsun. Basit ve temiz. Ancak dosya yükleme söz konusu olduğunda JSON yeterli olmuyor, çünkü binary veriyi JSON içinde taşımak ya Base64 encoding gerektiriyor (ki bu ciddi boyut artışına neden olur) ya da tamamen farklı bir yaklaşım benimsemeniz gerekiyor.

İşte burada GraphQL multipart request spesifikasyonu devreye giriyor. Jaydenseric tarafından geliştirilen bu spesifikasyon, multipart/form-data ile GraphQL sorgularını birleştirmenin standart bir yolunu tanımlıyor. Bugün Apollo Server, Nexus ve benzeri popüler GraphQL framework’lerinin çoğu bu spesifikasyonu ya native destekliyor ya da plugin ile destekliyor.

Ama şunu da söylemek lazım: Production ortamında dosya yükleme işlemi sadece “nasıl yüklerim” sorusundan ibaret değil. “Kim yükleyebilir”, “ne boyuta kadar”, “hangi dosya tipleri”, “bu dosyayı nereye koyacağım ve nasıl saklayacağım” sorularının hepsi güvenlik kapsamına giriyor.

Multipart Request Spesifikasyonunu Anlamak

Standart bir GraphQL multipart isteğinde üç temel part bulunuyor:

  • operations: JSON stringi olarak GraphQL sorgusu ve değişkenleri
  • map: Dosyaların hangi değişkene map edileceğini tanımlayan JSON
  • 0, 1, 2…: Gerçek dosya verileri

Bir örnek üzerinden gösterelim. Normalde şöyle bir GraphQL mutation yazarsınız:

# Örnek mutation tanımı (SDL)
mutation UploadFile($file: Upload!) {
  uploadFile(file: $file) {
    filename
    url
    size
  }
}

Bu mutation’ı multipart olarak göndermek istediğinizde HTTP isteği şöyle görünür:

# curl ile multipart GraphQL isteği gönderme
curl -X POST http://localhost:4000/graphql 
  -H "Authorization: Bearer YOUR_JWT_TOKEN" 
  -F 'operations={"query":"mutation UploadFile($file: Upload!) { uploadFile(file: $file) { filename url } }","variables":{"file":null}}' 
  -F 'map={"0":["variables.file"]}' 
  -F '0=@/path/to/your/file.pdf'

Buradaki mantığı kavramak önemli: variables içinde file alanını null olarak gönderiyorsunuz, ardından map ile “0 numaralı dosya, variables.file alanına gidecek” diyorsunuz, en son da gerçek dosyayı ekliyorsunuz. Framework bunu parse edip bir araya getiriyor.

Node.js / Apollo Server ile Implementasyon

Apollo Server 3 ve üzeri sürümlerde dosya yükleme desteği artık core’dan çıkarıldı ve graphql-upload paketini kullanmanız gerekiyor. Bu ayrımı bilmeden saatlerce hata ayıklayabilirsiniz, o yüzden baştan belirteyim.

# Gerekli paketleri yükleyin
npm install graphql-upload@16 graphql @apollo/server express cors

# TypeScript kullanıyorsanız
npm install -D @types/graphql-upload

Şimdi temel server kurulumuna bakalım:

# server.js - Apollo Server + Express + graphql-upload entegrasyonu
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const { graphqlUploadExpress } = require('graphql-upload');
const { GraphQLUpload } = require('graphql-upload');
const path = require('path');
const fs = require('fs');
const { createWriteStream } = require('fs');
const { pipeline } = require('stream/promises');

const app = express();

// ÖNEMLİ: graphqlUploadExpress middleware'i Apollo'dan önce gelmeli
// maxFileSize: byte cinsinden (10MB = 10 * 1024 * 1024)
// maxFiles: aynı anda yüklenebilecek maksimum dosya sayısı
app.use(
  '/graphql',
  graphqlUploadExpress({
    maxFileSize: 10 * 1024 * 1024, // 10MB
    maxFiles: 5,
  })
);

const typeDefs = `
  scalar Upload

  type File {
    filename: String!
    mimetype: String!
    url: String!
    size: Int!
  }

  type Mutation {
    uploadFile(file: Upload!): File!
    uploadMultipleFiles(files: [Upload!]!): [File!]!
  }

  type Query {
    _empty: String
  }
`;

const resolvers = {
  Upload: GraphQLUpload,
  Mutation: {
    uploadFile: async (_, { file }, context) => {
      // Context'ten kullanıcı doğrulaması
      if (!context.user) {
        throw new Error('Kimlik doğrulama gerekli');
      }

      const { createReadStream, filename, mimetype, encoding } = await file;

      // Güvenlik: İzin verilen MIME type kontrolü
      const allowedMimeTypes = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'application/pdf',
      ];

      if (!allowedMimeTypes.includes(mimetype)) {
        throw new Error(`İzin verilmeyen dosya tipi: ${mimetype}`);
      }

      // Güvenlik: Dosya adı sanitizasyonu
      const sanitizedFilename = path
        .basename(filename)
        .replace(/[^a-zA-Z0-9._-]/g, '_');

      const uploadDir = path.join(__dirname, 'uploads');

      // Upload dizini yoksa oluştur
      if (!fs.existsSync(uploadDir)) {
        fs.mkdirSync(uploadDir, { recursive: true });
      }

      // Benzersiz dosya adı oluştur (path traversal saldırısını engeller)
      const uniqueFilename = `${Date.now()}_${sanitizedFilename}`;
      const filePath = path.join(uploadDir, uniqueFilename);

      const readStream = createReadStream();
      const writeStream = createWriteStream(filePath);

      // Stream pipeline ile dosyayı kaydet
      await pipeline(readStream, writeStream);

      const stats = fs.statSync(filePath);

      return {
        filename: uniqueFilename,
        mimetype,
        url: `/uploads/${uniqueFilename}`,
        size: stats.size,
      };
    },
  },
};

async function startServer() {
  const server = new ApolloServer({
    typeDefs,
    resolvers,
  });

  await server.start();

  app.use(
    '/graphql',
    express.json(),
    expressMiddleware(server, {
      context: async ({ req }) => {
        // JWT token doğrulama
        const token = req.headers.authorization?.split(' ')[1];
        const user = token ? verifyToken(token) : null;
        return { user };
      },
    })
  );

  app.listen(4000, () => {
    console.log('Server 4000 portunda çalışıyor');
  });
}

startServer();

Güvenlik Katmanı: Ne Yanlış Gidebilir?

Dosya yükleme işleminin güvenlik açısından en kritik noktalarına geliyoruz. Production’da gördüğüm yaygın hataları tek tek ele alalım.

1. Path Traversal Saldırıları

Kullanıcı ../../etc/passwd gibi bir dosya adı gönderirse ve siz bunu doğrudan kullanırsanız ciddi problem yaşarsınız. Yukarıdaki örnekte path.basename() ve sanitizasyon kullandık, ama bunu ayrıca test etmek gerekiyor.

# Path traversal saldırısını test etmek için
curl -X POST http://localhost:4000/graphql 
  -H "Authorization: Bearer YOUR_TOKEN" 
  -F 'operations={"query":"mutation UploadFile($file: Upload!) { uploadFile(file: $file) { filename url } }","variables":{"file":null}}' 
  -F 'map={"0":["variables.file"]}' 
  -F '0=@/tmp/test.jpg;filename=../../etc/passwd'

# Beklenen davranış: Dosya adının sanitize edilmesi
# Kabul edilemez: Dosyanın /etc/ altına yazılmaya çalışılması

2. MIME Type Spoofing ve Magic Byte Kontrolü

Bir saldırgan dosya uzantısını .jpg olarak ayarlarken içeriğe PHP kodu yerleştirebilir. Content-Type header’ına güvenmek yeterli değil, dosyanın ilk birkaç byte’ını (magic bytes) kontrol etmek gerekiyor.

# file-type paketi ile magic byte kontrolü
npm install file-type
# Güvenli MIME type doğrulama - magic bytes ile
const { fileTypeFromStream } = require('file-type');

const validateFileType = async (createReadStream, allowedTypes) => {
  const stream = createReadStream();
  const fileType = await fileTypeFromStream(stream);

  if (!fileType) {
    throw new Error('Dosya tipi tespit edilemedi');
  }

  if (!allowedTypes.includes(fileType.mime)) {
    throw new Error(
      `Güvenlik ihlali: Bildirilen tip ile gerçek tip uyuşmuyor. ` +
      `Tespit edilen: ${fileType.mime}`
    );
  }

  return fileType;
};

// Resolver içinde kullanım
const uploadFile = async (_, { file }, context) => {
  const { createReadStream, filename, mimetype } = await file;

  const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];

  // Magic byte kontrolü - content-type'a güvenme
  const detectedType = await validateFileType(createReadStream, allowedTypes);

  console.log(`Dosya yüklendi: ${filename}, Tip: ${detectedType.mime}`);

  // Dosyayı kaydet...
};

3. Boyut Limiti ve DoS Koruması

graphqlUploadExpress middleware’inde belirlediğiniz maxFileSize önemli ama tek başına yeterli değil. Nginx veya başka bir reverse proxy arkasında çalışıyorsanız, o katmanda da limit koymanız gerekiyor.

# Nginx konfigürasyonu - dosya yükleme için güvenli ayarlar
# /etc/nginx/sites-available/graphql-api.conf

server {
    listen 80;
    server_name api.example.com;

    # Global upload limiti
    client_max_body_size 15M;

    # Yavaş yükleme saldırılarına karşı timeout
    client_body_timeout 60s;
    client_header_timeout 60s;

    location /graphql {
        proxy_pass http://localhost:4000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # Multipart için özel timeout
        proxy_read_timeout 120s;
        proxy_send_timeout 120s;

        # Upload endpoint için rate limiting
        limit_req zone=upload_limit burst=10 nodelay;
    }
}

# Rate limiting zone tanımı (http bloğunda)
# /etc/nginx/nginx.conf içine ekleyin
# limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=5r/m;

4. Depolama ve İzin Kontrolü

Yüklenen dosyaları doğrudan web sunucusunun public dizinine koymak ciddi risk taşıyor. Özellikle PHP, JSP gibi çalıştırılabilir dosya tipleri yüklenerek remote code execution saldırıları gerçekleştirilebilir.

# Güvenli dosya depolama stratejisi
# uploads/ dizini web'den erişilemez olmalı,
# dosyalar stream üzerinden servis edilmeli

const serveFile = async (req, res) => {
  const filename = req.params.filename;

  // Dosya adı doğrulama
  if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
    return res.status(400).json({ error: 'Geçersiz dosya adı' });
  }

  const filePath = path.join(__dirname, 'private_uploads', filename);

  // Dosyanın private_uploads dışına çıkmadığını doğrula
  const resolvedPath = path.resolve(filePath);
  const uploadsDir = path.resolve(path.join(__dirname, 'private_uploads'));

  if (!resolvedPath.startsWith(uploadsDir)) {
    return res.status(403).json({ error: 'Erişim reddedildi' });
  }

  // Kullanıcının bu dosyaya erişim yetkisi var mı?
  const hasAccess = await checkFileAccess(req.user.id, filename);
  if (!hasAccess) {
    return res.status(403).json({ error: 'Bu dosyaya erişim yetkiniz yok' });
  }

  // Güvenli header'larla dosyayı sun
  res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.sendFile(resolvedPath);
};

Çoklu Dosya Yükleme ve İlerleme Takibi

Gerçek dünya senaryolarında genellikle birden fazla dosya aynı anda yükleniyor. Bu durumu hem resolver hem de client tarafında doğru yönetmek gerekiyor.

# Çoklu dosya yükleme resolver'ı
const resolvers = {
  Upload: GraphQLUpload,
  Mutation: {
    uploadMultipleFiles: async (_, { files }, context) => {
      if (!context.user) {
        throw new Error('Kimlik doğrulama gerekli');
      }

      // Dosya sayısı limiti (middleware'den bağımsız ikinci kontrol)
      if (files.length > 5) {
        throw new Error('En fazla 5 dosya aynı anda yüklenebilir');
      }

      const uploadedFiles = [];
      const errors = [];

      // Paralel yükleme için Promise.allSettled kullan
      // Promise.all yerine allSettled, bir dosya başarısız olsa
      // diğerleri yüklenmeye devam eder
      const results = await Promise.allSettled(
        files.map(async (filePromise, index) => {
          const { createReadStream, filename, mimetype } = await filePromise;

          const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
          if (!allowedTypes.includes(mimetype)) {
            throw new Error(`Dosya ${index + 1}: İzin verilmeyen tip ${mimetype}`);
          }

          const sanitizedName = path
            .basename(filename)
            .replace(/[^a-zA-Z0-9._-]/g, '_');
          const uniqueName = `${Date.now()}_${index}_${sanitizedName}`;
          const filePath = path.join(__dirname, 'uploads', uniqueName);

          const readStream = createReadStream();
          const writeStream = createWriteStream(filePath);
          await pipeline(readStream, writeStream);

          const stats = fs.statSync(filePath);

          return {
            filename: uniqueName,
            mimetype,
            url: `/uploads/${uniqueName}`,
            size: stats.size,
          };
        })
      );

      // Başarılı ve başarısız yüklemeleri ayır
      results.forEach((result, index) => {
        if (result.status === 'fulfilled') {
          uploadedFiles.push(result.value);
        } else {
          errors.push(`Dosya ${index + 1}: ${result.reason.message}`);
        }
      });

      if (errors.length > 0) {
        console.warn('Bazı dosyalar yüklenemedi:', errors);
      }

      return uploadedFiles;
    },
  },
};

AWS S3 Entegrasyonu ile Production-Ready Yapı

Dosyaları local disk yerine S3’e yüklemek production ortamı için çok daha sağlıklı. Özellikle horizontal scaling yapıyorsanız local disk seçeneği zaten yoktur.

# AWS SDK kurulumu
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

# S3'e doğrudan stream yükleme
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

const uploadToS3 = async (createReadStream, filename, mimetype) => {
  const key = `uploads/${Date.now()}_${filename}`;

  // @aws-sdk/lib-storage ile multipart upload desteği
  // Büyük dosyalar için otomatik parçalama yapar
  const upload = new Upload({
    client: s3Client,
    params: {
      Bucket: process.env.S3_BUCKET_NAME,
      Key: key,
      Body: createReadStream(),
      ContentType: mimetype,
      // Public erişimi kapat, presigned URL kullan
      ACL: 'private',
      // Güvenlik metadata'sı ekle
      Metadata: {
        'uploaded-by': 'graphql-api',
        'upload-timestamp': new Date().toISOString(),
      },
    },
  });

  // Yükleme ilerlemesini takip et
  upload.on('httpUploadProgress', (progress) => {
    console.log(`Yükleme: ${progress.loaded}/${progress.total} bytes`);
  });

  const result = await upload.done();

  return {
    key,
    url: result.Location,
    bucket: process.env.S3_BUCKET_NAME,
  };
};

Hata Yönetimi ve Loglama

Production’da karşılaştığım durumların büyük çoğunluğunda yetersiz hata yönetimi ve loglama baş belası oluyor. Dosya yükleme işlemleri için özellikle dikkat edilmesi gereken birkaç nokta var.

# Merkezi hata yönetimi ve güvenlik loglama
const winston = require('winston');

const securityLogger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({
      filename: 'logs/security.log',
      level: 'warn',
    }),
    new winston.transports.File({
      filename: 'logs/uploads.log',
    }),
  ],
});

// Güvenlik olaylarını logla
const logUploadEvent = (userId, filename, mimetype, status, ip) => {
  securityLogger.info('file_upload_attempt', {
    userId,
    filename,
    mimetype,
    status, // 'success' | 'rejected' | 'error'
    ip,
    timestamp: new Date().toISOString(),
  });
};

// Şüpheli aktiviteyi logla
const logSecurityEvent = (type, details, ip) => {
  securityLogger.warn('security_event', {
    type, // 'path_traversal' | 'mime_mismatch' | 'size_exceeded' | 'unauthorized'
    details,
    ip,
    timestamp: new Date().toISOString(),
  });
};

// Resolver içinde entegrasyon örneği
const uploadFile = async (_, { file }, context) => {
  const clientIp = context.req?.ip || 'unknown';

  if (!context.user) {
    logSecurityEvent('unauthorized', { action: 'file_upload' }, clientIp);
    throw new Error('Kimlik doğrulama gerekli');
  }

  const { createReadStream, filename, mimetype } = await file;

  try {
    // ... yükleme işlemi ...
    logUploadEvent(context.user.id, filename, mimetype, 'success', clientIp);
  } catch (error) {
    logUploadEvent(context.user.id, filename, mimetype, 'error', clientIp);
    throw error;
  }
};

Sonuç

GraphQL ile dosya yükleme meselesini özetleyecek olursam: teknik implementasyon görece kolay, ama güvenli implementasyon dikkat istiyor. Karşılaştığım pek çok production sisteminde eksik olan şeyler şunlar oluyor genellikle: magic byte kontrolü yapılmıyor, path traversal koruması yok, dosyalar web-accessible dizinlerde tutulmuş ve loglama yetersiz.

Kritik noktaları tekrar sıralayalım:

  • Middleware sırasına dikkat edin: graphqlUploadExpress Apollo middleware’inden önce gelmelidir
  • Çift kontrol yapın: Hem framework seviyesinde hem resolver içinde doğrulama yapın
  • Magic byte kontrolü: Content-Type header’ına asla güvenmeyin, dosyanın gerçek tipini kontrol edin
  • Benzersiz isimler kullanın: Timestamp veya UUID ile dosya adlarını çakışmadan ve tahmin edilemez şekilde oluşturun
  • Private storage: Dosyaları public dizinlere koymayın, kontrollü endpoint üzerinden sun
  • Rate limiting: Hem Nginx hem uygulama katmanında limit koyun
  • Audit log: Her yükleme girişimini kullanıcı bilgisiyle birlikte kaydedin

S3 veya benzeri object storage kullanıyorsanız, presigned URL yaklaşımını da değerlendirin. Dosyayı önce kendi sunucunuza çekip oradan S3’e göndermek yerine, client’a doğrudan S3’e yüklemesi için kısa süreli signed URL vermek hem bant genişliği açısından hem de ölçeklenebilirlik açısından daha iyi sonuç veriyor. Ancak bu yaklaşımda validation’ı farklı bir katmanda (örneğin Lambda trigger ile) yapmanız gerekiyor, bu da ayrı bir yazı konusu.

Bir yanıt yazın

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