Back返回部落格

Next.js 中的安全思維指南

了解 Next.js 內建的安全防護機制,並查看應用程式稽核指南。

應用程式路由 (App Router) 中的 React 伺服器元件 (RSC) 是一種新穎的開發模式,它能消除傳統方法中的許多冗餘和潛在風險。由於這項技術較新,開發者和安全團隊可能會發現難以將現有的安全協議與此模型對齊。

本文旨在指出幾個需要注意的重點領域、內建的防護機制,並提供應用程式稽核指南。我們特別關注意外資料外洩的風險。

選擇您的資料處理模型

React 伺服器元件 模糊了伺服器與客戶端的界線。理解資料在何處處理及隨後可用的位置,對於資料處理至關重要。

首先,我們需要選擇適合專案的資料處理方式:

建議您堅持使用一種方法,避免過度混用。這能讓開發人員和安全稽核人員清楚知道預期行為,異常情況會顯得可疑。

HTTP APIs

若在現有專案中採用伺服器元件,推薦的預設做法是將伺服器元件視為不安全/不可信任的執行環境(如同 SSR 或客戶端)。因此不應假設內部網路或信任區域存在,工程師可應用零信任 (Zero Trust) 概念。您應該像在客戶端執行一樣,僅從伺服器元件使用 fetch() 呼叫自訂 API 端點(如 REST 或 GraphQL),並傳遞所有 cookies。

若您現有的 getStaticProps/getServerSideProps 連接到資料庫,建議整合此模型並將這些功能移至 API 端點,以統一處理方式。

需注意任何假設「內部網路請求是安全」的存取控制機制。

此方法可讓您維持現有組織結構,由專精安全的後端團隊沿用現有安全實踐。若這些團隊使用非 JavaScript 語言,此方法同樣適用。

透過減少發送至客戶端的程式碼量,此方法仍能享受伺服器元件的許多優勢,且固有的資料瀑布流 (data waterfalls) 能以低延遲執行。

資料存取層

對於新專案,我們推薦在 JavaScript 程式碼庫中建立獨立的資料存取層 (Data Access Layer),集中所有資料存取邏輯。此方法能確保一致的資料存取,減少授權錯誤的發生機率。由於整合至單一函式庫,維護也更為容易。使用單一程式語言可能提升團隊協作效率,同時還能享受更低執行時開銷帶來的效能優勢,以及在請求間共享記憶體快照的能力。

您需建立內部 JavaScript 函式庫,在提供資料給呼叫方前執行自訂的資料存取檢查。類似 HTTP 端點,但處於相同記憶體模型。每個 API 都應接受當前使用者,並在回傳資料前檢查使用者權限。原則是伺服器元件函式主體只能看到當前請求使用者有權存取的資料。

從此處開始,即採用實作 API 的常規安全實踐。

data/auth.tsx
import { cache } from 'react';
import { cookies } from 'next/headers';
 
// 快取輔助方法可輕鬆在多處取得相同值
// 而無需手動傳遞。這能避免在伺服器元件間傳遞,
// 降低傳遞至客戶端元件的風險。
export const getCurrentUser = cache(async () => {
  const token = cookies().get('AUTH_TOKEN');
  const decodedToken = await decryptAndValidate(token);
  // 不要將秘密令牌或私人資訊設為公開欄位。
  // 使用類別避免意外將整個物件傳遞至客戶端。
  return new User(decodedToken.id);
});
data/user-dto.tsx
import 'server-only';
import { getCurrentUser } from './auth';
 
function canSeeUsername(viewer: User) {
  // 目前為公開資訊,但可變更
  return true;
}
 
function canSeePhoneNumber(viewer: User, team: string) {
  // 隱私規則
  return viewer.isAdmin || team === viewer.team;
}
 
export async function getProfileDTO(slug: string) {
  // 不要傳遞值,讀取快取值,同時解決上下文問題且更容易實現延遲載入
 
  // 使用支援安全查詢模板的資料庫 API
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
 
  const currentUser = await getCurrentUser();
 
  // 僅回傳此查詢相關的資料而非全部
  // <https://www.w3.org/2001/tag/doc/APIMinimization>
  return {
    username: canSeeUsername(currentUser) ? userData.username : null,
    phonenumber: canSeePhoneNumber(currentUser, userData.team)
      ? userData.phonenumber
      : null,
  };
}

這些方法應公開可直接傳輸至客戶端的安全物件。我們稱之為資料傳輸物件 (DTO),以明確表示它們可供客戶端使用。

