Przejdź do treści
CodeAuditAgent
Wszystkie artykuły

Lista kontrolna bezpieczeństwa dla App Routera w Next.js

Lista kontrolna bezpieczeństwa dla App Routera Next.js: akcje serwerowe, route handlery, middleware, zmienne środowiskowe, CSRF, nagłówki i webhooki.

· 8 min czytania · Lina Source LLC

App Router przeniósł sporo kodu z przeglądarki z powrotem na serwer. To w większości zysk dla bezpieczeństwa: zapytania, sekrety i logika biznesowa działają teraz tam, gdzie użytkownicy nie mogą ich odczytać. Zatarł jednak granicę między tym, co jest publicznym endpointem, a tym, co tylko wygląda jak wywołanie funkcji. Większość poważnych błędów w aplikacjach Next.js bierze się właśnie z tego zatarcia.

Ta lista kontrolna obejmuje problemy, które najczęściej widzimy w bazach kodu z App Routerem, w kolejności, w jakiej warto je sprawdzać. Nic tu nie jest egzotyczne; każdy punkt to miejsce, w którym wygoda frameworka ukrywa granicę zaufania.

1. Akcje serwerowe są publicznymi endpointami

Funkcja oznaczona 'use server' kompiluje się do endpointu HTTP. Każdy, kto potrafi wczytać Twoją stronę, może znaleźć identyfikator akcji i wywołać ją z dowolnymi argumentami, niezależnie od tego, czy Twój interfejs kiedykolwiek wyrenderuje przycisk, który jej używa. Ukrycie formularza przed osobami bez uprawnień administratora nie chroni stojącej za nim akcji.

Każda akcja potrzebuje tych samych trzech kroków co dowolny handler API: uwierzytelnić wywołującego, zwalidować dane wejściowe i autoryzować dostęp do konkretnego rekordu. Umieść te sprawdzenia wewnątrz ciała samej akcji. Sprawdzenie w komponencie strony renderującym formularz wykonuje się przy wczytaniu strony, a nie przy wywołaniu akcji, więc nie chroni niczego.

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

  // Sprawdzenie własności jest częścią zapisu, a nie osobnym odczytem
  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');
}

Uważaj na pliki pomocnicze eksportujące wiele akcji. Każda eksportowana funkcja w module z 'use server' jest wywoływalna, łącznie z tą, którą ktoś dodał na potrzeby wewnętrznego skryptu i o niej zapomniał.

2. Route handlery wymagają tych samych sprawdzeń

Route handlery w app/api są bardziej oczywistymi endpointami, ale mają ten sam tryb awarii: dynamiczny segment służy do wczytania rekordu bez sprawdzenia, kto jest jego właścicielem. W nowszych wersjach Next.js params jest obietnicą; czekaj na nią, waliduj ją i ograniczaj zapytanie do wywołującego.

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

Dla rekordów, których wywołujący nie jest właścicielem, zwracaj 404 zamiast 403, żeby endpoint nie potwierdzał, które identyfikatory istnieją. Nigdy nie ufaj params, searchParams, nagłówkom ani ciasteczkom przy decyzjach autoryzacyjnych; wszystko to są dane wejściowe kontrolowane przez atakującego.

3. Middleware samo w sobie nie jest granicą uwierzytelniania

Middleware (w Next.js 16 przemianowane na proxy) przydaje się do przekierowywania wylogowanych użytkowników i ustawiania nagłówków. Nie powinno być jedynym miejscem, w którym zachodzi autoryzacja. Łatwo pomylić się w matcherach, nowe trasy powstają poza nimi, a akcje serwerowe wysyłają żądania na ścieżkę strony, która może nie pasować do wzorca, jaki miałeś na myśli. W 2025 roku CVE-2025-29927 pokazało, że spreparowany nagłówek wewnętrzny mógł sprawić, że niektóre wersje Next.js całkowicie pomijały middleware.

