2023 年 10 月 23 日,星期一
如何在 Next.js 中思考安全性
作者:應用程式路由器 (App Router) 中的 React 伺服器元件 (RSC) 是一種新穎的模式,它消除了許多與傳統方法相關的冗餘和潛在風險。鑑於其新穎性,開發人員和後續的安全團隊可能會發現難以將其現有的安全協議與此模型對齊。
本文檔旨在強調一些需要注意的領域、內建的保護措施,並包含應用程式審計指南。我們尤其關注意外資料洩露的風險。
選擇您的資料處理模型
React 伺服器元件 模糊了伺服器和客戶端之間的界限。資料處理對於理解資訊在何處處理以及隨後在何處可用至關重要。
我們首先需要做的是選擇適合我們專案的資料處理方法。
我們建議您堅持使用一種方法,不要過多混合搭配。這讓在程式碼庫中工作的開發人員和安全審核人員都能清楚地知道預期結果。例外情況會被視為可疑。
HTTP API
如果您要在現有專案中採用伺服器元件,建議的方法是預設將執行階段的伺服器元件視為不安全/不可信,就像 SSR 或在客戶端內一樣。因此,不會假設內部網路或信任區域,工程師可以應用零信任的概念。您可以像在客戶端上執行一樣,僅使用 fetch()
從伺服器元件呼叫自訂 API 端點,例如 REST 或 GraphQL。並傳遞任何 Cookie。
如果您現有的 getStaticProps
/getServerSideProps
連接到資料庫,您可能需要整合模型並將這些也移至 API 端點,以便您擁有一致的處理方式。
注意任何假設從內部網路擷取資料是安全的存取控制。
這種方法可讓您保留現有的組織結構,讓專精於安全性的現有後端團隊可以應用現有的安全措施。如果這些團隊使用 JavaScript 以外的語言,這種方法也能夠良好運作。
它仍然利用了伺服器元件的許多優點,例如傳送較少的程式碼到客戶端,並且固有的資料瀑布可以以低延遲執行。
資料存取層
我們針對新專案建議的方法是在 JavaScript 程式碼庫內建立一個獨立的資料存取層,並將所有資料存取整合在其中。這種方法可確保一致的資料存取,並減少授權錯誤發生的機率。由於整合到單一程式庫中,維護也更容易。可能透過單一程式語言提供更好的團隊凝聚力。您還可以利用較低的執行階段負荷和在請求的不同部分之間共享記憶體快取的能力來獲得更好的效能。
您建置一個內部 JavaScript 程式庫,在將資料提供給呼叫者之前提供自訂資料存取檢查。類似於 HTTP 端點,但在相同的記憶體模型中。每個 API 都應該接受目前的使用者,並在返回資料之前檢查使用者是否有權查看此資料。原則是伺服器元件函式主體應該只看到發出請求的目前使用者被授權存取的資料。
從這一點出發,實作 API 的一般安全措施就會接手。
import { cache } from 'react';
import { cookies } from 'next/headers';
// Cached helper methods makes it easy to get the same value in many places
// without manually passing it around. This discourages passing it from Server
// Component to Server Component which minimizes risk of passing it to a Client
// Component.
export const getCurrentUser = cache(async () => {
const token = cookies().get('AUTH_TOKEN');
const decodedToken = await decryptAndValidate(token);
// Don't include secret tokens or private information as public fields.
// Use classes to avoid accidentally passing the whole object to the client.
return new User(decodedToken.id);
});
import 'server-only';
import { getCurrentUser } from './auth';
function canSeeUsername(viewer: User) {
// Public info for now, but can change
return true;
}
function canSeePhoneNumber(viewer: User, team: string) {
// Privacy rules
return viewer.isAdmin || team === viewer.team;
}
export async function getProfileDTO(slug: string) {
// Don't pass values, read back cached values, also solves context and easier to make it lazy
// use a database API that supports safe templating of queries
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
const userData = rows[0];
const currentUser = await getCurrentUser();
// only return the data relevant for this query and not everything
// <https://www.w3.org/2001/tag/doc/APIMinimization>
return {
username: canSeeUsername(currentUser) ? userData.username : null,
phonenumber: canSeePhoneNumber(currentUser, userData.team)
? userData.phonenumber
: null,
};
}
這些方法應該公開可以直接安全地傳輸到客戶端的物件。我們喜歡將這些稱為資料傳輸物件 (DTO),以闡明它們已準備好供客戶端使用。
它們實際上可能只會被伺服器元件使用。這建立了一個分層結構,安全審核可以主要集中在資料存取層,而使用者介面可以快速迭代。較小的表面積和較少的程式碼覆蓋範圍,更容易發現安全問題。
import {getProfile} from '../../data/user'
export async function Page({ params: { slug } }) {
// This page can now safely pass around this profile knowing
// that it shouldn't contain anything sensitive.
const profile = await getProfile(slug);
...
}
私密金鑰可以儲存在環境變數中,但在此方法中,只有資料存取層應該存取 process.env
。
元件層級資料存取
另一種方法是直接在伺服器元件中放置資料庫查詢。這種方法僅適用於快速迭代和原型設計。例如,適用於小型產品和小型團隊,且團隊成員都了解風險以及如何監控風險。
在這種方法中,您需要仔細審核您的 "use client"
檔案。在審核和檢查 PR 時,請查看所有匯出的函式,如果類型簽章接受過於廣泛的物件(例如 User
),或者包含諸如 token
或 creditCard
之類的屬性,甚至像 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];
// EXPOSED: This exposes all the fields in userData to the client because
// we are passing the data from the Server Component to the Client.
// This is similar to returning `userData` in `getServerSideProps`
return <Profile user={userData} />;
}
'use client';
// BAD: This is a bad props interface because it accepts way more data than the
// Client Component needs and it encourages server components to pass all that
// data down. A better solution would be to accept a limited object with just
// the fields necessary for rendering the profile.
export default async function Profile({ user }: { user: User }) {
return (
<div>
<h1>{user.name}</h1>
...
</div>
);
}
請務必使用參數化查詢,或使用能自動執行參數化查詢的資料庫函式庫,以避免 SQL 注入攻擊。
僅限伺服器端
只能在伺服器端執行的程式碼可以標記為
import 'server-only';
如果客戶端元件嘗試匯入此模組,這將導致建置錯誤。這可以用於確保專有/敏感程式碼或內部業務邏輯不會意外洩漏到客戶端。
傳輸資料的主要方式是使用 React 伺服器元件協定,該協定在將屬性傳遞給客戶端元件時會自動發生。此序列化支援 JSON 的超集。不支援傳輸自訂類別,並且將導致錯誤。
因此,避免過大的物件意外暴露給客戶端的一個好技巧是使用 class
作為資料存取記錄。
在即將推出的 Next.js 14 版本中,您還可以透過在 next.config.js
中啟用 taint
標誌來試用實驗性的 React Taint API。
module.exports = {
experimental: {
taint: true,
},
};
這讓您可以標記不應允許按原樣傳遞給客戶端的物件。
import { experimental_taintObjectReference } from 'react';
export async function getUserData(id) {
const data = ...;
experimental_taintObjectReference(
'Do not pass user data to the client',
data
);
return data;
}
import { getUserData } from './data';
export async function Page({ searchParams }) {
const userData = getUserData(searchParams.id);
return <ClientComponent user={userData} />; // error
}
這並不能防止從該物件中提取資料欄位並將其傳遞出去
export async function Page({ searchParams }) {
const { name, phone } = getUserData(searchParams.id);
// Intentionally exposing personal data
return <ClientComponent name={name} phoneNumber={phone} />;
}
對於像是 token 這樣的唯一字串,也可以使用 taintUniqueValue
來封鎖原始值。
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react';
export async function getUserData(id) {
const data = ...;
experimental_taintObjectReference(
'Do not pass user data to the client',
data
);
experimental_taintUniqueValue(
'Do not pass tokens to the client',
data,
data.token
);
return data;
}
然而,即使這樣也無法阻止衍生值的傳遞。
最好一開始就避免資料進入伺服器組件,可以使用資料存取層。污點檢查藉由指定值提供額外的保護層以防止錯誤,請注意,函式和類別已被禁止傳遞到用戶端組件。多層保護可最大限度地降低資料洩漏的風險。
根據預設,環境變數僅在伺服器上可用。按照慣例,Next.js 也會將任何以 NEXT_PUBLIC_
為前綴的環境變數公開給用戶端。這讓您可以公開某些應該提供給用戶端的明確設定。
伺服器端渲染 (SSR) 與伺服器組件 (RSC)
對於初始載入,Next.js 會在伺服器上同時執行伺服器組件和用戶端組件以產生 HTML。
伺服器組件 (RSC) 在與用戶端組件不同的模組系統中執行,以避免意外地在兩個模組之間洩露資訊。
透過伺服器端渲染 (SSR) 渲染的用戶端組件應被視為與瀏覽器用戶端相同的安全策略。它不應存取任何特權資料或私有 API。強烈建議不要使用駭客手段來規避此保護(例如將資料儲存在全域物件上)。原則是此程式碼應該能夠在伺服器和用戶端上以相同的方式執行。為了符合預設安全性的做法,如果從用戶端組件匯入 server-only
模組,Next.js 將會建置失敗。
閱讀
在 Next.js App Router 中,透過渲染伺服器組件頁面來實作從資料庫或 API 讀取資料。
頁面的輸入是 URL 中的 searchParams、從 URL 對應的動態參數和標頭。用戶端可能會濫用這些輸入,使其成為不同的值。不應信任它們,並且每次讀取時都需要重新驗證。例如,不應使用 searchParam 來追蹤諸如 ?isAdmin=true
之類的內容。僅僅因為使用者位於 /[team]/
上並不代表他們有權存取該團隊,這需要在讀取資料時進行驗證。原則是每次讀取資料時都要重新讀取存取控制和 cookies()
。不要將其作為 props 或 params 傳遞。
渲染伺服器組件不應執行像是變更資料之類的副作用。這並非伺服器組件獨有的。React 自然不鼓勵即使在渲染用戶端組件時(useEffect 之外)產生副作用,例如執行雙重渲染。
此外,在 Next.js 中,無法在渲染期間設定 cookie 或觸發快取重新驗證。這也降低了使用渲染進行資料變更的可能性。
例如,不應使用 searchParams
來執行諸如儲存變更或登出之類的副作用。應該使用伺服器動作來執行這些操作。
這表示 Next.js 模型在按預期使用時,永遠不會將 GET 請求用於副作用。這有助於避免大量的 CSRF 問題。
Next.js 支援自訂路由處理程式 (route.tsx
),可以在 GET 請求中設定 Cookie。這被視為一種特殊處理方式,並非一般模型的一部分。這些處理程式必須明確選擇接受 GET 請求。沒有任何可能會意外收到 GET 請求的全域處理程式。如果您決定建立自訂 GET 處理程式,則可能需要額外的審核。
寫入
在 Next.js App Router 中執行寫入或變更的慣用方法是使用伺服器動作 (Server Actions)。
'use server';
export function logout() {
cookies().delete('AUTH_TOKEN');
}
"use server"
標記會公開一個端點,讓所有匯出的函式都能被客戶端呼叫。識別碼目前是原始碼位置的雜湊值。只要使用者取得動作的 ID 句柄,就可以使用任何參數呼叫它。
因此,這些函式應該始終先驗證目前的使用者是否允許呼叫此動作。函式也應該驗證每個參數的完整性。這可以手動完成,也可以使用像 zod
這樣的工具。
"use server";
export async function deletePost(id: number) {
if (typeof id !== 'number') {
// The TypeScript annotations are not enforced so
// we might need to check that the id is what we
// think it is.
throw new Error();
}
const user = await getCurrentUser();
if (!canDeletePost(user, id)) {
throw new Error();
}
...
}
閉包
伺服器動作也可以在閉包中編碼。這讓動作可以與渲染時使用的資料快照關聯,以便在呼叫動作時使用。
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 專案期間會自動產生私鑰。每次重建都會產生一個新的私鑰,這表示每個伺服器動作只能針對特定的建置版本呼叫。您可能需要使用版本偏移保護來確保在重新部署期間始終呼叫正確的版本。
如果您需要一個更頻繁輪替或在多個建置版本中持續存在的金鑰,您可以使用 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
環境變數手動設定。
透過加密所有閉包變數,您不會意外洩露其中的任何機密。透過簽署,它使攻擊者更難以篡改動作的輸入。
使用閉包的另一個替代方法是使用 JavaScript 中的 .bind(...)
函式。**這些** **不會** **被加密。** 這提供了一個效能的選擇性退出,並且也與客戶端上的 .bind()
一致。
async function deletePost(id: number) {
"use server";
// verify id and that you can still delete it
...
}
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 Cookie 已成為預設設定。
作為額外的保護措施,Next.js 14 中的伺服器動作還會比較 Origin
標頭和 Host
標頭(或 X-Forwarded-Host
)。如果它們不匹配,動作將會被拒絕。換句話說,伺服器動作只能在與託管它的頁面相同的網域上被呼叫。非常舊且不支援 Origin
標頭的過時瀏覽器可能會有風險。
伺服器動作不使用 CSRF 權杖,因此 HTML sanitization (清除) 至關重要。
當使用自訂路由處理程式 (route.tsx
) 時,可能需要額外的審核,因為 CSRF 保護必須在那裡手動完成。傳統的規則適用於此。
錯誤處理
錯誤難免會發生。當伺服器上拋出錯誤時,它們最終會在客戶端程式碼中重新拋出,以便在使用者介面中處理。錯誤訊息和堆疊追蹤可能最終包含敏感資訊。例如:「[信用卡號碼] 不是有效的電話號碼」。
在生產模式下,React 不會將錯誤或被拒絕的 Promise 發送到客戶端。而是會發送一個代表錯誤的雜湊值。這個雜湊值可以用於將多個相同的錯誤關聯在一起,並將錯誤與伺服器日誌關聯。React 會將錯誤訊息替換為其自身的通用訊息。
在開發模式下,伺服器錯誤仍以純文字形式發送到客戶端,以協助除錯。
務必在生產環境中以 Next.js 的生產模式執行生產工作負載。開發模式未針對安全性及效能進行最佳化。
自訂路由和中介軟體 和 中介軟體 被認為是無法使用任何其他內建功能實作的功能的低階逃生出口。這也開啟了框架原本可以防範的潛在陷阱。能力越大,責任越大。
如上所述,route.tsx
路由可以實作自訂的 GET 和 POST 處理程式,如果做得不正確,可能會遭受 CSRF 問題的困擾。
中介軟體可以用於限制對特定頁面的存取。通常最好使用允許清單而不是拒絕清單來執行此操作。這是因為要了解所有不同的資料存取方式可能很棘手,例如是否有重新導向或客戶端請求。
例如,通常只考慮 HTML 頁面。Next.js 也支援可以載入 RSC/JSON payloads 的客戶端導覽。在 Pages Router 中,這曾經是在自訂 URL 中。
為了簡化比對器的撰寫,Next.js App Router 在初始 HTML、客戶端導航和伺服器動作中皆使用頁面的原始 URL。客戶端導航使用 ?_rsc=...
搜尋參數作為快取清除器。
伺服器動作位於其所使用的頁面上,因此繼承相同的存取控制。如果中介軟體允許讀取頁面,您也可以在該頁面上呼叫動作。要限制對頁面上伺服器動作的存取,您可以禁止該頁面的 POST HTTP 方法。
稽核
如果您正在稽核 Next.js App Router 專案,我們建議您特別注意以下幾點:
- 資料存取層。是否有建立隔離資料存取層的既定做法?驗證資料庫套件和環境變數是否未在資料存取層外部匯入。
"use client"
檔案。元件屬性是否預期接收私有資料?類型簽章是否過於廣泛?"use server"
檔案。動作參數是在動作中驗證還是在資料存取層內驗證?使用者是否在動作內重新授權?/[param]/
。帶有括號的資料夾是使用者輸入。參數是否已驗證?middleware.tsx
和route.tsx
擁有很大的權限。請使用傳統技術仔細稽核這些檔案。定期或根據團隊的軟體開發生命週期執行滲透測試或弱點掃描。