CodeAuditAgent
Tüm yazılar
  • Next.js
  • Güvenlik
  • Kontrol Listesi

Next.js App Router Güvenlik Kontrol Listesi

Next.js App Router uygulamaları için pratik güvenlik listesi: server action'lar, route handler'lar, middleware, ortam değişkenleri, CSRF, header'lar, webhook'lar, önbellek.

· 8 dk okuma · Lina Source LLC

App Router, kodun büyük bir kısmını tarayıcıdan tekrar sunucuya taşıdı. Bu çoğunlukla bir güvenlik kazancı: sorgular, gizli bilgiler ve iş mantığı artık kullanıcıların okuyamayacağı bir yerde çalışıyor. Öte yandan neyin herkese açık bir endpoint, neyin yalnızca fonksiyon çağrısına benzeyen bir şey olduğu arasındaki çizgiyi de bulanıklaştırdı. Next.js uygulamalarındaki ciddi hataların çoğu bu bulanıklıktan kaynaklanır.

Bu kontrol listesi, App Router kod tabanlarında en sık gördüğümüz sorunları, kontrol edilmeye değer sırayla ele alıyor. Hiçbiri egzotik değil; her madde, framework'ün sağladığı kolaylığın bir güven sınırını gizlediği bir yer.

1. Server action'lar herkese açık endpoint'lerdir

'use server' ile işaretlenen bir fonksiyon bir HTTP endpoint'ine derlenir. Sitenizi yükleyebilen herkes action ID'sini bulabilir ve onu, arayüzünüz o action'ı kullanan düğmeyi hiç göstermese bile, istediği argümanlarla çağırabilir. Bir formu yönetici olmayanlardan gizlemek, arkasındaki action'ı korumaz.

Her action, herhangi bir API işleyicisiyle aynı üç adıma ihtiyaç duyar: çağıranın kimliğini doğrulayın, girdiyi doğrulayın ve dokunulan kaydın kendisi için yetkilendirme yapın. Bu kontrolleri doğrudan action gövdesinin içine koyun. Formu oluşturan sayfa bileşenindeki bir kontrol, action çağrıldığında değil sayfa yüklendiğinde çalışır; yani hiçbir şeyi korumaz.

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // Sahiplik kontrolü ayrı bir sorgu değil, yazma işleminin parçasıdır
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

Çok sayıda action dışa aktaran yardımcı dosyalara dikkat edin. 'use server' modülündeki dışa aktarılan her fonksiyon çağrılabilir; biri tarafından dahili bir betik için eklenip unutulan da dahil.

2. Route handler'lar da aynı kontrollere ihtiyaç duyar

app/api altındaki route handler'ların endpoint olduğu daha açıktır, ama aynı başarısızlık türünü paylaşırlar: dinamik bir segment, kaydın kime ait olduğu kontrol edilmeden kaydı yüklemek için kullanılır. Yeni Next.js sürümlerinde params bir Promise'tir; onu await edin, doğrulayın ve sorguyu çağıranla sınırlayın.

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

Çağıranın sahibi olmadığı kayıtlar için 403 yerine 404 döndürün; böylece endpoint hangi ID'lerin var olduğunu doğrulamış olmaz. Yetkilendirme kararlarında params, searchParams, header'lar veya çerezlere asla güvenmeyin; hepsi saldırganın kontrol ettiği girdilerdir.

3. Middleware tek başına bir yetkilendirme sınırı değildir