實際上它們可能僅由伺服器元件使用。這形成了一個分層,安全稽核可主要關注資料存取層,而 UI 能快速迭代。較小的表面積和較少的程式碼量使安全問題更易被發現。

import {getProfile} from '../../data/user'
export async function Page({ params: { slug } }) {
  // 此頁面現在可安全傳遞此設定檔,
  // 知道它不應包含任何敏感資訊。
  const profile = await getProfile(slug);
  ...
}

秘密金鑰可儲存在環境變數中,但在此方法中僅資料存取層應存取 process.env

元件層級資料存取

另一種方法是直接將資料庫查詢寫在伺服器元件中。此方法僅適用於快速迭代和原型開發,例如小型產品或團隊成員皆了解風險並知道如何防範的情況。

在此方法中,您需仔細稽核 "use client" 檔案。在稽核和審查 PR 時,檢查所有匯出的函式,若型別簽章接受過於寬泛的物件如 User,或包含如 tokencreditCard 的屬性。即使是如 phoneNumber 等隱私敏感欄位也需額外審查。客戶端元件不應接受超過其工作所需的最小資料量。

import Profile from './components/profile.tsx';
 
export async function Page({ params: { slug } }) {
  const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
  const userData = rows[0];
  // 暴露風險:這會將 userData 中的所有欄位暴露給客戶端,
  // 因為我們將資料從伺服器元件傳遞至客戶端。
  // 這類似於在 `getServerSideProps` 中回傳 `userData`
  return <Profile user={userData} />;
}
'use client';
// 不良實踐:此 props 介面設計不良,因為它接受的資料量
// 遠超過客戶端元件所需,且鼓勵伺服器元件傳遞所有資料。
// 更好的解決方案是接受僅包含渲染設定檔所需欄位的有限物件。
export default async function Profile({ user }: { user: User }) {
  return (
    <div>
      <h1>{user.name}</h1>
      ...
    </div>
  );
}

始終使用參數化查詢,或能為您處理此事的資料庫函式庫,以避免 SQL 注入攻擊。

伺服器專用程式碼

標記僅應在伺服器執行的程式碼:

import 'server-only';

若客戶端元件嘗試匯入此模組,建置將報錯。這可確保專有/敏感程式碼或內部業務邏輯不會意外洩漏至客戶端。

傳遞資料的主要方式是使用 React 伺服器元件協議,當傳遞 props 至客戶端元件時會自動觸發。此序列化支援 JSON 的超集。不支援傳遞自訂類別,會導致錯誤。

因此,避免過大物件意外暴露至客戶端的技巧是對資料存取記錄使用 class

在即將發布的 Next.js 14 中,您也可透過在 next.config.js 啟用 taint 標記,嘗試實驗性的 React Taint APIs

next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};

這讓您能標記不應被傳遞至客戶端的物件。

app/data.ts
import { experimental_taintObjectReference } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    '請勿將使用者資料傳遞至客戶端',
    data
  );
  return data;
}
app/page.tsx
import { getUserData } from './data';
 
export async function Page({ searchParams }) {
  const userData = getUserData(searchParams.id);
  return <ClientComponent user={userData} />; // 錯誤
}

這無法防止從物件中提取資料欄位並傳遞:

app/page.tsx
export async function Page({ searchParams }) {
  const { name, phone } = getUserData(searchParams.id);
  // 故意暴露個人資料
  return <ClientComponent name={name} phoneNumber={phone} />;
}

對於如令牌等唯一字串,也可使用 taintUniqueValue 封鎖原始值。

app/data.ts
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    '請勿將使用者資料傳遞至客戶端',
    data
  );
  experimental_taintUniqueValue(
    '請勿將令牌傳遞至客戶端',
    data,
    data.token
  );
  return data;
}

然而,這仍無法封鎖衍生值。

最好一開始就避免資料進入伺服器元件——使用資料存取層。污染檢查 (Taint checking) 透過指定值提供額外的錯誤防護層,請注意函式和類別已被禁止傳遞至客戶端元件。多重防護能最小化資料洩漏風險。

預設情況下,環境變數僅在伺服器可用。Next.js 依慣例也會將任何以 NEXT_PUBLIC_ 為前綴的環境變數公開至客戶端。這讓您能公開某些應對客戶端可用的明確設定。

SSR 與 RSC

對於初始載入,Next.js 會在伺服器上同時執行伺服器元件和客戶端元件以產生 HTML。

