सामग्री पर जाएँ
CodeAuditAgent
सभी लेख

Next.js App Router सिक्योरिटी चेकलिस्ट

Next.js App Router के लिए व्यावहारिक सिक्योरिटी चेकलिस्ट: server actions, route handlers, middleware, env vars, CSRF, headers, webhooks और per-user caching।

· 8 मिनट का लेख · Lina Source LLC

App Router ने बहुत सारा कोड ब्राउज़र से वापस सर्वर पर भेज दिया। यह ज़्यादातर सिक्योरिटी के लिए अच्छा है: queries, secrets और बिज़नेस लॉजिक अब वहाँ चलते हैं जहाँ यूज़र उन्हें पढ़ नहीं सकते। साथ ही इसने यह रेखा धुँधली कर दी कि क्या पब्लिक endpoint है और क्या सिर्फ़ फ़ंक्शन कॉल जैसा दिखता है। Next.js ऐप्स के ज़्यादातर गंभीर बग उसी धुँधलेपन से आते हैं।

यह चेकलिस्ट उन समस्याओं को कवर करती है जो हमें App Router कोडबेस में सबसे ज़्यादा दिखती हैं, उसी क्रम में जिसमें उन्हें जाँचना ठीक है। इनमें कुछ भी विचित्र नहीं है; हर आइटम एक ऐसी जगह है जहाँ फ़्रेमवर्क की सुविधा किसी trust boundary को छिपा देती है।

1. Server actions पब्लिक endpoints हैं

'use server' से चिह्नित फ़ंक्शन एक HTTP endpoint में compile होता है। जो कोई भी आपकी साइट लोड कर सकता है, वह action ID ढूँढकर उसे मनमाने arguments के साथ कॉल कर सकता है, चाहे आपका UI उस बटन को कभी render करे या न करे। ग़ैर-admins से form छिपाना उसके पीछे के action को सुरक्षित नहीं करता।

हर action को किसी भी API handler जैसे तीन क़दम चाहिए: कॉलर को authenticate करें, इनपुट validate करें, और जिस रिकॉर्ड को छुआ जा रहा है उस पर authorize करें। ये जाँचें action की body के भीतर ही रखें। Form render करने वाले page component में लगी जाँच तब चलती है जब पेज लोड होता है, तब नहीं जब action कॉल होता है, इसलिए वह कुछ भी सुरक्षित नहीं करती।

'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'),
  });

  // ownership चेक write का ही हिस्सा है, कोई अलग lookup नहीं
  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');
}

उन helper फ़ाइलों पर नज़र रखें जो कई actions export करती हैं। किसी 'use server' module में export किया गया हर फ़ंक्शन कॉल करने योग्य है, वह भी जिसे किसी ने किसी internal स्क्रिप्ट के लिए जोड़ा और भूल गया।

2. Route handlers को भी वही जाँचें चाहिए

app/api में मौजूद route handlers ज़्यादा साफ़ तौर पर endpoints हैं, पर उनकी विफलता का तरीक़ा वही है: किसी dynamic segment से रिकॉर्ड लोड कर लिया जाता है बिना यह जाँचे कि उसका मालिक कौन है। हाल के Next.js वर्ज़न में params एक Promise है; उसे await करें, validate करें, और query को कॉलर तक scope करें।

// 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' },
  });
}

जिन रिकॉर्ड्स का कॉलर मालिक नहीं है, उनके लिए 403 के बजाय 404 लौटाएँ, ताकि endpoint यह पुष्टि न करे कि कौन-सी IDs मौजूद हैं। Authorization के फ़ैसलों के लिए params, searchParams, headers या cookies पर कभी भरोसा न करें; ये सब हमलावर के नियंत्रण वाला इनपुट हैं।

3. Middleware अकेले कोई auth सीमा नहीं है

Middleware (Next.js 16 में इसका नाम proxy है) साइन-आउट यूज़र्स को redirect करने और headers सेट करने के लिए उपयोगी है। वह इकलौती जगह नहीं होनी चाहिए जहाँ authorization होता है। Matchers ग़लत लिखना आसान है, नए routes उनके बाहर जुड़ जाते हैं, और server actions पेज path पर post करते हैं, जो शायद उस pattern से मेल न खाए जो आपके मन में था। 2025 में CVE-2025-29927 ने दिखाया कि एक गढ़ा हुआ internal header कुछ Next.js वर्ज़न में middleware पूरी तरह छोड़ सकता था।