Middleware (Next.js 16'da adı proxy olarak değişti), oturumu kapalı kullanıcıları yönlendirmek ve header ayarlamak için kullanışlıdır. Ancak yetkilendirmenin yapıldığı tek yer olmamalıdır. Matcher'lar kolayca yanlış yazılır, yeni rotalar bunların dışında eklenir ve server action'lar sayfa yoluna POST gönderir; bu yol aklınızdaki kalıpla eşleşmeyebilir. 2025'te CVE-2025-29927, özel hazırlanmış dahili bir header'ın bazı Next.js sürümlerinde middleware'in tamamen atlanmasına yol açabildiğini gösterdi.

Middleware'i bir kolaylık katmanı olarak görün. Asıl kontrolün yeri verinin yanıdır: her action ve işleyicinin içi, ya da daha iyisi, sunucu tarafındaki her okumanın geçtiği bir veri erişim katmanı. Veri erişim katmanı, getProjectForUser(projectId) gibi fonksiyonlar sunan ve oturum kontrolünü ve sahiplik filtresini kendi içinde uygulayan, yalnızca sunucuda çalışan tek bir modüldür. Sayfalar, action'lar ve route handler'lar veritabanı istemcisi yerine bu modülü çağırır; böylece yeni bir rota kontrolü unutamaz: unutulacak kontrolsüz bir yol yoktur.

4. Sunucu kodunu sunucuda tutun

NEXT_PUBLIC_ önekiyle başlayan her ortam değişkeni build sırasında JavaScript paketine gömülür ve her ziyaretçi tarafından görülebilir. Bu, yayımlanabilir bir Stripe anahtarı veya bir analitik ID'si için doğrudur; başka her şey için bir sızıntıdır. Kod tabanında NEXT_PUBLIC_ araması yapın ve her birinin bir reklam panosunda durmasının güvenli olup olmayacağını kontrol edin. Tersi hata da olur: öneksiz bir değişken bir client bileşeninde okunur, tarayıcıda undefined döner ve biri hatayı ortadan kaldırmak için adını NEXT_PUBLIC_ ile değiştirir. Bir değere tarayıcıda ihtiyaç varsa, önce tarayıcının ona hiç sahip olması gerekip gerekmediğini sorun.

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // Satırın tamamını değil, yalnızca arayüzün ihtiyaç duyduğu alanları döndürün
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

server-only paketi, bir client bileşeni modülü içe aktarırsa build'in başarısız olmasını sağlar; bu da veritabanı istemcilerini ve gizli bilgi okuyan yardımcıları tarayıcıya sızmaktan korur. Server bileşenlerinin client bileşenlerine prop olarak ne aktardığına da dikkat edin: bu sınırdan geçen her şey sayfaya serileştirilir, dolayısıyla kullanıcı satırının tamamını aktarmak parola hash'ini ve dahili bayraklarını tarayıcıya gönderir.

5. CSRF: framework'ün neyi kapsadığını bilin

Server action'lar yalnızca POST kabul eder ve Next.js bunları çalıştırmadan önce Origin header'ını host ile karşılaştırır. Bir proxy arkasında veya birden fazla alan adında yayın yapıyorsanız, serverActions.allowedOrigins ayarını hatalar kaybolana kadar genişletmek yerine bilinçli olarak yapılandırın.

Route handler'lar böyle bir koruma almaz. Bir POST, PUT veya DELETE işleyicisi kimliği çerezlerle doğruluyorsa, başka bir sitedeki form yine de ona gönderim yapabilir. İçerik türünü kontrol etmek tek başına yeterli değildir: bir HTML formu preflight olmadan urlencoded, multipart ve text/plain gövdeler gönderebilir ve gövdeyi esnek biçimde ayrıştıran bir işleyici bunları kabul eder. Oturum çerezlerini SameSite=Lax veya Strict ile ayarlayın, GET isteklerinde asla durum değiştirmeyin ve çerezle kimlik doğrulanan değişiklik isteklerinde Origin header'ını kontrol edin. Yalnızca Authorization header'ındaki bir bearer token'ı kabul eden işleyiciler klasik CSRF'ye açık değildir.

6. Güvenlik header'ları ve CSP

Next.js varsayılan olarak çok az güvenlik header'ı gönderir. Bunları next.config içinde headers() fonksiyonuyla ya da katı bir Content-Security-Policy için istek başına nonce gerektiğinde middleware içinde ekleyin.

  • Content-Security-Policy, tercihen nonce tabanlı, object-src 'none' ve base-uri 'self' ile.
  • HTTPS her yerde sağlam hâle geldikten sonra uzun bir max-age ile Strict-Transport-Security.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • Clickjacking'i önlemek için CSP'de frame-ancestors veya X-Frame-Options.
  • X-Powered-By header'ını kaldırmak için next.config içinde poweredByHeader: false.

7. Hız sınırlama

Yerleşik bir hız sınırlayıcı yoktur. Giriş, kayıt, parola sıfırlama, OTP doğrulama ve çağrı başına para harcatan her şey (e-posta, SMS, yapay zeka istekleri) kullanıcı başına ve IP başına sınır gerektirir. Bellek içi sayaçlar serverless veya çok örnekli dağıtımlarda çalışmaz; Redis veya veritabanınız gibi paylaşılan bir depo kullanın. Sınırları yalnızca middleware'de değil, kimliği doğrulanmış kullanıcının bilindiği action veya işleyicinin içinde uygulayın. Sınırları saldırganın ucuza değiştiremeyeceği bir şeye bağlayın: IP'ye ek olarak, kimlik doğrulamalı rotalarda hesaba, sıfırlama ve OTP akışlarında hedef e-posta veya telefon numarasına. Meşru istemcilerin doğru şekilde geri çekilebilmesi için Retry-After header'ıyla 429 döndürün.

8. Webhook'ları ham gövdeye göre doğrulayın

Webhook imzaları, sağlayıcının gönderdiği baytların tam hâli üzerinden hesaplanır. Gövdeyi JSON olarak ayrıştırıp yeniden serileştirmek bu baytları değiştirir ve doğrulamayı bozar; bu da insanları doğrulamayı atlamaya iter. Bir route handler'da gövdeyi req.text() ile okuyun ve başka hiçbir şey yapmadan önce doğrulayın.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // İşleyiciler idempotent olmalıdır: sağlayıcılar yeniden dener ve iki kez teslim edebilir
  await handleStripeEvent(event);
  return new Response('ok');
}

