7.36 憑證在請求中怎麼帶:附上的決定由誰做
本章幫讀者做一個決定:使用者登入之後,接下來每一個請求要靠什麼把身分帶上去。
它接在 7.31 認證方式選型 之後——那一章決定拿什麼驗證使用者,這一章決定驗完之後的每一次呼叫怎麼證明「還是同一個人」。
本章涵蓋與不涵蓋
本章聚焦憑證在每次請求裡怎麼被附上,以及這個選擇把攻擊面換成了什麼形狀。憑證本身用什麼機制是上游的決定(7.31 決定拿什麼驗證使用者、7.28 密碼學原語選型 決定原語)。登入狀態放伺服器還是放 token、以及隨之而來的撤銷成本,見 Session 處理——那條軸問狀態放哪、本章問憑證怎麼送,兩者可以任意組合,放外部 session store 的狀態一樣可以用 cookie 或 header 帶。機器對機器的呼叫沒有本章的核心機制(瀏覽器依網域自動附上),入口是 7.29 API 認證的信任邊界分層 與 7.34 機器憑證的機制選型。頁面本身怎麼避免執行到別人的程式碼(輸出編碼、內容安全政策)屬前端的責任面,章節層尚未寫,最小判準見 cross-site scripting 的設計責任段;本章把它的結果當成前提來判讀,而不涵蓋它的做法。
判讀軸:附上的決定由誰做
憑證出現在請求裡,來源只有兩種:瀏覽器依規則自動放進去,或程式碼在每次呼叫時明確寫進去。 這一項決定攻擊者要先做到什麼,才有辦法讓這個憑證為他工作。
自動附上時,攻擊者需要的是誘發一個請求。明確附上時,攻擊者需要的是讀到那段憑證。兩者是不同的能力,門檻不同、得手之後能做的事也不同,因此這個選擇是在挑「自己這個系統比較擋得住哪一種前提」。
自動附上:攻擊者不必讀到憑證
本章的自動附上聚焦 cookie,因為它是這一邊的主流形態;瀏覽器快取的 HTTP Basic 憑證與 TLS 用戶端憑證同樣由瀏覽器自動附上,而它們不受 SameSite 涵蓋——那一層管的是 cookie。其餘幾層照常適用:來源檢查看的是請求宣告的來源,額外值由頁面帶出、伺服器比對,兩者都與憑證用什麼形式帶無關。
Cookie 的性質是瀏覽器按網域規則把它放進請求,而這個規則看的是請求要送去哪裡,與請求從哪個頁面發起無關。攻擊者因此有一條不必碰到憑證的路徑:在自己控制的頁面上排一個指向目標網域的請求——一個會自動送出的表單、一張圖片的網址、一段背景呼叫——受害者的瀏覽器照規則附上 cookie,那個請求就以受害者的身分執行了。這是跨站請求偽造。
這條路徑的能力上限值得寫清楚,因為它決定了防護要守什麼。攻擊者從頭到尾讀不到 cookie,也讀不到回應——同源政策讓他的頁面拿不到來自另一個來源的回應內容。他能做的是發動寫入:改密碼、改綁定信箱、送出一筆轉帳、刪掉一份資料。動作只能在受害者的瀏覽器裡發生,也只能在受害者還登入著的時候發生。
防護因此都在回答同一個問題:這個請求是從我自己的頁面發出來的嗎。三種做法互補,執行者各自不同:
SameSite 屬性讓瀏覽器在跨站請求上略過這個 cookie。這一層要先講清楚一件事:**把沒有標示的 cookie 當成 Lax 的是 Chromium 系的瀏覽器,Firefox 與 Safari 沒有跟進。**於是「預設值已經幫我擋掉了」的實際涵蓋範圍由使用者手上是哪個瀏覽器決定,而那不在自己的控制之下——這是它不能當唯一防線的第一個理由。它還有兩個邊界要一起記住。第一,Lax 在頂層導覽的 GET 上仍然附上 cookie,於是用 GET 執行寫入的端點落在保護之外。第二,「站」的判定用的是可註冊網域而非完整主機名,於是同一個註冊網域下的不同子網域之間不算跨站。Strict 連從外部連結點進來的第一個請求都不附,代價是使用者從搜尋結果或別人的分享連結進來時會看到未登入的頁面。
Origin 或 Referer 檢查由伺服器端執行:看請求宣告的來源在不在自己的允許清單裡。這一層的價值在於它不依賴瀏覽器的預設值,也不受子網域的判定規則影響。實務上咬人的是標頭缺席時怎麼判——放行等於這一層不存在,拒絕會擋掉部分正常流量,而這個決定要在導入時做完並寫下來。
一個攻擊者讀不到的額外值(常稱 CSRF token)由頁面帶出、由請求送回,伺服器兩相比對。它成立的前提是攻擊者的頁面讀不到這個值,而提供這個前提的正是同源政策。前提一旦被跨站指令碼拿掉——攻擊者的程式碼跑在自己的頁面上,讀這個值不受任何限制——這一層就跟著失效。
Fetch Metadata 標頭(Sec-Fetch-Site 這一組)由瀏覽器附上、JavaScript 改不了,伺服器據此判斷請求是同站發起還是跨站發起。它與前三層的缺口都不重疊:不像 Referer 會被來源站的政策剝掉、不依賴 cookie 屬性的預設值、也不需要頁面先帶出什麼。代價是舊瀏覽器不送這組標頭,缺席時要決定放行或拒絕——與 Origin 檢查同一個問題。
明確附上:攻擊者必須先讀到憑證
Authorization header 不會自己出現在請求裡,程式碼要在每次呼叫時寫上去。這個性質直接把自動附上那條誘發路徑封住:攻擊者的頁面誘發得出請求,誘發不出一個帶著正確 header 的請求,因為那個值只有自己的程式碼知道要放什麼。
代價落在憑證的住址上。程式碼要讀得到它才寫得進去,於是它得放在 JavaScript 拿得到的位置——瀏覽器的本地儲存(localStorage 或 sessionStorage)、或某個模組的變數。攻擊者一旦能在這個頁面上執行程式碼,就能把它讀走。
讀走與借用的差別在持續性與地點。借用只發生在受害者的瀏覽器裡,也只在會話還有效的時候;讀走之後憑證離開了受害者的裝置,攻擊者能在任何地方、任何時間用它,直到過期或被撤銷。這條差別讓「哪一種比較安全」沒有一般性的答案,而讓它變成一個關於自己這個系統的問題。
門檻的不對稱要跟後果一起看。誘發一個請求的門檻低——一個連結、一封信裡的一張圖就夠了,而且這件事一定會被試。在別人的頁面上執行程式碼的門檻高,要先有一個注入點。所以自動附上這一邊要配防護,而明確附上這一邊是把風險押在「這個頁面不會跑到別人的程式碼」這個前提上,而維持那個前提的是前端的輸出處理與內容安全政策,不是這個決定本身。第三方腳本在這裡要單獨算:頁面上每多掛一個外部腳本,這個前提就多依賴一個自己不控制的發佈流程。
把「讀得到」與「附得上」拆開
兩件事正交,混在一起會讓幾種常見組態找不到位置。Cookie 可以標成 JavaScript 讀不到(HttpOnly),而瀏覽器仍然會自動附上它;憑證也可以只活在記憶體裡(頁面重整就消失),由程式碼明確附上。拆開之後判讀變成兩問:這段憑證 JavaScript 讀不讀得到(決定它在跨站指令碼下的暴露),以及伺服器接不接受「瀏覽器自動附上」當作授權依據(決定跨站請求偽造成不成立)。
兩個常見組合各自把兩問答成不同的組態。
長效與短效分家:長效的那一份(常見的形態是 refresh token)放 JavaScript 讀不到的 cookie,短效的那一份只活在記憶體、由程式碼寫進 header。前者換後者的那個端點是整個系統裡唯一靠自動附上的請求,於是跨站請求偽造的防護只需要守住它一個。這個組態的代價是換發流程本身要設計——什麼時候換、換失敗時使用者看到什麼、多個分頁同時換發怎麼收斂。
配對值:憑證放 cookie,而伺服器要求請求同時帶一個 JavaScript 讀得到的配對值,兩者相符才通過。攻擊者的頁面讀不到配對值因此附不上,即使 cookie 被瀏覽器自動放進去了也通不過。它與自動附上那一節的額外值那一層的差別在於配對值可以由 cookie 本身衍生,伺服器不必為它保存狀態。它的前提是攻擊者無法替本網域寫入 cookie——子網域失守時這個前提消失,攻擊者可以自己配一組相符的值,與 SameSite 那一層踩到同一個缺口。
所以選哪一種
前面幾節給的是每一種帶法的性質,把它們收成一個選擇要看兩件自己這邊的事實。
子網域控制不了、或用 GET 執行寫入的端點盤點不完時,不能只靠自動附上。 前者讓 SameSite 的信任邊界大於預期,後者讓它的保護面與攻擊面錯開,而這兩項都不是調整 cookie 屬性能補的——要補的是伺服器端的來源檢查或額外值。
頁面掛著多個第三方腳本、或沒有人維持內容安全政策時,憑證不能放在 JavaScript 讀得到的位置。 明確附上的前提是「這個頁面不會跑到別人的程式碼」,而維持這個前提的成本由前端承擔;沒有人承擔時,明確附上換到的是一個更嚴重的失效形態——憑證被讀走之後離開受害者的裝置。
兩項都成立時走長短效分家那一組:長效的那一份放 JavaScript 讀不到的 cookie,短效的只活在記憶體。它把兩種攻擊面都收到最小,代價是換發流程要自己設計。
兩項都不成立時,兩種帶法都可行,選擇回到工程便利性——瀏覽器以外的呼叫方多不多、前端框架的既有做法是什麼。這個判斷要留意的是它會隨時間失效:多接一個子網域、多掛一個第三方腳本,上面兩項就可能從不成立翻成成立,而翻過去的當下不會有任何訊號。
呼叫方不是瀏覽器時這條軸怎麼退化
伺服器對伺服器的呼叫、原生行動應用、命令列工具都沒有「依網域自動附上」這個機制,也沒有一個由攻擊者控制的頁面能替它們發動請求。跨站請求偽造在這些呼叫方上不成立,這一章剩下的部分是憑證存在哪、怎麼送、怎麼撤銷,那條路徑走 7.29 API 認證的信任邊界分層 與 7.34 機器憑證的機制選型。
行動應用要分兩種。原生程式碼發出的請求適用前述非瀏覽器呼叫方的判定;應用內嵌的網頁畫面(webview)載入的是頁面,瀏覽器的規則照常適用,因此那一部分回到本章。同一個應用兩種形態並存是常態,判讀要按請求的發起點分開走。
判讀流程
- 先確認呼叫方裡有沒有瀏覽器載入的頁面。全部都不是的話,本章的自動附上那一半不適用,直接走 7.29 API 認證的信任邊界分層 與 7.34 機器憑證的機制選型。
- 列出所有會改變狀態的端點。這一步有兩類最常漏掉:用 GET 執行寫入的端點(一鍵退訂、匯出、切換某個設定),以及只給內部用、沒有掛在主要路由上的管理端點。前者漏掉的後果是防護與攻擊面錯開,後者是防護根本沒有套用到它。
- 對每個端點問「它憑什麼認為這個請求來自我自己的頁面」。答案若是「請求帶了 cookie」,這個答案不成立——跨站請求上 cookie 一樣會被附上,除非有一層防護明確擋掉。
- 選一組防護,並把每一層的失效條件寫下來:SameSite 由瀏覽器執行、對 GET 導覽與同註冊網域的子網域有缺口;Origin 檢查由自己執行、需要維護允許清單;額外值需要跨站指令碼防護成立。寫得出失效條件,覆蓋範圍才算評估過。
- 憑證改走 header 的話,寫出它存在哪,並判斷那個位置在跨站指令碼下會發生什麼。寫不出住址代表這個決定還沒做完,因為憑證總是存在某個地方。
- 最後回頭確認撤銷需求。需要能立即讓單一使用者失效時,狀態要放在自己查得到的地方,取捨見 Session 處理。
這個決定失敗時長什麼樣
主站的日誌裡,那幾筆請求帶著合法的會話、來自真實使用者的瀏覽器,與其他幾萬筆沒有差別。沒有新端點、沒有設定變更、沒有任何一項告警。
往回追是這樣:公司在主網域下開了一個活動用的子網域,交給外部廠商維護,內容與主站無關。那個子網域被拿下之後,從它發出的請求對主站不算跨站——「站」看的是可註冊網域而非完整主機名——於是 SameSite=Lax 不擋,主站的 cookie 照常被附上,攻擊者可以用任何一個造訪過活動頁的登入使用者的身分發動寫入。
這條路徑之所以一路暢通,是因為主站從來沒有人決定過防護要做到哪裡。開發與測試多半在 Chromium 系的瀏覽器上進行,而那一邊把沒有標示的 cookie 當成 Lax,於是掃描不回報問題、滲透測試也不一定觸發,「已經有保護」與「有人決定過保護到哪裡」看起來一模一樣。補起來要在主站加上不依賴瀏覽器預設值的那一層,而那正是當初判定「預設值已經夠了」時省下來的工作。
常見風險邊界
- 三層防護只用了其中一層時,缺的就是另外兩層各自覆蓋的那一塊——它們的缺口彼此不重疊,因此少一層不是少三分之一的保護,是完全沒有那一塊。
- 存在用 GET 執行寫入的端點,而防護只掛在寫入方法上時,攻擊面與防護面錯開,且錯開的那部分不會出現在任何檢查清單上。
- 憑證放在 JavaScript 讀得到的位置而頁面掛著第三方腳本時,暴露面等於那些腳本的總和,而它們的更新節奏由別人決定。
- 子網域交給不同團隊或外部廠商維護,而防護只靠 SameSite 時,實際的信任邊界涵蓋同一個註冊網域下的所有主機。
- 主要路徑改走 header 之後登入與換發端點仍靠自動附上時,實際的跨站請求偽造面就是那幾個端點,而它們常因為「已經改成 token 了」而不再被檢視。
下一步路由
- 決定拿什麼驗證使用者(自建、委派、passkey):7.31 認證方式選型
- 登入狀態放伺服器還是放 token、撤銷成本怎麼算:Session 處理
- 呼叫方是系統而非人時的身分分層與撤銷粒度:7.29 API 認證的信任邊界分層
- 機器憑證要不要在每次呼叫裡送出秘密:7.34 機器憑證的機制選型
- 終端使用者的登入節奏與會話收斂:7.2 身分與授權邊界
- 對外入口本身的暴露面與管理端點可達來源:7.3 入口治理與伺服器防護
- 「站」的切點與信任邊界的實際範圍:registrable domain
- 注入成立之後的後果限制:content security policy