了解如何搭配表單,和在表單以外觸發 Server Actions 後,我們來嘗試理解它背後的原理,以及目前版本,使用 Server Actions 可能存在的資安風險,以及幾個在斟酌要不要使用 Server Actions 時,可以思考的方向。
我們打開瀏覽器 DevTools,觀察一下觸發 Server Actions 時 Network 的狀況:
可以發現,當我們按下「提交表單後」,Next 以目前路由 ( /users ) 為 endpoint,打了一支 POST request。而使用者輸入的內容則是帶在 request payload 裡:
假如看 source code,可以在 server-action-reducer.ts
檔案中看到,Next 定義了一個 fetchServerAction
的 function,負責發送 POST request,並 return一個 object,儲存要不要 redirect、action 處理結果、revalidation 細節等等資訊:
async function fetchServerAction(
state: ReadonlyReducerState,
{ actionId, actionArgs }: ServerActionAction
): Promise<FetchServerActionResult> {
const body = await encodeReply(actionArgs)
const res = await fetch('', {
method: 'POST',
headers: {
Accept: RSC_CONTENT_TYPE_HEADER,
[ACTION]: actionId,
[NEXT_ROUTER_STATE_TREE]: encodeURIComponent(JSON.stringify(state.tree)),
...(process.env.__NEXT_ACTIONS_DEPLOYMENT_ID &&
process.env.NEXT_DEPLOYMENT_ID
? {
'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID,
}
: {}),
...(state.nextUrl
? {
[NEXT_URL]: state.nextUrl,
}
: {}),
},
body,
})
…
}
…
return {
redirectLocation,
revalidatedParts,
}
接著看到 186 行,ServerActionReducer
執行的事件。ServerActionReducer
根據官方註解,負責呼叫 Server Actions 與處理過程產生的 side effects。當中呼叫了 fetchServerAction
,並根據 return 的 object 來處理後續 redirection 和 revalidation。
/*
* This reducer is responsible for calling the server action and processing any side-effects from the server action.
* It does not mutate the state by itself but rather delegates to other reducers to do the actual mutation.
*/
export function serverActionReducer(
state: ReadonlyReducerState,
action: ServerActionAction
): ReducerState {
...
mutable.inFlightServerAction = createRecordFromThenable(
fetchServerAction(state, action)
)
...
}
所以 Server Actions 還是透過 API 來處理 client 與 server 溝通,只是 Next 底層幫你寫好了這支 API。
Next 在設計 Server Actions 時,採納了 Progressive Enhancement 的策略。讓 <form>
中的 Server Actions,在沒有 JavaScript 的環境也可以執行。
什麼是 Progressive Enhancement 呢?簡單來說,是盡可能讓不同瀏覽器的使用者都能使用網頁的基本功能,再視瀏覽器的情況,決定功能體驗的完整度。
所以當我們禁用瀏覽器 JavaScript,可以發現,仍然可以在 <form>
中觸發 Server Actions:
但從影片可以看到,禁用 JavaScript 後,按下提交表單,瀏覽器就沒有發出 Fetch/XHR 請求。那瀏覽器是怎麼跟 Server 達成溝通的呢?
我們切到 DevTools 的 Elements,看一下這份表單的 HTML tag,會發現 <form>
上有三個 attributes:
<form action enctype="multipart/form-data" method="POST">...</form>
action:
指定表單提交後,要將 FormData 傳送到的地方。比方說:<form action="/action_page.php">...</form>
,就是將 FormData 傳送到 action_page.php
這個檔案。
假如沒有帶任何 URL,則傳送到表單所在的檔案。所以我們表單的 FormData 會被傳到表單所在的 html 檔案中。
enctype:指定 FormData 加密的方式
method:指定表單提交後,送出的 HTTP 請求類型。假如 method 為 POST,則表單提交後,FormData 會被夾在 request body 中。
所以透過 <form>
原生的 attributes,瀏覽器可以在 JavaScript 載入失敗或禁用的情況下,將 FormData 傳給 server 執行 Server Actions。
Progressive Enhencement 只適用於以 action 和 formAction prop 觸發 Server Actions 的情況,假如使用 useTransition 則不適用 Progressive Enhencement。
為了防止 server 負擔過重,Next 預設 request body 的大小最多為 1MB。假如 request body 需要超過 1MB,可以修改 next.config.js
:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
serverActionsBodySizeLimit: '2mb'
},
};
module.exports = nextConfig;
大致理解 Server Actions 的原理後,接下來想跟大家分享一個,我在網路上看到關於現行版本 (v13.5),Server Actions 可能存在的資安疑慮,使用上必須務必注意的地方。
試想一個情境:假如我要拿到用戶資料,需經過以下步驟:
所以我寫了一段程式碼:
// 假設密碼是 THIS IS SECRET
const getSecret = async () => {
...
return 'THIS IS SECRET';
};
...
export default async function Page() {
const secret = await getSecret();
// 以密碼連線資料庫,並撈取用戶資料
const getData = async () => {
'use server';
const status = await connectToDb(secret);
status === 'success' && getProfile();
...
};
return (
// 提交表單後會撈取用戶資料
<form
action={getData}
>
<button
type='submit'
>
取得用戶資料
</button>
</form>
);
}
運作起來沒問題,但假如打開 DevTools 的 Network,查看 Next 幫我們寫的 API 的 payload,會發現一件令人害怕的事:
連線資料庫的密碼也被夾在 payload 中。假如打開網頁原始碼,也會發現 <form>
裡面被塞了幾個隱形的 <input>
,其中一個的 value 還帶有密碼資訊:
<form action='' encType='multipart/form-data' method='POST'>
...
<input type='hidden' name='$ACTION_1:1' value='["THIS IS SECRET"]' />
<button type='submit'>提交表單</button>
</form>
具體原因還不清楚,可能跟目前 Server Actions 和 components 溝通的機制有關,假如之後有時間我會再試著從從 source code 中找答案。
總之,目前假如在 closure 中去使用外層的變數,以上述例子來說,我在 getData()
去使用 secret 變數,似乎會讓變數也暴露在 request payload 裡,Next 渲染時也會在 HTML 中塞入幾個隱形的 <input>
,其中一個 <input>
的 value 會是該變數的值。
所以目前相對安全的做法,是將 secret 宣告在 getData
中:
const getSecret = async () => {
return 'THIS IS SECRET';
};
export default async function Page() {
const getData = async () => {
'use server';
// 將 secret 宣告在 server action 中
const secret = await getSecret();
const status = await connectToDb(secret);
status === 'success' && getProfile();
};
return (
<form
action={getData}
>
<button
type='submit'
>
取得用戶資料
</button>
</form>
);
}
這樣當 getData()
觸發後,secret 就不會出現在 request payload 中:<form>
中也不會出現 value 為 secret 的隱藏 <input>
。
參考資料:
Web Dev Cody: Next's Server Actions Might Not Be That Safe...
Theo-t3.gg: I Fixed Next.js Server Actions
補充:假如有碰到 Jest 無法針對 Server Actions 跑 testing 的問題,可以將 Next 版本升到 v13.5.4 以上,詳情可以參考這個 Pull Request
Server Actions 的介紹就到這邊。這幾天在讀 Server Actions 的內容時,也有找幾個前後端工程師分享這個概念,但大部分的人都不覺得 Server Actions 讓 client 與 server 溝通變得更簡單😆
主要有幾個因素:
總結來說,目前 Server Actions 建議可以在規模較小、後端邏輯較單純的專案上嘗試,不建議使用在 production grade 的產品上。
儘管 Server Actions 目前似乎還存在頗多可討論的空間,但它的確提供了開發個人全端專案上,一個便利的選擇。而且它也還在 alpha 階段,我們就靜待 Vercel 之後的調整吧!
謝謝大家耐心的閱讀,我們明天見!