iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Claude AI

盡信 Claude,不如無 Code — 心法與全端實戰系列 第 24 篇

Day 24 AI 寫的 JWT 六關過四關:紅燈不在 JWT,問題在時間從哪來

  • 分享至 

  • xImage
  •  

昨天 18 條 endpoint 全通,其中 4 條是認證。而認證是這個專案唯一「寫錯了會出事」的地方。

沒有 Apple Developer 帳號,所以沒有 Sign in with Apple;非目標清單第 12 條又寫了不做第三方 OAuth。依這個專案的限制,JWT 認證是我選的路,而它也剛好很適合拿來當今天的題目 —— 因為 JWT 有一些經典的錯誤模式,而且其中一部分可以直接變成測試。

Day 9 那個一個 case 釘一個邊界的做法,今天要放大成六個。


六個測試,加一條靜態檢查

跟 Day 22 一樣的順序:先把測試寫好、commit,再開一個空目錄給 AI。

c67c7d1  09:49  JWT 六個邊界測試(寫作 session 起草、我審)
36ccdfd  11:08  AI 版 JWT(乾淨 session,原文)

AI 拿到的 prompt,第一句是需求:

在 Cloudflare Workers 上實作 JWT 登入,要有 refresh token。

後面我補了必要的介面:四條 endpoint 的輸入輸出形狀、一個叫 requireMember 的 middleware、兩張表的 DDL —— 這是讓它的程式接得進同一份測試的最低需要。不含任何安全規則。但要誠實講一件事:DDL 裡 refresh token 那張表的主鍵叫 token_hash,這等於提示了「refresh token 要存 hash,不存明文」。那一題它是被我洩題的。

然後用六個分開的測試加一條靜態規則打它 —— 第 1–6 關是功能測試,第 7 關是靜態檢查:

# 邊界 正確行為
1 過期 access 發出後滿 15 分鐘 → 401
2 簽章竄改 改 payload 不改簽章 → 401
3 alg: none 拒絕,不得接受無簽章
4 重放 已輪替的 refresh 再用一次 → 401,而且該成員全部 refresh 一起撤銷
5 效期 access 15 分鐘、refresh 30 天,邊界前 1 毫秒還有效
6 格式異常 無 header / 非 Bearer / 空字串 / 亂碼 → 401 而不是 500
7 簽章驗證方式 ⭐ 用 crypto.subtle.verify 或函式庫,不是自己重算再 ===

第 6 項的重點是 401 而不是 500。回 500 通常代表它沒有處理這個情況,是例外被丟出來 —— 那意味著同一條路徑上可能還有別的沒處理到的東西。

第 3 項是 JWT 歷史上一個很經典的漏洞。這一關不是在考它懂不懂 alg: none,而是看它驗證時有沒有明確指定允許的演算法,而不是接受 token 自己宣告的 alg。

第 7 項跟前六項不是同一種東西:它是這篇唯一一個功能測試抓不到的。 錯法長這樣 —— 自己用 HMAC 重算一次簽章,然後跟 token 裡那段比對:

const expected = await hmacSha256(secret, `${header}.${payload}`)
if (expected !== signature) return unauthorized()   // ← 問題在這一行

這個寫法在功能上完全正確。 正確的簽章會過,竄改的會被擋 —— 第 2 項照樣是綠的。問題在寫法:自己重算簽章、再自己比字串,是不該由應用程式碼處理的密碼學細節。字串比較可能在第一個不同的字元就結束,花的時間可能透露「猜對了多少」,形成 timing side channel。正確的做法是把驗證交給 crypto.subtle.verify 或成熟的 JWT 函式庫。功能測試全綠,不代表實作方式沒有安全問題。(這種時間差隔著網路能不能被穩定利用,有得辯;列進來是因為它是這批邊界裡唯一一個測試全綠、寫法仍然錯的。)

它交出什麼

5 輪、$0.34、一分鐘,交回一個 259 行的 auth.js。接進 repo(只把認證路由換成它的,其他不動)跑同一份測試:

Tests  2 failed | 4 passed (6)
check-jwt-timing    ✅ 沒有自己重算簽章再字串比對

第一行是第 1–6 關的功能測試(6 個裡過 4 個),第二行是第 7 關的靜態檢查。

# 邊界 AI 版
1 過期 ❌ 推過 15 分鐘,token 仍被當成有效
2 簽章竄改 ✅
3 alg: none ✅ 用 hono/jwt 的 verify,明確指定 HS256
4 重放 ✅
5 效期 ❌ 推過 30 天,refresh 仍回 200
6 格式異常 ✅
7 簽章驗證方式 ✅ 簽章交給函式庫,沒有自己刻

先講它做對的,因為做對的比我預期多:

  • 第 7 項它根本沒走到那條岔路。 驗簽章直接交給 hono/jwt,沒有自己刻。
  • 密碼比對它自己用了常數時間的寫法。 用 XOR 累積差異、最後才判斷,不是 ===。這一項我沒出題。
  • 帳號不存在時,它照樣跑一次密碼雜湊,註解寫著「讓回應時間不洩漏帳號是否存在」。這一題我也沒出。
  • refresh 輪替用一條條件式 UPDATE … WHERE revoked_at IS NULL 認領舊 token,同一個 token 兩個請求同時來,只有一個會成功;認領失敗、而 token 已經被撤銷過,就撤掉這個人全部的 refresh。第 4 項要的整套行為,它自己想到了。