İşlenmiş olay ID'lerini benzersizlik kısıtlamasıyla saklayın; böylece yeniden denenen veya tekrar oynatılan bir teslimat bir aboneliği iki kez vermez. Sağlayıcılar teslim sırasını garanti etmediğinden, sıra dışı gelen olayları da ele alın.

9. Kullanıcıya özel veriyi global olarak önbelleğe almayın

App Router agresif biçimde önbelleğe alır ve varsayılanlar sürümler arasında değişti. unstable_cache veya 'use cache' ile sarmalanmış, “geçerli kullanıcı” için veri döndüren ama önbellek anahtarına kullanıcı ID'sini dahil etmeyen bir fonksiyon, bir kullanıcının verisini bir sonrakine sunar. Aynı durum, dinamik olması gerekirken statik oluşturulan sayfalar ve API yanıtlarının CDN'de önbelleğe alınması için de geçerlidir. Bunu doğrudan test edin: iki farklı tarayıcıda iki farklı kullanıcıyla giriş yapın ve aynı sayfaları yükleyin. İlk kullanıcının verisini ikinciye gösteren her şey bir önbellek hatasıdır ve genellikle ciddi bir hatadır.

  • Kullanıcı veya tenant ID'sini önbelleğe alınan fonksiyonlara açıkça aktarın; böylece anahtarın bir parçası olur.
  • Önbelleğe alınan fonksiyonların içinde çerez veya header okumayın; bunları dışarıda okuyup değerleri içeri aktarın.
  • Kullanıcıya özel veri içeren yanıtlarda Cache-Control: private, no-store gönderin.
  • Build çıktısını kontrol edin: dinamik olmasını beklediğiniz rotalar statik olarak listelenmemelidir.

Kontrol listesini uygulamak

Listeyi dosya bazında değil rota bazında ele alın: her action ve işleyici için onu kim çağırabilir, hangi girdiye güvenir, neyi önbelleğe alır ve ne döndürür? CodeAuditAgent, herkese açık bir GitHub deposu veya yapıştırılmış bir kod parçası üzerinde ilk turu yapabilir ve her bulguyu önem derecesi, CWE, alıntılanan satır ve önerilen bir yamayla raporlar. Hangi rotaların herkese açık olması gerektiği gibi tasarım soruları ise yine ekibinizin muhakemesine kalır.