Traktuj middleware jako warstwę wygody. Prawdziwe sprawdzenie należy umieścić przy danych: w każdej akcji i każdym handlerze albo, jeszcze lepiej, w warstwie dostępu do danych, przez którą przechodzi każdy odczyt po stronie serwera. Warstwa dostępu do danych to pojedynczy moduł wyłącznie serwerowy, który udostępnia funkcje takie jak getProjectForUser(projectId) i wykonuje wewnątrz sprawdzenie sesji oraz filtr własności. Strony, akcje i route handlery wywołują ją zamiast bezpośrednio klienta bazy danych, więc nowa trasa nie może zapomnieć o sprawdzeniu: nie ma niesprawdzonej ścieżki, o której dałoby się zapomnieć.

4. Trzymaj kod serwerowy na serwerze

Każda zmienna środowiskowa z przedrostkiem NEXT_PUBLIC_ jest wstawiana do paczki JavaScriptu podczas budowania i widoczna dla każdego odwiedzającego. To poprawne dla publikowalnego klucza Stripe czy identyfikatora analityki, a wyciek dla wszystkiego innego. Przeszukaj bazę kodu pod kątem NEXT_PUBLIC_ i sprawdź, czy każda z tych wartości mogłaby bezpiecznie wisieć na billboardzie. Zdarza się też odwrotna pomyłka: zmienna bez przedrostka jest odczytywana w komponencie klienckim, w przeglądarce wraca jako undefined i ktoś zmienia jej nazwę na NEXT_PUBLIC_, żeby błąd zniknął. Jeśli wartość jest potrzebna w przeglądarce, najpierw zapytaj, czy przeglądarka powinna ją mieć w ogóle.

// 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;
  // Zwracaj tylko pola potrzebne interfejsowi, nigdy całego wiersza
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

Pakiet server-only sprawia, że budowanie kończy się błędem, gdy moduł zaimportuje komponent kliencki, co chroni klientów bazy danych i funkcje pomocnicze czytające sekrety przed trafieniem do przeglądarki. Zwracaj też uwagę, co komponenty serwerowe przekazują jako właściwości do komponentów klienckich: wszystko, co przechodzi przez tę granicę, jest serializowane do strony, więc przekazanie całego wiersza użytkownika wysyła do przeglądarki jego hasz hasła i wewnętrzne flagi.

5. CSRF: wiedz, co obejmuje framework

Akcje serwerowe przyjmują wyłącznie POST, a Next.js przed ich uruchomieniem porównuje nagłówek Origin z hostem. Jeśli wdrażasz za proxy albo na wielu domenach, skonfiguruj serverActions.allowedOrigins świadomie, zamiast poszerzać tę listę, aż znikną błędy.

Route handlery nie mają takiej ochrony. Jeśli handler POST, PUT lub DELETE uwierzytelnia się ciasteczkami, formularz na innej stronie wciąż może do niego wysłać żądanie. Sprawdzanie samego typu treści nie wystarczy: formularz HTML może wysyłać ciała urlencoded, multipart i text/plain bez żądania preflight, a handler pobłażliwie parsujący ciało je przyjmie. Ustawiaj ciasteczka sesji z SameSite=Lax lub Strict, nigdy nie zmieniaj stanu metodą GET i sprawdzaj nagłówek Origin przy mutacjach uwierzytelnianych ciasteczkiem. Handlery przyjmujące wyłącznie token bearer w nagłówku Authorization nie są narażone na klasyczny CSRF.

6. Nagłówki bezpieczeństwa i CSP

Next.js domyślnie wysyła bardzo niewiele nagłówków bezpieczeństwa. Dodaj je w next.config przez funkcję headers() albo w middleware, gdy potrzebujesz nonce dla każdego żądania na potrzeby ścisłej Content Security Policy.

  • Content-Security-Policy, najlepiej oparte na nonce, z object-src 'none' i base-uri 'self'.
  • Strict-Transport-Security z długim max-age, gdy HTTPS działa już wszędzie solidnie.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • frame-ancestors w CSP albo X-Frame-Options, żeby zapobiec clickjackingowi.
  • poweredByHeader: false w next.config, żeby usunąć nagłówek X-Powered-By.

