@sedatdemirAra

RSC güvenliği: React2Shell ve saldırı yüzeyi

Bir Server Component içinde doğrudan veritabanı sorgusu çalıştırabilmek, fs modülüne erişebilmek, environment variable'ları okuyabilmek. Bunların hepsi RSC'nin geliştiriciye sunduğu kolaylıklar. Ama sunucuda çalışan kodun çıktısını istemciye serialize edip gönderdiğiniz an, o serialize katmanı bir saldırı yüzeyi haline geliyor. 2025'in ilk yarısında tartışılan React2Shell senaryosu (CVE-2025-55182 ile ilişkilendirilen kavramsal saldırı vektörü) tam olarak bu noktaya parmak basıyor.

RSC serialization katmanı nasıl çalışıyor

React Server Components, sunucuda render edilen bileşenlerin çıktısını RSC Wire Format adı verilen özel bir formata dönüştürüyor. Bu format, JSON benzeri ama React'e özgü bir protokol. Sunucu, bileşen ağacını bu formata serialize ediyor; istemci tarafındaki React runtime ise bu payload'ı deserialize edip DOM'a monte ediyor.

Şu basit Server Component'i düşünelim:

// app/dashboard/page.tsx
import { db } from "@/lib/db";

export default async function DashboardPage() {
  const user = await db.query.users.findFirst({
    where: (users, { eq }) => eq(users.id, getCurrentUserId()),
  });

  return (
    <div>
      <h1>{user?.name}</h1>
      <p>{user?.email}</p>
    </div>
  );
}

Bu bileşenin çıktısı istemciye gönderilirken, user nesnesinin tamamı değil, yalnızca JSX ağacında kullanılan name ve email alanları wire format'a yazılıyor. Buraya kadar sorun yok. Sorun, serialize edilen verinin sınırlarının geliştiricinin kontrolünde olması ve bu kontrolün kolayca kaybolabilmesi.

React2Shell: saldırı vektörü

React2Shell olarak adlandırılan saldırı sınıfı, RSC serialization sınırının ihlal edilmesiyle ortaya çıkıyor. Saldırının temel mantığı şu adımlardan oluşuyor:

  1. Saldırgan, sunucu tarafında işlenen bir girdiye (URL parametresi, form verisi, cookie) kötü niyetli bir payload enjekte ediyor.
  2. Server Component bu girdiyi yeterince doğrulamadan işliyor ve serialize edilen çıktıya dahil ediyor.
  3. İstemci tarafındaki deserialize işlemi sırasında bu payload, beklenmedik davranışlara yol açıyor.

Klasik XSS'ten farkı şu: burada saldırı yüzeyi tarayıcıdaki DOM manipülasyonu değil, sunucu ile istemci arasındaki serialization protokolünün kendisi. Wire format üzerinden geçen veri, React'in iç yapısına müdahale edebilecek şekilde manipüle edilebiliyorsa, istemci tarafında keyfi bileşen render'ı veya sunucu tarafında (Server Actions üzerinden) komut çalıştırma mümkün hale geliyor.

Somut bir tehlikeli pattern:

// TEHLIKELI: Kullanıcı girdisi doğrudan props olarak geçiriliyor
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string; filter?: string }>;
}) {
  const params = await searchParams;
  // params.filter doğrudan serialize edilip istemciye gönderiliyor
  return <ClientSearchResults filter={params.filter} query={params.q} />;
}

Burada params.filter string olarak bekleniyor ama URL'den gelen değer manipüle edilmişse, serialize katmanına beklenmedik tipler veya yapısal veriler sızabiliyor.

Server Actions: ikinci saldırı yüzeyi

React Server Components mimarisinde Server Actions, istemciden sunucuya doğrudan fonksiyon çağrısı yapılmasını sağlıyor. Bu çağrılar da aynı serialization katmanını kullanıyor, ama ters yönde: istemciden sunucuya veri akıyor.

"use server";

import { exec } from "child_process";

export async function processFile(formData: FormData) {
  const filename = formData.get("filename") as string;
  // TEHLIKELI: doğrudan shell komutu
  exec(`convert ${filename} output.pdf`);
}

Bu örnek kasıtlı olarak abartılmış, ama gerçek projelerde benzer pattern'ler daha ince biçimlerde ortaya çıkıyor. Bir Server Action'a gelen verinin tipini TypeScript derleme zamanında kontrol etmek yeterli değil. Çünkü istemciden gelen HTTP isteği, TypeScript tiplerini taşımıyor. Saldırgan, Server Action'ın endpoint'ine doğrudan HTTP isteği göndererek TypeScript'in tip güvencesini tamamen bypass edebiliyor.

Bu yüzden her Server Action'da runtime validation şart:

"use server";

import { z } from "zod";

const processFileSchema = z.object({
  filename: z
    .string()
    .min(1)
    .max(255)
    .regex(/^[a-zA-Z0-9._-]+$/),
});

export async function processFile(formData: FormData) {
  const result = processFileSchema.safeParse({
    filename: formData.get("filename"),
  });

  if (!result.success) {
    return { error: "Geçersiz dosya adı" };
  }

  // Güvenli işlem
}

Zod v4 veya ArkType gibi runtime validation kütüphaneleri burada zorunlu araçlar.

Next.js middleware zafiyeti geçmişi ve dersleri

Next.js'in middleware katmanı, RSC güvenlik tartışmalarından bağımsız olarak kendi zafiyet geçmişine sahip. 2025 Mart'ında açıklanan CVE-2025-29927, x-middleware-subrequest header'ının manipüle edilmesiyle middleware'in tamamen bypass edilebilmesine olanak tanıyordu. Bu zafiyet Next.js 15.2.3, 14.2.25 ve 13.5.9 sürümlerinde giderildi.

