把 JWT 用成事故的常見錯誤
最後更新:
JSON Web Token 是一份簽了章的聲明,不是一個密封的信封。它是由點號連接的三段 Base64url 編碼內容——一個指明簽章演算法的標頭、一個裝著關於使用者或工作階段的宣告(claims)的酬載、以及對前兩段的簽章。簽章證明這份權杖由持有金鑰的一方簽發、且此後未被改動。它對保密性什麼都不證明:任何持有權杖的人都能解碼酬載、讀出其中每一條宣告——這就是為什麼解開一個權杖只需要貼上,而不需要密碼。JWT 在正式環境中出事時,壞掉的幾乎從來不是密碼學本身。壞掉的是某人跳過的一項檢查——從權杖自身輕信來的演算法、沒人驗證的到期時間、人類能憑記憶打出來的共享金鑰。把任何權杖貼上到JWT 解碼器裡,它的標頭和宣告就明明白白擺在眼前。本文逐個講清那些把跳過的檢查變成資料外洩的工程錯誤,以及關上每個錯誤的那條防禦規則。
簽了章不等於保密
酬載是 Base64url——一種編碼,與普通 Base64 同族——而不是加密。其中一切都能被權杖途經的任何人讀到:電子郵件地址、角色、租戶 ID、內部帳號編號,偶爾還有糟糕得多的東西,比如密碼重設旗標、或開發者為除錯加進去又忘了刪的個人資料片段。權杖的足跡也比開發者想像的遠:它們躺在瀏覽器儲存裡,穿過代理與應用日誌,被複製進崩潰報告,又被貼上到缺陷單和聊天記錄裡。由此直接得出的工作規則是:永遠不要把一條你不樂意看到它印在日誌檔案裡的宣告放進權杖——因為遲早它會出現在那裡。
這種誤解情有可原——緊湊的權杖看起來像密文,全是讀不出聲的字元和點號。但簽章只讓權杖可篡改偵測:改動任何一條宣告,驗證就會失敗。保密性是同一標準家族中的另一種構造(加密權杖,JWE),而普通存取權杖很少是它。所以把酬載當作簽了章的公開文本來設計:只放識別符與授權事實,敏感資料在伺服器端按 ID 查詢,生命週期保持短暫,讓萬一洩露出去的東西盡快到期。
讓權杖自己挑選演算法
標頭宣告了一個 alg,而 JWT 領域最古老的失敗就是驗證方相信了它。宣告值之一是 none——無簽章權杖,根本沒有簽章。一個遵從權杖自報演算法的函式庫或手寫檢查,可能收到一個簽章為空的權杖並把它當作真的接受。最佳實務 RFC 在這一點上說得很直白:無簽章權杖只有在通道本身已經保證完整性時才可接受,而一枚經由公共網際網路到達的持有者權杖並不滿足這一點。同一種「相信標頭」的缺陷會產生演算法混淆:一個簽發 RS256 權杖的系統持有私鑰,並發布配套的公開金鑰用於驗證。若驗證方能被說服改按 HS256 處理這枚權杖,驗證材料就變成了那把公開金鑰——按設計就是公開的、人人可知——被當作對稱金鑰來用,於是任何人做出的簽章都能通過驗證。這兩種失敗裡都沒有金鑰被盜;驗證方只是照辦了傳送方寫下的指令。
標頭還帶著第二種更安靜的風險:金鑰指標。kid(金鑰 ID)、jku 與 x5u(存放金鑰或憑證的 URL)這類參數告訴驗證方去哪裡找驗證金鑰——而它們就坐在受攻擊者影響的權杖內部。若一個 kid 值未加嚴格校驗就流入檔案路徑、資料庫查詢或金鑰抓取,查找動作本身就成了攻擊面。防禦規則形狀都一樣:在伺服器端設定中釘死預期的演算法、拒絕其餘一切;從你自己的註冊表中解析金鑰,金鑰 ID 對照允許清單核對;永遠不要從權杖提供的 URL 抓取金鑰材料;把每個標頭欄位都當作不可信輸入,因為它就是。
簽章通過了;宣告卻從沒被檢查
驗證簽章只回答一個問題——這枚權杖是否由金鑰持有者簽發且未被改動?它對這枚權杖是否應該在此刻、在這裡被接受什麼都不說。那是宣告的工作,而宣告驗證正是正式系統悄悄跳步的地方。先是 exp:一個從不檢查到期時間的驗證方,會把每枚權杖都變成永久憑證,於是三年前在日誌裡洩露的權杖今天仍能通過認證。其次是 iss:只接受你設定過的簽發者發出的權杖,而不是碰巧使用同一演算法的任何人。然後是 aud,代價最大的遺漏:組織常讓多個服務共用一把簽章金鑰,於是低價值服務的權杖——免費層 API、靠近預發環境的工具——成了一枚簽章完全為真、卻會被高價值服務接受的權杖,因為簽章是真的,而沒人問過這枚權杖是為哪個受眾簽發的。
這些都不冷門。這是一張清單,而失敗模式是:函式庫熱情地驗證簽章,卻把宣告檢查留作可選、預設關閉或呼叫方的責任。防禦姿態是:每次驗證都要求 exp、iss 與 aud,任何缺失或不符都失敗關閉;時鐘偏差容差以分鐘計而非小時——容差是給不準的時鐘用的,放得太寬就會悄悄把過期權杖重新續命。然後像測試登入表單一樣測試否定情形:過期權杖、受眾錯誤的權杖、未知簽發者的權杖都應該被拒絕,而這些拒絕本身值得作為安全訊號記錄到日誌裡。
人記得住的金鑰,就是攻擊者能試出來的金鑰
HS256 是對稱的:一把共享金鑰既簽章又驗證。在真實程式碼庫裡,這把金鑰太常是一個詞或短句——公司名、從教學裡抄來的框架預設值、開發者能憑記憶打出來的東西。讓這一點致命的原理如下,它是設計本身的屬性而非某個工具的屬性:一枚被擷取的權杖帶著離線測試猜測所需的一切。訊息已知、簽章已知,於是猜測可以在本地全速核對,伺服器永遠看不到一次嘗試,任何速率限制都不適用。於是整個方案的安全性歸結為那一串字元的熵——而人類選的字串熵極低。
修復辦法毫不耀眼。用機器產生簽章金鑰,長度要夠——HMAC 至少 256 位元熵——並像對待其他正式憑證一樣保管它們。每個環境唯一,讓洩露的預發設定裡的金鑰在正式環境一文不值;永遠別讓開發預設值活著進入真實部署。當許多服務要驗證權杖時,優先選用 RS256 或 ES256 這類非對稱演算法:驗證方只持有公開金鑰,於是驗證方被攻破也洩露不出能鑄造權杖的東西,簽章金鑰只留在一處。並且在需要之前就準備好輪換方案,因為對稱金鑰洩露時沒有警報——第一個跡象通常是你沒簽發過的權杖被當作你的接受了。
瀏覽器把權杖存在哪裡,決定了誰能讀到它
在用戶端,儲存選擇是一項安全決策,不管有沒有人有意識地做過它。放在 localStorage 裡的權杖可被頁面上執行的每個腳本讀到:你自己的程式碼、你放進來的每個第三方標籤和分析片段——以及任何曾執行過的跨站腳本酬載,哪怕它只來自應用裡任意一處未逸出的留言欄位。持有者權杖是一份可複製的憑證:讀到它的人可以在任何地方出示它,並在到期前就是那個使用者。因此,腳本可讀儲存裡的長效權杖,其價值恰好等於你最糟的那個 XSS 漏洞,而大多數應用遲早會有一個。
通常的替代方案 HttpOnly Cookie 完全移除了腳本存取——XSS 酬載再也無法讀取並外帶權杖——但瀏覽器會自動附帶 Cookie,於是跨站請求偽造成了要去對付的威脅,用 SameSite 屬性與 CSRF 權杖來應對。只把權杖放在記憶體裡能同時躲開兩個問題,代價是每次重新整理都遺失工作階段。並不存在放諸四海皆準的正確答案,假裝有,正是團隊最後兩頭落空的途徑。把儲存變成洩露事件的,是那個沒人有意選過的組合:腳本可讀的儲存、長生命週期、寬泛的權限範圍、沒有重新整理權杖輪換來壓縮副本的價值。有意識地選擇這個取捨,無論哪種方案都保持存取權杖短效,並把理由寫在下一個開發者能找到的地方。