Middleware को सुविधा की परत मानें। असली जाँच डेटा के पास होनी चाहिए: हर action और handler में, या उससे बेहतर, किसी ऐसी data access layer में जिससे हर सर्वर-साइड read गुज़रे। Data access layer एक अकेला server-only module है जो getProjectForUser(projectId) जैसे फ़ंक्शन देता है और session चेक तथा ownership filter अपने भीतर करता है। पेज, actions और route handlers डेटाबेस क्लाइंट को सीधे कॉल करने के बजाय उसे कॉल करते हैं, इसलिए कोई नया route जाँच भूल ही नहीं सकता: भूलने के लिए कोई बिना जाँच वाला रास्ता है ही नहीं।

4. सर्वर का कोड सर्वर पर ही रखें

NEXT_PUBLIC_ से शुरू होने वाला कोई भी environment variable build के समय JavaScript bundle में inline हो जाता है और हर विज़िटर को दिखता है। किसी publishable Stripe key या analytics ID के लिए यह सही है, और बाक़ी हर चीज़ के लिए लीक। कोडबेस में NEXT_PUBLIC_ खोजें और जाँचें कि हर एक किसी होर्डिंग पर भी सुरक्षित रहेगी या नहीं। उल्टी ग़लती भी होती है: बिना prefix वाला variable किसी client component में पढ़ा जाता है, ब्राउज़र में undefined आता है, और कोई error हटाने के लिए उसका नाम बदलकर NEXT_PUBLIC_ कर देता है। अगर कोई value ब्राउज़र में चाहिए, तो पहले पूछें कि ब्राउज़र के पास वह होनी भी चाहिए या नहीं।

// 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;
  // सिर्फ़ वही fields लौटाएँ जो UI को चाहिए, कभी पूरी row नहीं
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

server-only पैकेज build को फ़ेल कर देता है अगर कोई client component उस module को import करे, जिससे डेटाबेस क्लाइंट और secret पढ़ने वाले helpers ब्राउज़र तक पहुँचने से बच जाते हैं। यह भी देखें कि server components, client components को props में क्या भेजते हैं: उस सीमा के पार भेजी गई हर चीज़ पेज में serialize हो जाती है, इसलिए पूरी user row भेजना उसका password hash और internal flags ब्राउज़र तक पहुँचा देता है।

5. CSRF: जानें कि फ़्रेमवर्क क्या संभालता है

Server actions सिर्फ़ POST स्वीकार करते हैं, और Next.js उन्हें चलाने से पहले Origin header की host से तुलना करता है। अगर आप किसी proxy के पीछे या कई डोमेन पर डिप्लॉय करते हैं, तो serverActions.allowedOrigins सोच-समझकर कॉन्फ़िगर करें, न कि errors ख़त्म होने तक उसे चौड़ा करते जाएँ।

Route handlers को ऐसी कोई सुरक्षा नहीं मिलती। अगर कोई POST, PUT या DELETE handler cookies से authenticate करता है, तो किसी दूसरी साइट का form अब भी उस पर submit कर सकता है। सिर्फ़ content type जाँचना अकेले काफ़ी नहीं: एक HTML form बिना preflight के urlencoded, multipart और text/plain bodies भेज सकता है, और body को ढीले ढंग से parse करने वाला handler उन्हें स्वीकार कर लेगा। Session cookies SameSite=Lax या Strict के साथ सेट करें, GET पर कभी state न बदलें, और cookie से authenticate होने वाले mutations पर Origin header जाँचें। जो handlers सिर्फ़ Authorization header में bearer token स्वीकार करते हैं, वे क्लासिक CSRF के सामने नहीं पड़ते।

6. सिक्योरिटी headers और CSP

Next.js डिफ़ॉल्ट रूप से बहुत कम सिक्योरिटी headers भेजता है। इन्हें next.config में headers() फ़ंक्शन से जोड़ें, या middleware में जब आपको सख़्त Content-Security-Policy के लिए प्रति-request nonce चाहिए।

  • Content-Security-Policy, आदर्श रूप से nonce-आधारित, object-src 'none' और base-uri 'self' के साथ।
  • Strict-Transport-Security लंबे max-age के साथ, जब HTTPS हर जगह मज़बूत हो जाए।
  • X-Content-Type-Options: nosniff।
  • Referrer-Policy: strict-origin-when-cross-origin।
  • clickjacking रोकने के लिए CSP में frame-ancestors, या X-Frame-Options।
  • next.config में poweredByHeader: false, ताकि X-Powered-By header हट जाए।

