2023 年 10 月 23 日 星期一
如何在 Next.js 中思考安全性
作者:App Router 中的 React 伺服器元件 (RSC) 是一種新穎的典範,它消除了與傳統方法相關聯的許多冗餘和潛在風險。 鑑於其新穎性,開發人員以及隨後的安全團隊可能會發現將其現有的安全協定與此模型對齊具有挑戰性。
本文檔旨在重點介紹一些需要注意的領域、內建的保護措施,並包含應用程式稽核指南。 我們特別關注意外資料洩露的風險。
選擇您的資料處理模型
React 伺服器元件 模糊了伺服器和客戶端之間的界線。 資料處理在理解資訊在哪裡被處理以及隨後可用方面至關重要。
我們需要做的第一件事是選擇適合我們專案的資料處理方法。
我們建議您堅持一種方法,不要過多地混合和匹配。 這使得在您的程式碼庫中工作的開發人員和安全稽核人員都能清楚地了解期望。 例外情況會顯得可疑。
HTTP API
如果您要在現有專案中採用伺服器元件,建議的方法是在執行階段將伺服器元件視為預設不安全/不受信任的元件,就像 SSR 或在客戶端中一樣。 因此,沒有內部網路或信任區域的假設,工程師可以應用零信任的概念。 相反,您只從伺服器元件調用自訂 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),以闡明它們已準備好被客戶端使用。
實際上,它們可能僅被伺服器元件使用。 這創建了一個分層,安全稽核可以主要關注資料存取層,而 UI 可以快速迭代。 較小的表面積和更少的程式碼涵蓋範圍使其更容易發現安全問題。
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} />;
}
對於諸如令牌之類的唯一字串,也可以使用 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;
}
但是,即使這樣也無法阻止衍生值。
最好避免資料首先進入伺服器元件 - 使用資料存取層。 Taint 檢查通過指定值來提供額外的保護層以防止錯誤,請注意,函數和類別已經被阻止傳遞到客戶端元件。 更多的層可以最大限度地減少某些東西洩露的風險。
預設情況下,環境變數僅在伺服器上可用。 按照慣例,Next.js 也會將任何以 NEXT_PUBLIC_
為字首的環境變數暴露給客戶端。 這讓您可以暴露某些應可供客戶端使用的明確配置。
SSR vs RSC
對於初始載入,Next.js 將在伺服器上同時運行伺服器元件和客戶端元件以產生 HTML。
伺服器元件 (RSC) 在與客戶端元件分離的模組系統中執行,以避免意外暴露兩個模組之間的資訊。
通過伺服器端渲染 (SSR) 渲染的客戶端元件應被視為與瀏覽器客戶端相同的安全策略。 它不應獲得對任何特權資料或私有 API 的存取權。 極力反對使用駭客嘗試規避此保護(例如在全球物件上隱藏資料)。 原則是此程式碼應能夠在伺服器和客戶端上執行相同操作。 為了與預設安全實務保持一致,如果從客戶端元件導入 server-only
模組,Next.js 將使建置失敗。
讀取
在 Next.js App Router 中,從資料庫或 API 讀取資料是通過渲染伺服器元件頁面來實作的。
頁面的輸入是 URL 中的 searchParams、從 URL 映射的動態參數和標頭。 這些可能會被客戶端濫用而成為不同的值。 它們不應被信任,每次讀取時都需要重新驗證。 例如,searchParam 不應用於追蹤諸如 ?isAdmin=true
之類的事項。 僅僅因為使用者在 /[team]/
上並不意味著他們有權存取該團隊,這需要在讀取資料時進行驗證。 原則是始終在讀取資料時重新讀取存取控制和 cookies()
。 不要將其作為屬性或參數傳遞。
渲染伺服器元件絕不應執行副作用,例如變更。 這並非伺服器元件獨有。 React 自然不鼓勵副作用,即使在渲染客戶端元件(useEffect 之外)時也是如此,例如通過執行雙重渲染。
此外,在 Next.js 中,無法在渲染期間設定 Cookie 或觸發快取的重新驗證。 這也不鼓勵將渲染用於變更。
例如,searchParams
不應用於執行副作用,例如保存變更或登出。 伺服器行為應改為用於此目的。
這意味著 Next.js 模型在使用時絕不會將 GET 請求用於副作用。 這有助於避免大量的 CSRF 問題。
Next.js 確實支援自訂路由處理程序 (route.tsx
),它可以在 GET 上設定 Cookie。 它被認為是一種應急出口,而不是通用模型的一部分。 這些必須顯式選擇接受 GET 請求。 沒有可能會意外接收 GET 請求的全方位處理程序。 如果您確實決定創建自訂 GET 處理程序,則這些可能需要額外的稽核。
寫入
在 Next.js App Router 中執行寫入或變更的慣用方法是使用 伺服器行為。
'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 專案的建置過程中自動生成。 每次重新建置都會生成一個新的私鑰,這意味著每個伺服器行為只能針對特定的建置進行調用。 您可能希望使用 Skew Protection,以確保您始終在重新部署期間調用校正版本。
如果您需要更頻繁輪換或在多個建置中持久存在的密鑰,您可以手動使用 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 清理至關重要。
當使用自訂路由處理程序 (route.tsx
) 時,可能需要額外的稽核,因為 CSRF 保護必須在那裡手動完成。 傳統規則在那裡適用。
錯誤處理
錯誤會發生。 當伺服器上拋出錯誤時,它們最終會在客戶端程式碼中重新拋出,以便在 UI 中處理。 錯誤訊息和堆疊追蹤可能最終包含敏感資訊。 例如,[信用卡號碼] 不是有效的電話號碼
。
在生產模式下,React 不會向客戶端發出錯誤或拒絕的 Promise。 相反,會發送一個哈希值,表示錯誤。 此哈希值可用於將多個相同錯誤關聯在一起,並將錯誤與伺服器日誌關聯起來。 React 將錯誤訊息替換為其自己的通用訊息。
在開發模式下,伺服器錯誤仍然以純文字形式發送到客戶端,以幫助進行調試。
對於生產工作負載,始終在生產模式下運行 Next.js 非常重要。 開發模式不針對安全性和效能進行優化。
自訂路由和中介軟體
自訂路由處理程序 和 中介軟體 被認為是用於無法使用任何其他內建功能實作的功能的低階應急出口。 這也開啟了框架以其他方式防止的潛在危險。 能力越大,責任越大。
如上所述,route.tsx
路由可以實作自訂 GET 和 POST 處理程序,如果未正確完成,則可能會遭受 CSRF 問題。
中介軟體可用於限制對某些頁面的訪問。 通常最好使用允許列表而不是拒絕列表來執行此操作。 這是因為可能很難知道訪問資料的所有不同方式,例如是否存在重寫或客戶端請求。
例如,通常只考慮 HTML 頁面。 Next.js 也支援可以載入 RSC/JSON 有效負載的客戶端導航。 在 Pages Router 中,這過去是在自訂 URL 中。
為了使編寫匹配器更容易,Next.js App Router 始終對初始 HTML、客戶端導航和伺服器行為使用頁面的純 URL。 客戶端導航使用 ?_rsc=...
searchParam 作為快取破壞器。
伺服器行為位於它們使用的頁面上,因此繼承相同的存取控制。 如果中介軟體允許讀取頁面,您也可以調用該頁面上的操作。 要限制對頁面上伺服器行為的訪問,您可以禁止該頁面上的 POST HTTP 方法。
稽核
如果您要對 Next.js App Router 專案進行稽核,以下是我們建議額外關注的幾件事
- 資料存取層。 是否有針對隔離的資料存取層的既定實務? 驗證資料庫套件和環境變數是否未在資料存取層外部導入。
"use client"
檔案。 元件屬性是否期望私有資料? 類型簽名是否過於寬泛?"use server"
檔案。 操作參數是否在操作內或資料存取層內驗證? 使用者是否在操作內重新授權?/[param]/
。 帶括號的資料夾是用戶輸入。 參數是否已驗證?middleware.tsx
和route.tsx
具有很大的權力。 花費額外的時間使用傳統技術稽核這些。 定期或與您團隊的軟體開發生命週期保持一致地執行滲透測試或漏洞掃描。