假設 Alice 只加入了工作區 alpha。她請求 /workspaces/alpha/documents/doc-b/,卻拿到了 beta 的文件。服務端認出了 Alice,也檢查了工作區成員身分,兩步都透過了。請求究竟從哪裡走偏?
下面用虛構的文件和身分做一個教學案例。執行示例需要 Node.js 22 或更新版本,讀程式碼的方法也適用於其他語言和框架。這次要查的事很具體:成員檢查針對哪個工作區,資料查詢又被限制在哪個工作區?
先把請求和存取規則放在一起
把方法、路徑、身分和預期結果記在同一處:
GET /workspaces/alpha/documents/doc-b/
演示身份:alice,只是 alpha 的成员
存储数据:doc-b 属于 beta
预期:404,响应中没有文档
错误结果:200,返回 Beta release notes
案例採用一條簡單規則:工作區成員可以讀這個工作區的全部文件,沒有單獨分享,也沒有管理員例外。對於 alpha 的成員,不屬於 alpha 的文件與不存在的文件返回相同狀態碼和正文;如果 URL 直接指向使用者未加入的工作區,就在呼叫文件儲存前返回 403。
接下來要追兩件事:已認證的使用者是否屬於 URL 中的工作區,返回的文件是否也屬於這個工作區。第一項檢查透過後,資料查詢仍然需要落實第二項。
排查真實問題時,再補上應用建置或原始碼版本,確認手頭路由與處理那次請求的程式碼一致。本機重現使用合成資料即可。
先找路由註冊,再進處理函式
把 request-flow.mjs 和 request-flow.test.mjs 下載到同一個目錄。在編輯器裡搜路徑中有辨識度的詞,比如 workspaces、documents。如果裝了 ripgrep,可以這樣縮小範圍:
rg -n 'workspaces|documents' request-flow.mjs
rg -n 'middleware|runMiddleware|requireWorkspaceMember|findById|findInWorkspace' request-flow.mjs
開啟 createApp。路由物件把方法、路徑匹配、中介軟體和處理函式放在了一起:
const route = {
method: 'GET',
pattern: /^\/workspaces\/([a-z0-9-]+)\/documents\/([a-z0-9-]+)\/$/,
middleware: [authenticate, requireWorkspaceMember],
handler: lookup === 'unscoped' ? getDocumentUnsafe : getDocument,
};
繼續往下讀幾行。handle 匹配 new URL(request.url).pathname,檢查請求方法,再把兩個捕獲段分別賦給 workspaceId 和 documentId。這兩個值都不取自查詢參數。示例路徑只接受小寫字母、數字和連字元,還要求末尾有上面那條斜槓;這裡只實作了教程需要的一條路由。
真實框架裡,完整路徑可能分散在父路由 /workspaces/:workspaceId 與子路由 /documents/:documentId 中。要把掛載位置一起讀到,並確認父級參數怎樣傳給子級。只搜到處理函式,還不知道呼叫它之前套了哪些中介軟體。
按實際執行順序讀中介軟體
runMiddleware 用 reduceRight 一層層包裝處理函式。陣列是 [authenticate, requireWorkspaceMember],所以先執行身分認證,再檢查成員身分;每一層呼叫 next() 後,才會進入下一層,最後到達處理函式。return next() 又把結果逐層交回 handle;中間丟擲的 HttpError 會由它的 catch 轉成響應。
Request → 匹配路径和方法 → authenticate
→ requireWorkspaceMember → 查询文档 → JSON Response
↘ 抛错 → handle 的 catch → 错误 Response
authenticate 用請求的 Authorization 頭查一張固定對映表,再把結果放進 ctx.user。demo-alice、demo-bob 是公開的測試輸入,用來代替已驗證的會話身分。藉此可以追到案例裡的使用者來源;真實系統怎麼驗證會話或令牌,仍要進入對應實作去讀。
接著看成員檢查:
async function requireWorkspaceMember(ctx, next) {
const { workspaceId } = ctx.params;
const allowed = ctx.user.workspaceIds.includes(workspaceId);
ctx.trace.push(`membership:${workspaceId}:${allowed ? 'allowed' : 'denied'}`);
if (!allowed) throw new HttpError(403, 'Forbidden');
return next();
}
Alice 請求的路徑帶著 alpha,因此 allowed 為真。這個函式既沒檢查 documentId,也沒讀取文件。筆記應當準確寫成「Alice 可以進入 alpha 的路由」。只記「已鑑權」,很容易把後面的文件歸屬也當成已經查過了。
順手記下請求會在哪裡停住。演示身分缺失或未知時,返回 401,不會進入成員檢查;使用者不是目標工作區的成員時,返回 403,不會進入文件處理函式。如果某個中介軟體先呼叫 next()、之後才決定是否放行,處理函式可能早已執行,所以 next() 的位置也得讀。
分開記錄使用者、請求的工作區和文件歸屬
針對這條出錯請求,四行就能把容易混淆的值拆開:
| 要追的值 | 來源 | 本次請求中的值 | 接下來去哪裡找 |
|---|---|---|---|
| 使用者與成員清單 | authenticate 讀取演示身分對映 |
Alice,['alpha'] |
requireWorkspaceMember |
| 請求的工作區 | 路由捕獲的第一段 | alpha |
成員檢查,再到儲存呼叫的實參 |
| 請求的文件 | 路由捕獲的第二段 | doc-b |
文件查詢 |
| 文件實際歸屬 | 儲存的記錄 | beta |
查詢條件與響應正文 |
每讀到一個來源和使用位置,就在旁邊補檔名、行號。換成大專案,這些值可能屬於不同包裡的不同型別,但仍應分清:URL 表達使用者想請求什麼,身分資料說明使用者加入了哪裡,儲存記錄才說明文件屬於哪裡。
兩個 ID 一起進了處理函式。現在看它們是否一起傳給了儲存層。
處理函式再短,也要點開資料查詢
故意保留錯誤的處理函式是這樣的:
async function getDocumentUnsafe(ctx) {
ctx.trace.push(`lookup:unscoped:${ctx.params.documentId}`);
const document = await ctx.store.findById(ctx.params.documentId);
if (!document) throw new HttpError(404, 'Not found');
return json(200, document);
}
傳給儲存層的只有 documentId。進入 createStore 裡的 findById,能看到條件僅為 document.id === documentId,沒有比較工作區,序列化響應之前也沒有後續歸屬檢查。前面的中介軟體准許的是 alpha,這裡卻可能取出任意工作區的記錄。
觸發問題不需要併發,也不用偽造身分。Alice 繼續用自己的身分和獲准存取的 alpha 路徑,只把文件 ID 換成案例裡已知的 doc-b。檢查與讀取針對了不同的資源範圍。
在真實專案裡,光憑函式名或少一個參數,還不能下同樣結論:資料存取層可能自己加租戶條件,也可能使用帶範圍的資料庫會話,或者依賴資料庫行級存取策略。要確認實際選中了哪個實作,以及相關設定。這個例子的查詢全在記憶體謂詞裡,可以直接讀完。
讓反例走完整條處理鏈
在兩個下載檔案所在的目錄執行:
node request-flow.mjs
node --test request-flow.test.mjs
無需安裝依賴、開帳號或啟動服務。makeRequest 構造 Web Request,測試把它傳給應用的 handle,再檢查返回的 Web Response。example.invalid 只作為記憶體裡解析的 URL;程式碼不呼叫 fetch,也不監聽埠。
演示程式會列印下面三條響應,每條下面還會列出經過的步驟和儲存呼叫記錄:
normal: 200 {"id":"doc-a","workspaceId":"alpha","title":"Alpha release notes"}
reproduce: 200 {"id":"doc-b","workspaceId":"beta","title":"Beta release notes"}
fixed: 404 {"error":"Not found"}
錯誤請求經過的步驟是:
request:GET -> route:alpha:doc-b -> authenticate:alice -> membership:alpha:allowed -> lookup:unscoped:doc-b -> response:200
trace 陣列是專門加在教學程式碼裡的記錄,不會放進 HTTP 響應。測試還檢查了真正返回的文件,以及儲存層是否收到 findById('doc-b')。把這三處放在一起,就能從「經過哪些分支」一直核對到「最終拿回什麼資料」。
名字以 reproduce: 開頭的測試,在舊實作返回 beta 文件時才透過。它透過說明錯誤確實被複現了。另一項測試則要求修正後的實作對同一請求返回 404,兩者的目的要分清。
把已經檢查過的工作區帶進查詢
修正後的處理函式保留了兩個路由值:
async function getDocument(ctx) {
const { workspaceId, documentId } = ctx.params;
ctx.trace.push(`lookup:scoped:${workspaceId}:${documentId}`);
const document = await ctx.store.findInWorkspace(workspaceId, documentId);
if (!document) throw new HttpError(404, 'Not found');
return json(200, document);
}
findInWorkspace 同時要求 document.workspaceId === workspaceId 和 document.id === documentId。成員中介軟體剛剛檢查過的,正是這個 workspaceId。示例沒有在兩步之間修改 ctx.params,文件與成員關係在執行期間也保持不變。
請求 /workspaces/alpha/documents/doc-b/ 時,現在查詢不到匹配記錄。文件不存在,或者屬於其他工作區,都會得到 404 和 {"error":"Not found"}。相同的狀態碼與正文不會直接告訴呼叫方是哪一種情況;測試沒有驗證耗時等其他側通道。
先全域性讀取、再檢查 document.workspaceId,也能阻止這一次響應洩露。把工作區限制放進查詢,則能在呼叫處明確讀取範圍,並讓目前處理函式拿不到其他工作區的記錄。不過,本例的 Array.find 仍會檢視記錄欄位來判斷是否匹配;呼叫記錄展示的是查詢參數,不能用來推算資料庫 I/O 或證明物理隔離。
換成 SQL 或 ORM 後,還要看實際查詢條件、參數繫結,並用真實資料存取層驗證。成員資格撤銷、文件移動、跨工作區分享和其他路由,都需要按各自規則繼續檢查。
同時驗證正常存取和提前結束的位置
測試檔案共有十項測試:一項確認舊錯誤可以重現,九項檢查修正後的應用。有些測試會遍歷多個輸入:
| 請求或條件 | 預期響應 | 還要檢查什麼 |
|---|---|---|
Alice 讀 alpha/doc-a;Bob 讀 beta/doc-b |
200,正確文件 |
兩個 ID 都傳入 findInWorkspace |
Alice 讀 alpha/doc-b |
404,沒有文件 |
查詢仍被限制在 alpha |
Alice 讀 alpha/missing |
與上一行狀態碼、正文相同 | 沒有匹配的文件 |
| 演示身分缺失或未知 | 401 |
不進入成員檢查,不呼叫儲存層 |
Alice 讀 beta/doc-b |
403 |
不呼叫儲存層 |
Alice 讀 alpha/doc-b?workspaceId=beta |
404 |
查詢參數不能替換路由裡的工作區 |
| 未知路徑;已知路徑但方法錯誤 | 404;帶 Allow: GET 的 405 |
不呼叫儲存層 |
| 儲存層丟擲意外異常 | 500,通用錯誤正文 |
異常走到統一響應處理 |
表裡的縮寫路徑展開後是 /workspaces/{workspaceId}/documents/{documentId}/,查詢串接在末尾斜槓之後。兩次正常讀取,能擋住「把所有請求都拒絕掉」的假修復;沒有儲存呼叫的斷言,則能發現先讀文件、最後才拒絕的實作。只看最終狀態碼,會漏掉這些區別。
這些測試驗證的是這條路由在程序內的請求處理、中介軟體順序、查詢參數和 JSON 響應,沒有覆蓋 HTTP 傳輸、反向代理、真實身分驗證或資料庫。應用到自己的專案時,從真正接收 HTTP 的適配層開始,確認它選擇的就是已經追過的處理函式和中介軟體。
留下別人能複查的閱讀筆記
圍繞那條請求,把值經過的關鍵位置寫下來:
触发:Alice + GET /workspaces/alpha/documents/doc-b/
路由:createApp 捕获 alpha 与 doc-b。
成员检查:requireWorkspaceMember 允许 Alice 访问 alpha。
旧查询:getDocumentUnsafe 只把 doc-b 传给 findById。
实际结果:200,返回记录的 workspaceId 是 beta。
修复:getDocument 把 alpha 和 doc-b 一起传给 findInWorkspace。
回归:同一请求变为 404;两条合法成员请求仍返回 200。
验证范围:内存请求链;未验证真实 HTTP 适配层和存储。
附上原始碼位置和測試命令。同事問「這個路由不是已經鑑權了嗎」時,這份筆記可以直接回答:檢查了什麼,以及工作區從哪一步開始沒有被繼續使用。