伺服器元件 (RSC) 在獨立的模組系統中執行,與客戶端元件分隔,以避免意外資訊洩漏。

透過伺服器端渲染 (SSR) 的客戶端元件應視為與瀏覽器客戶端相同的安全政策。它不應取得任何特權資料或私有 API 的存取權。強烈不建議嘗試繞過此保護的破解方法(如在全域物件儲存資料)。原則是此程式碼應能在伺服器與客戶端以相同方式執行。根據預設安全實踐,若從客戶端元件匯入 server-only 模組,Next.js 會使建置失敗。

讀取

在 Next.js 應用程式路由中,從資料庫或 API 讀取資料是透過渲染伺服器元件頁面實現的。

頁面的輸入包括 URL 中的 searchParams、從 URL 映射的動態參數和標頭。這些可能被客戶端濫用為不同值。它們不應被信任,且每次讀取時都需重新驗證。例如,不應使用 searchParam 追蹤如 ?isAdmin=true 的狀態。僅因使用者在 /[team]/ 路徑上,不代表他們有權存取該團隊,讀取資料時需驗證此權限。原則是每次讀取資料時都重新檢查存取控制和 cookies(),不要將其作為 props 或參數傳遞。

渲染伺服器元件時絕不應執行如變更的副作用。這並非伺服器元件獨有。React 自然也不鼓勵在渲染客戶端元件時(在 useEffect 外)執行副作用,例如透過雙重渲染等方式。

此外,Next.js 在渲染期間無法設定 cookies 或觸發快照重新驗證。這也抑制了將渲染用於變更的作法。

例如,searchParams 不應用於執行如儲存變更或登出等副作用。應改用伺服器動作 (Server Actions) 處理這些操作。

這意味著在正確使用時,Next.js 模型從不將 GET 請求用於副作用。這有助避免大量的 CSRF 問題來源。

Next.js 確實支援自訂路由處理器 (route.tsx),可在 GET 請求上設定 cookies。這被視為逃生艙口,而非通用模型的一部分。這些處理器需明確選擇接受 GET 請求。沒有可能意外接收 GET 請求的全域處理器。若您決定建立自訂 GET 處理器,這些可能需要額外稽核。

寫入

在 Next.js 應用程式路由中,執行寫入或變更的慣用方式是使用 伺服器動作

actions.ts
'use server';
 
export function logout() {
  cookies().delete('AUTH_TOKEN');
}

"use server" 註解會公開一個端點,使所有匯出的函式可由客戶端呼叫。目前識別碼是原始碼位置的雜湊值。只要使用者取得動作的 id 控制代碼,就能以任何參數呼叫它。

因此,這些函式應始終先驗證當前使用者是否有權呼叫此動作。函式也應驗證每個參數的完整性。可手動完成或使用如 zod 的工具。

actions.ts
"use server";
 
export async function deletePost(id: number) {
  if (typeof id !== 'number') {
    // TypeScript 註解不會強制執行,
    // 因此我們可能需要檢查 id 是否符合預期。
    throw new Error();
  }
  const user = await getCurrentUser();
  if (!canDeletePost(user, id)) {
    throw new Error();
  }
  ...
}

閉包 (Closures)

伺服器動作 (Server Actions) 也可以透過 閉包 來編碼。這讓動作能與渲染時使用的資料快照關聯,以便在動作被調用時使用這些資料:

app/page.tsx
export default function Page() {
  const publishVersion = await getLatestVersion();
  async function publish() {
    "use server";
    if (publishVersion !== await getLatestVersion()) {
      throw new Error('The version has changed since pressing publish');
    }
    ...
  }
  return <button action={publish}>Publish</button>;
}
 

閉包的快照必須在伺服器被調用時發送到客戶端並返回。

在 Next.js 14 中,閉包中的變數會在發送到客戶端前使用動作 ID 進行加密。預設情況下,Next.js 專案在構建時會自動生成一個私鑰。每次重新構建都會生成新的私鑰,這意味著每個伺服器動作只能在特定的構建版本中被調用。你可能需要使用 版本偏差保護 (Skew Protection) 來確保在重新部署時總是調用正確的版本。

如果需要更頻繁輪換或在多個構建中保持一致的密鑰,可以透過 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 環境變數手動配置。

透過加密所有閉包中的變數,可以避免意外洩露其中的敏感資訊。透過簽名機制,攻擊者更難篡改動作的輸入。