7. Rate limiting

कोई built-in rate limiter नहीं है। साइन-इन, साइन-अप, पासवर्ड रीसेट, OTP सत्यापन और हर वह चीज़ जिसकी प्रति कॉल लागत है (ईमेल, SMS, AI requests) को प्रति यूज़र और प्रति IP सीमाएँ चाहिए। In-memory counters serverless या multi-instance डिप्लॉयमेंट पर काम नहीं करते; Redis या अपने डेटाबेस जैसा साझा स्टोर इस्तेमाल करें। सीमाएँ action या handler के भीतर लगाएँ, जहाँ authenticated यूज़र पता होता है, सिर्फ़ middleware में नहीं। सीमाओं की key ऐसी चीज़ पर रखें जिसे हमलावर सस्ते में बदल न सके: authenticated routes के लिए अकाउंट, और reset तथा OTP फ़्लो के लिए लक्ष्य ईमेल या फ़ोन नंबर, IP के अलावा। Retry-After header के साथ 429 लौटाएँ ताकि वाजिब क्लाइंट सही तरह पीछे हटें।

8. Webhooks की पुष्टि raw body पर करें

Webhook signatures ठीक उन्हीं bytes पर गिनी जाती हैं जो प्रोवाइडर ने भेजे। Body को JSON के रूप में parse करके फिर से serialize करना वे bytes बदल देता है और verification तोड़ देता है, जिससे लोग उसे छोड़ने के लिए ललचाते हैं। किसी route handler में body को req.text() से पढ़ें और बाक़ी कुछ भी करने से पहले verify करें।

// 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 });
  }

  // Handlers idempotent होने चाहिए: प्रोवाइडर retry करते हैं और दो बार भेज सकते हैं
  await handleStripeEvent(event);
  return new Response('ok');
}

प्रोसेस किए गए event IDs को unique constraint के साथ स्टोर करें ताकि दोबारा भेजी या replay की गई delivery किसी subscription को दो बार न दे दे, और क्रम से बाहर आने वाले events संभालें, क्योंकि प्रोवाइडर delivery का क्रम पक्का नहीं करते।

9. प्रति-यूज़र डेटा को globally cache न करें

App Router आक्रामक रूप से cache करता है, और डिफ़ॉल्ट वर्ज़न-दर-वर्ज़न बदले हैं। unstable_cache या 'use cache' में लिपटा कोई फ़ंक्शन जो मौजूदा यूज़र का डेटा लौटाता है पर अपनी cache key में यूज़र ID शामिल नहीं करता, एक यूज़र का डेटा अगले को परोस देगा। यही बात उन पेजों पर लागू होती है जो static render हुए पर dynamic होने चाहिए थे, और API responses की CDN caching पर भी। इसे सीधे टेस्ट करें: दो ब्राउज़रों में दो अलग यूज़र के रूप में साइन इन करें और वही पेज लोड करें। जो कुछ भी पहले यूज़र का डेटा दूसरे को दिखाए वह cache बग है, और आमतौर पर गंभीर।

  • Cached फ़ंक्शन में यूज़र या tenant ID स्पष्ट रूप से पास करें ताकि वह key का हिस्सा बन जाए।
  • Cached फ़ंक्शन के भीतर cookies या headers न पढ़ें; उन्हें बाहर पढ़ें और values अंदर पास करें।
  • प्रति-यूज़र डेटा वाले responses पर Cache-Control: private, no-store भेजें।
  • Build आउटपुट जाँचें: जिन routes के dynamic होने की उम्मीद है वे static के रूप में सूचीबद्ध नहीं होने चाहिए।

चेकलिस्ट चलाना

सूची को फ़ाइल-दर-फ़ाइल नहीं, route-दर-route देखें: हर action और handler के लिए, उसे कौन कॉल कर सकता है, वह किस इनपुट पर भरोसा करता है, वह क्या cache करता है और क्या लौटाता है। CodeAuditAgent किसी पब्लिक GitHub रिपॉज़िटरी या पेस्ट किए गए स्निपेट पर पहला पास कर सकता है, हर फ़ाइंडिंग को severity, CWE, कोट की गई लाइन और सुझाए गए पैच के साथ रिपोर्ट करते हुए; डिज़ाइन के सवाल, जैसे कौन-से routes पब्लिक होने ही चाहिए, अब भी आपकी टीम के विवेक माँगते हैं।