所以別因為後面有兩關紅,就反推它寫得很差:四關綠,外加兩項我沒出題的防護。

紅的兩關:時鐘

第 1、5 兩項紅,不是兩個錯,是同一個問題打中了兩個測試:它讀的不是測試注入的時間。它的檔案第 12 行:

const now = () => Math.floor(Date.now() / 1000)

第一層,是它自己讀系統時鐘。 而這份測試用的是假時鐘:app 建立時注入一個 now,測試把它往後推 15 分鐘、推 30 天,看 token 會不會失效。它看不到被推過的時間 —— 對它來說,現在永遠是真的現在,token 永遠剛發出來。

所以要公平地講:這兩個紅燈沒有證明正式環境的過期邏輯是錯的。 exp 的檢查、refresh 的到期欄,它都寫了;紅燈證明的是測試控制不了它看到的時間,所以驗不了「剛好 15 分鐘」那一刻。錯的不是邏輯,是時間從哪來。

然後我往下挖了一層,發現第二層更麻煩:它依賴的函式庫也自己讀。它驗證 access token 用的 hono/jwt 的 verify,函式庫內部檢查 exp 的那一行是:

const now = Math.floor(Date.now() / 1e3);   // hono 4.13.5, utils/jwt/jwt.js

時鐘藏在函式庫裡。 就算它把自己那行 now() 改成注入的時間,access token 過期沒過期,還是由函式庫拿真的時鐘判斷 —— 真正做決定的,不是它直接控制的那段程式碼。要讓過期測得到,得關掉函式庫的 exp 檢查、自己拿注入的時間比,或者像 repo 裡那份一樣,驗證函式直接收一個 now 參數。

兩層時鐘畫在一起,是這樣:

https://ithelp.ithome.com.tw/upload/images/20261008/20103790yg5n6rL1Ni.png

這跟 Day 22 那份 schema 在 SQL 裡寫 unixepoch() 是同一件事,換了一層。讀時鐘的地方,不一定在你看得到的程式碼裡。

誰抓到的:兩種裁判,和一個盲區

第 1–6 項:測試。 六個分開寫,所以哪一項沒過就是哪一項,不需要猜。如果合成一個 it('JWT 安全性'),今天這個結果只會是一個紅燈,而你得自己去翻是哪裡。

第 7 項:靜態檢查,因為它沒有紅燈可以亮。scripts/check-jwt-timing.sh 掃 src/ 底下「簽章相關的變數名跟 == / != 放在一起」的寫法,兩個方向都掃。

這條 pattern 我前後改了三次,每次都是錯例騙過它亮綠燈。一條對錯例亮綠燈的規則,正是這篇在講的那種缺陷。

而今天多了一個盲區:時鐘那兩關,靜態檢查也是綠的。

check-time-injection   ✅ domain 層的時間都是參數

這支檢查當時只掃 src/domain/。AI 的 Date.now() 寫在認證路由 src/routes/,不在掃描範圍內;函式庫內部讀時鐘,更是任何 grep 都看不到。時鐘那兩關,是測試碰巧抓到的。

所以寫這篇的當天就補了(e5a8442):規則收緊成「讀時鐘只准在兩個入口 —— 注入 now 的 app.js 和跑排程的 worker.js」,檢查範圍從 src/domain/ 擴大到整個 src/。新的檢查擋三類情況:

  • 兩個入口以外、直接讀時鐘的程式碼
  • 寫在 src/ 裡、自己取時間的 SQL(像 Day 22 那份 schema 的 unixepoch())
  • 引入會自己讀時鐘的函式庫(import … from 'hono/jwt')—— 函式庫裡面 grep 看不到,但引入它的那一行看得到

https://ithelp.ithome.com.tw/upload/images/20261008/20103790sZh5XaSgne.png

一個缺陷如果沒有任何檢查會為它變紅,那「全綠」就不構成它不存在的證據。 今天這句話同時適用於第 7 項,和第 1、5 項。


總結

一個只拿到需求和必要介面的 AI:四關綠,外加兩項我沒出題的防護。 它驗證時明確指定 HS256,不接受 alg: none;不自己比對簽章、重放會撤全部、格式錯回 401 不回 500。而紅的那兩關,問題不在 JWT 的寫法,在時間從哪來 —— 而其中一個時間來源,藏在它選的函式庫裡。

Day 22 的 schema 在 SQL 裡讀時鐘,今天的 JWT 在路由和函式庫裡讀時鐘。

這一篇留下的心法:

程式裡凡是自己去讀外部狀態的地方 —— 時間、亂數、網路 —— 測試如果控制不到,那一段就驗不了。
解法是依賴注入:讓時間當參數從外面傳進來,測試才推得動;挑函式庫時,也要看它讓不讓你把時間傳進去。

明天:早鳥加團體加優惠碼。規則改了,叫 AI 照新規則改 —— 它改得對嗎?會不會幫你想到漏掉的地方?


參考資料


上一篇
Day 23 報名 API:先定好標準答案,再讓 AI 寫實作
下一篇
Day 25 早鳥加團體加優惠碼:規則改了,AI 不到一分鐘跟上,還問出一個我沒想到的問題
系列文
盡信 Claude,不如無 Code — 心法與全端實戰 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言