另一種不使用閉包的方法是使用 JavaScript 中的 .bind(...) 函數。這些不會被加密。 這提供了一個性能優化的選擇退出機制,同時也與客戶端的 .bind() 行為保持一致。

app/page.tsx
async function deletePost(id: number) {
  "use server";
  // 驗證 id 並確認仍可刪除
  ...
}
 
export async function Page({ slug }) {
  const post = await getPost(slug);
  return <button action={deletePost.bind(null, post.id)}>
    Delete
  </button>;
}

原則是伺服器動作 ("use server") 的參數列表必須始終被視為不可信任的,輸入必須經過驗證。

CSRF

所有伺服器動作都可以透過普通的 <form> 調用,這可能使其暴露於 CSRF 攻擊。在底層,伺服器動作總是使用 POST 實現,並且只允許這種 HTTP 方法調用它們。這本身就防止了現代瀏覽器中的大多數 CSRF 漏洞,特別是預設啟用 Same-Site cookies 的情況下。

作為額外保護,Next.js 14 中的伺服器動作還會比較 Origin 標頭與 Host 標頭(或 X-Forwarded-Host)。如果不匹配,動作將被拒絕。換句話說,伺服器動作只能在與託管它的頁面相同的主機上調用。不支援 Origin 標頭的非常舊且過時的瀏覽器可能存在風險。

伺服器動作不使用 CSRF 令牌,因此 HTML 淨化至關重要。

當使用自訂路由處理器 (route.tsx) 時,可能需要額外的審查,因為 CSRF 保護必須手動實現。傳統規則在此適用。

錯誤處理 (Error Handling)

錯誤難免發生。當伺服器上拋出錯誤時,最終會在客戶端代碼中重新拋出,以便在 UI 中處理。錯誤訊息和堆疊追蹤可能包含敏感資訊。例如 [信用卡號碼] 不是有效的電話號碼

在生產模式下,React 不會將錯誤或拒絕的 Promise 發送到客戶端。而是發送一個代表錯誤的哈希值。此哈希可用於將多個相同錯誤關聯起來,並將錯誤與伺服器日誌關聯。React 會將錯誤訊息替換為自己的通用訊息。

在開發模式下,伺服器錯誤仍以純文字形式發送到客戶端以協助調試。

對於生產工作負載,始終以 Next.js 的生產模式運行非常重要。開發模式不會針對安全性和性能進行優化。

自訂路由和中間件 (Custom Routes and Middleware)

自訂路由處理器中間件 被視為低階的逃生艙口,用於無法透過其他內建功能實現的特性。這也可能開啟框架原本防護的潛在陷阱。能力越大,責任越大。

如上所述,route.tsx 路由可以實現自訂的 GET 和 POST 處理器,如果處理不當可能會遭受 CSRF 問題。

中間件可用於限制對某些頁面的訪問。通常最好使用允許清單而非拒絕清單。因為很難知道所有可能訪問資料的不同方式,例如是否存在重寫或客戶端請求。

例如,通常只考慮 HTML 頁面。Next.js 還支援可載入 RSC/JSON 負載的客戶端導航。在 Pages Router 中,這曾經位於自訂 URL 中。

為了使匹配器更易於編寫,Next.js App Router 始終使用頁面的普通 URL 作為初始 HTML、客戶端導航和伺服器動作的基礎。客戶端導航使用 ?_rsc=... 搜尋參數作為快取破壞器。

伺服器動作位於它們使用的頁面上,因此繼承相同的訪問控制。如果中間件允許讀取頁面,則也可以調用該頁面上的動作。要限制對頁面上伺服器動作的訪問,可以禁止該頁面的 POST HTTP 方法。

審計 (Audit)

如果要審計 Next.js App Router 專案,以下是我們建議特別關注的幾點:

  • 資料訪問層 (Data Access Layer)。 是否有獨立的資料訪問層實踐?驗證資料庫套件和環境變數是否未在資料訪問層之外導入。
  • "use client" 檔案。 元件屬性是否預期接收私有資料?類型簽名是否過於寬泛?
  • "use server" 檔案。 動作參數是否在動作內或資料訪問層中驗證?是否在動作內重新授權使用者?
  • /[param]/ 帶有括號的資料夾是使用者輸入。參數是否經過驗證?
  • middleware.tsxroute.tsx 擁有很大權限。花額外時間使用傳統技術審計這些檔案。定期或與團隊的軟體開發生命週期一致地進行滲透測試或漏洞掃描。