7. Ograniczanie liczby żądań

Nie ma wbudowanego mechanizmu ograniczania liczby żądań. Logowanie, rejestracja, resetowanie hasła, weryfikacja OTP i wszystko, co kosztuje pieniądze przy każdym wywołaniu (e-mail, SMS, żądania AI), potrzebują limitów na użytkownika i na adres IP. Liczniki w pamięci nie działają we wdrożeniach serverless ani wieloinstancyjnych; użyj współdzielonego magazynu, takiego jak Redis albo Twoja baza danych. Stosuj limity wewnątrz akcji lub handlera, gdzie znany jest uwierzytelniony użytkownik, a nie tylko w middleware. Kluczuj limity po czymś, czego atakujący nie zmieni tanio: po koncie dla tras uwierzytelnionych oraz po docelowym adresie e-mail lub numerze telefonu w procesach resetu i OTP, oprócz adresu IP. Zwracaj 429 z nagłówkiem Retry-After, żeby uczciwi klienci poprawnie się wycofywali.

8. Weryfikuj webhooki na podstawie surowego ciała

Podpisy webhooków są liczone z dokładnie tych bajtów, które wysłał dostawca. Sparsowanie ciała jako JSON i ponowne zserializowanie zmienia te bajty i psuje weryfikację, co kusi, żeby ją pominąć. W route handlerze odczytaj ciało przez req.text() i zweryfikuj je przed czymkolwiek innym.

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

  // Handlery muszą być idempotentne: dostawcy ponawiają i mogą dostarczyć dwa razy
  await handleStripeEvent(event);
  return new Response('ok');
}

Zapisuj identyfikatory przetworzonych zdarzeń z ograniczeniem unikalności, żeby ponowione lub powtórzone dostarczenie nie przyznało subskrypcji dwa razy, i obsługuj zdarzenia przychodzące w złej kolejności, bo dostawcy nie gwarantują kolejności dostarczania.

9. Nie buforuj globalnie danych per użytkownik

App Router buforuje agresywnie, a ustawienia domyślne zmieniały się między wersjami. Funkcja opakowana w unstable_cache albo 'use cache', która zwraca dane „bieżącego użytkownika”, ale nie zawiera identyfikatora użytkownika w kluczu pamięci podręcznej, poda dane jednego użytkownika następnemu. To samo dotyczy stron renderowanych statycznie, które powinny być dynamiczne, oraz buforowania odpowiedzi API na CDN-ie. Przetestuj to wprost: zaloguj się jako dwóch różnych użytkowników w dwóch przeglądarkach i wczytaj te same strony. Wszystko, co pokazuje drugiemu użytkownikowi dane pierwszego, jest błędem pamięci podręcznej i zwykle poważnym.

  • Przekazuj identyfikator użytkownika lub najemcy jawnie do funkcji buforowanych, żeby stał się częścią klucza.
  • Nie odczytuj ciasteczek ani nagłówków wewnątrz funkcji buforowanych; odczytaj je na zewnątrz i przekaż wartości.
  • Wysyłaj Cache-Control: private, no-store w odpowiedziach zawierających dane per użytkownik.
  • Sprawdź wynik budowania: trasy, które mają być dynamiczne, nie powinny być wymienione jako statyczne.

Przechodzenie przez listę kontrolną

Przechodź listę trasa po trasie, a nie plik po pliku: dla każdej akcji i każdego handlera ustal, kto może je wywołać, jakim danym wejściowym ufają, co buforują i co zwracają. CodeAuditAgent może wykonać pierwsze przejście po publicznym repozytorium GitHub lub wklejonym fragmencie kodu, zgłaszając każde znalezisko z wagą, CWE, zacytowaną linią i proponowaną poprawką; pytania projektowe, takie jak które trasy w ogóle powinny być publiczne, nadal wymagają oceny Twojego zespołu.