Bu zafiyetin RSC güvenliğiyle doğrudan ilişkisi şu: birçok geliştirici, authentication ve authorization kontrollerini middleware'de yapıp Server Component'lerin ve Server Actions'ların "güvenli" olduğunu varsayıyor. Middleware bypass edildiğinde, korumasız Server Actions doğrudan erişilebilir hale geliyor.

// middleware.ts - Tek başına yeterli DEĞİL
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("session");
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

Middleware bypass zafiyetinde bu kontrol hiç çalışmıyor. Bu yüzden defense-in-depth prensibi gereği, her Server Action kendi authentication kontrolünü de yapmalı:

"use server";

import { getSession } from "@/lib/auth";
import { z } from "zod";

export async function deleteAccount(formData: FormData) {
  const session = await getSession();
  if (!session?.userId) {
    throw new Error("Unauthorized");
  }

  const schema = z.object({
    confirmText: z.literal("DELETE"),
  });

  const parsed = schema.safeParse({
    confirmText: formData.get("confirm"),
  });

  if (!parsed.success) {
    return { error: "Onay metni geçersiz" };
  }

  // Hesap silme işlemi
}

Veri sızıntısı: serialize edilen fazla veri

RSC'nin daha sessiz ama yaygın güvenlik sorunu, gereğinden fazla verinin istemciye serialize edilmesi. Bir Server Component veritabanından kullanıcı nesnesini çekip sadece adını gösteriyorsa bile, nesnenin tamamını bir Client Component'e prop olarak geçirdiğinde, wire format'ta tüm alanlar istemciye ulaşıyor:

// SORUNLU: tüm user nesnesi istemciye gidiyor
export default async function ProfilePage() {
  const user = await db.query.users.findFirst({
    where: (users, { eq }) => eq(users.id, getCurrentUserId()),
  });

  return <ClientProfile user={user} />;
}
// GÜVENLİ: yalnızca gerekli alanlar
export default async function ProfilePage() {
  const user = await db.query.users.findFirst({
    where: (users, { eq }) => eq(users.id, getCurrentUserId()),
    columns: {
      name: true,
      email: true,
      avatarUrl: true,
    },
  });

  return <ClientProfile user={user} />;
}

Bu, RSC'ye özgü bir sorun. Geleneksel API endpoint'lerinde hangi alanların döndüğünü açıkça kontrol ediyorduk. RSC'de ise sunucu ve istemci aynı dosyada, aynı bileşen ağacında yaşadığı için veri sınırı bulanıklaşıyor. Drizzle ORM gibi query builder'ların columns seçeneği veya tRPC gibi katmanların select mekanizması bu riski azaltmak için doğrudan kullanılabilir.

React'in "taint" API'si

React, bu veri sızıntısı problemine karşı deneysel bir API sunuyor: experimental_taintObjectReference ve experimental_taintUniqueValue. Bu API'ler, belirli nesnelerin veya değerlerin istemciye serialize edilmesini engelliyor:

import { experimental_taintObjectReference as taintObject } from "react";

async function getUser(id: string) {
  const user = await db.query.users.findFirst({
    where: (users, { eq }) => eq(users.id, id),
  });

  if (user) {
    taintObject("User nesnesi istemciye gönderilemez", user);
  }

  return user;
}

Bu fonksiyondan dönen user nesnesi bir Client Component'e prop olarak geçirilmeye çalışıldığında React hata fırlatıyor. Henüz deneysel aşamada (React 19.1 itibarıyla experimental prefix'i devam ediyor), ama üretim ortamında kullanılabilir durumda.

Pratik güvenlik kontrol listesi

RSC tabanlı bir uygulamada güvenlik katmanlarını şu şekilde düşünmek gerekiyor:

Her Server Action'da runtime input validation yap. TypeScript tipleri yalnızca derleme zamanı güvencesi sağlıyor; ağ üzerinden gelen veriyi doğrulamıyor. Her Server Action'da authentication kontrolü yap. Middleware'e güvenme, middleware bypass edilebilir. Client Component'lere geçirilen prop'ları minimize et. Veritabanından çekilen nesnenin tamamını değil, yalnızca ihtiyaç duyulan alanları gönder. Server-only modülleri server-only paketi ile koru:

// lib/db.ts
import "server-only";
import { drizzle } from "drizzle-orm/neon-http";

export const db = drizzle(process.env.DATABASE_URL!);

Bu paket, modülün istemci bundle'ına dahil edilmeye çalışılması durumunda derleme hatası veriyor. Basit ama etkili.

Güvenlik modeli değişiyor

RSC öncesi dünyada sunucu ve istemci arasında net bir API sınırı vardı. REST veya GraphQL endpoint'leri, bu sınırı açıkça tanımlıyordu. RSC ile bu sınır aynı dosyanın, hatta aynı bileşen ağacının içine taşındı. "use server" ve "use client" direktifleri derleyici için sınır işaretleri, ama güvenlik için yeterli değiller.

React 19'un form actions ve useActionState yapısı Server Actions'ı daha kolay kullanılır hale getirdi. Kolaylık arttıkça, bu fonksiyonların birer HTTP endpoint olduğunu unutmak da kolaylaşıyor. Her "use server" fonksiyonu, dışarıdan çağrılabilir bir endpoint. Bu zihinsel modeli korumak, RSC tabanlı uygulamalarda güvenlik açıklarını önlemenin ilk adımı.