WordPress Transient 暫存機制的風險

某天客戶傳來一張截圖:「LINE 登入按鈕按下去,跳回來就跳出 Invalid state,所有人都登不進來。」外掛是我們自己維護的 OrderNotify,我們在本機跟開發機跑了一輪測試完全正常,到客戶站問題就發生,後來發現問題不在 LINE,也不在我們的驗證邏輯,而是 set_transient() 寫進去之後,下一個 request 的 get_transient() 永遠拿到 false

這篇紀錄我們把 OAuth state 設計升級到第三個版本的過程,提供給有需要串接 OAuth 的朋友參考~

為什麼 OAuth 流程需要 state 這個參數

先講一下 LINE Login 大致長怎樣,後面才好理解我們遇到的狀況,LINE 登入流程是標準的 OAuth 2.0 authorization:

  1. 使用者在我方網站點 LINE 登入按鈕
  2. 我方產生授權 URL,把使用者導到 access.line.me/oauth2/v2.1/authorize?...&state=ABC123
  3. 使用者在 LINE 端完成授權
  4. LINE 把使用者導回我方 callback URL,網址帶 ?code=xxx&state=ABC123
  5. 我方拿 code 跟 LINE 換 access_token,再用 token 取得使用者 profile,做帳號綁定

第二步和第四步那個 state 是關鍵。它的作用很單純:防 CSRF

想像一個攻擊情境:攻擊者用自己的帳號跟 LINE 走完授權流程,拿到 callback URL(含他自己的 code),然後騙受害者去點這個 URL,如果我方 callback 沒驗證 state,受害者一打開連結就「以攻擊者的 LINE 身分」登入了我方網站,所有後續操作都記在攻擊者帳號上——這就是 OAuth 經典的 login CSRF。

state 的設計是:第二步發出去前,我方先記住「我發過 state=ABC123」;第四步收到 callback 時檢查「這個 state 真的是我發的嗎?」,攻擊者的 callback URL 裡帶的是他自己那輪的 state,跟受害者瀏覽器裡記著的對不起來,就被擋下。

LINE 官方文件對 state 的硬性規定只有兩條:

A unique alphanumeric string used to prevent cross-site request forgery. Your web app should generate a random value for each login session.

純英數字、每次登入都重產。怎麼存、怎麼比對,留給開發者自己決定——這個自由度就是後面三個版本演進的空間。

第一版:用 $_SESSION 存 state

最直覺的做法。產 state 時 $_SESSION['line_state'] = $state,callback 時拿 $_SESSION['line_state'] 比對。

這版上線一陣子之後,我們踩到兩個問題,最後直接把它整段拿掉:

問題一:信任 client cookie 帶來的帳號接管漏洞。 早期的 callback 處理夾帶了一些「從 cookie 讀使用者 ID 直接登入」的 fallback 邏輯,安全 review 抓到後一併把 $_SESSION 拔掉,客觀講 $_SESSION 本身對 OAuth state 是合規的,但跟那段不安全的 cookie 邏輯耦合在一起,一起拔掉比較乾淨。

問題二:PHP session 在 WordPress 上是麻煩製造機。

情況影響
WP 預設不啟動 session要手動 session_start(),且要在 init 早期
session_start() 會發 PHPSESSID cookieVarnish/Cloudflare/Breeze 看到 cookie 直接 bypass page cache
PHP session lock同一使用者的並行 request 被序列化,整個站慢下來
檔案型 session 預設存 /tmp多機/容器化環境會壞掉

特別是第二、三點,等於「為了存一個 OAuth state,把全站頁面快取跟並行性都犧牲掉」,這次經驗讓我們對 session_start() 在 WP 站上的副作用印象很深。

第二版:改用 WordPress transient

WP 內建的 transient 看起來是完美替代品。產生 state 時呼叫 set_transient 寫進去(TTL 一小時),驗證時呼叫 get_transient 拿出來比對,驗完就 delete_transient 讓它一次性失效。

優點看起來都到位:不用碰 PHP session、不影響頁面快取、沒有 session lock,又有自動過期跟限制單次使用,但有一個大問題:預設情境下 transient 會存在快取中,在官方文件中有斗大的標語寫著:

Everyone seems to misunderstand how transient expiration works, so the long and short of it is: transient expiration times are a maximum time. There is no minimum age. Transients might disappear one second after you set them, or 24 hours, but they will never be around after the expiration time.

也就是快取資料有可能因為記憶體滿了而被刪除,而導致使用者登入後無法正確取得 state。

而在發現這限制時,我們在自家環境、其他主機、本機上跑了一輪都沒事,但問題就出在我們測不到的客戶主機,而客戶主機應該就是有使用物件快取的機制。

transient 在客戶這台主機上為什麼是死路

照慣例先在在客戶站塞 log,發現 set_transient 寫完立刻 get_transientfalse寫進去 0 秒讀不到。這不是過期,是根本沒寫入。

直接看 wp_options,裡面也找不到對應的 transient 紀錄,進主機檔案系統才發現,wp-content/ 裡面躺著一個叫 object-cache.php 的檔案——這就是傳說中的 object cache drop-in

簡單講,drop-in 是 WordPress 內建的一個「劫持」機制:只要你在 wp-content/ 放一個特定檔名的 PHP 檔(像 object-cache.php),WordPress 啟動時就會自動把它載入,然後用它取代核心原本的某些行為。它不是外掛、不需要啟用、後台看不到、wp-settings.php 早期就 require 進來。

用郵差來比喻:原本你呼叫 set_transient,資料會老老實實走進「郵局」(wp_options 資料庫)。但 wp-content/ 多了一個 drop-in 檔案,等於有個人站在門口說「以後信交給我送就好」,從此你寄出去的所有信都被他攔截,丟進另一個系統(Memcached)。如果他自己出了狀況,像是連線壞了、放錯收件人、把信弄丟,你的信就送不到,但你完全不知情,還以為照常運作。

這台客戶主機在開通時就由平台預先放好了 Memcached 版的 drop-in,跟使用者有沒有裝任何快取外掛無關,是平台層級的設定。寫進去之後讀不出來的可能成因就有可能是被當作快取丟掉了。

兩條路:修主機 vs 改外掛

當下能想到兩個解法。

路徑 A:請客戶 SSH 進去處理。wp-content/ 底下的 object cache drop-in 改名備份,transient 就會自動 fallback 回 wp_options,問題立刻解,但無法通用,每個用同類主機架構的客戶都要做一次,未來新客戶照樣踩坑,而且要請對方上 SSH 改檔案,溝通成本高。

路徑 B:外掛端不要用 transient 儲存 state。

如果 state 根本不用存任何地方就能驗證,那 object cache 壞不壞、wp_options 寫不寫得進去、客戶用什麼主機,全都無所謂,這就是 stateless HMAC 的切入點。

我們認為對「外掛要面對 N 個未知環境的客戶站」這種情境,B 比 A 值得做,客戶端零動作,所有版本一升級全部受惠。

stateless HMAC 跟 transient 的本質差異

把這兩種做法用比喻講最清楚:

TransientStateless HMAC
比喻存物櫃號碼牌帶密押的支票
state 是什麼一個索引(index),真實資料在 server 端訊息本身 + 簽章,自己就完整
驗證方式拿索引去 server 查出真實值,比對,但有高機率被當成快取資料刪除拿密鑰當場重算簽章,驗對就過
倉庫掛掉時全部使用者都登不進來完全不受影響

transient 那個流程是這樣的:

產生:state = 隨機字串
      set_transient(key, state, 1h)  ← 寫進 server 端

驗證:stored = get_transient(key)    ← 從 server 端讀
      if stored !== state → 失敗     ← 讀不到就一律失敗

stateless HMAC 的流程:

產生:nonce  = 隨機字串
      expire = now + 600
      payload = nonce + expire
      sig = HMAC(SECRET, payload)
      state = payload + sig          ← 不寫任何 storage

驗證:拆出 payload 跟 sig
      check expire > now             ← 沒過期
      expected = HMAC(SECRET, payload)
      check sig === expected         ← 簽是我發的

關鍵差別:stateless 版本完全沒有任何 read 動作。state 自己就帶著「不可偽造」「有效期限」「來自我方」三件事的證明,誰拿到誰當場驗。

關於重放風險,transient 有用完即丟的性質,HMAC 在過期前理論上可重放,但實際攻擊情境很窄:攻擊者要重放 state,必須同時拿到那輪的 LINE code(也在 callback URL query string 裡),而 OAuth 規範強制 LINE 端的 code 是單次使用的,用過就拒絕。

把場景具體化來看:受害者在 LINE 完成授權後,瀏覽器即將被導回我方的 callback URL,網址裡同時帶著 codestate,攻擊者要重放這個 state,前提是他已經先攔截到那串完整的 callback URL(包含受害者那輪的 code);接著還得搶在受害者的瀏覽器送出 callback request 之前,自己先把這串網址打過去——這樣 code 就被攻擊者用掉,攻擊者以受害者的 LINE 身分登入成功,但問題是到這一步,攻擊者已經拿到完整的 callback URL 了,state 用 transient 還是 HMAC 都擋不住,能擋的是傳輸層(HTTPS、HttpOnly cookie、CSP),不是 state 本身。換句話說,stateless HMAC 對 LINE Login 場景的安全性,等價於 transient 的做法。

順帶一提,OAuth 2.0 的最新 BCP(RFC 9700,2025 年 1 月)把 server-side storage 跟 stateless 簽章都列為合規做法,沒有強制偏好,LINE 官方也沒指定。

為什麼最後用 hex 不用 base64url

這裡有個 LINE 規範的細節差點踩進去。

最直覺的編碼方式是 base64url,OAuth 圈很常見,把 payload 跟 sig 串在一起做一次 base64 編碼就好,但回頭看 LINE 文件那句:

A unique alphanumeric string … This cannot be a URL-encoded string.

base64url 會用到 -_,base64 標準版還會出現 + / =,這些都不是 alphanumeric,理論上違規,實測 LINE 目前不會擋,但「未來 LINE 加嚴驗證」這種風險不值得賭,外掛一旦出去要靠所有客戶逐一升級才能修。

最後選 hex:純 0-9 a-f,是嚴格 alphanumeric,完全合法,state 結構為 nonce(16 字元)+ expire 時間戳(16 字元)+ HMAC 簽章(64 字元),總共 96 字元純英數。

簽章用的密鑰直接拿 wp_salt( 'auth' ),它只在這台 WordPress 內可讀,不需要額外的密鑰管理機制;密鑰外洩等於整個站已經淪陷,那時候什麼都救不了。

少一層儲存就少一個雷區

外掛開發跟產品開發最大的不同,是你永遠不知道客戶站在跑什麼環境,有些主機平台預設啟用 Memcached、有些走 Redis、有些站裝了三層快取互相打架,任何依賴「我寫進去的東西等等讀得回來」的設計,在你看不到的客戶站都可能失效。

stateless 不是萬靈丹,它需要密鑰管理,且失去了用完即丟的性質。但對 OAuth state 這種「短期、防 CSRF、依賴 LINE code 單次性使用」的場景,它比 transient 多一層免疫力,HMAC 簽章驗證只看密鑰跟字串,減少依賴。

這次重寫之後客戶站立刻能登入,沒人需要 SSH,沒人需要改主機設定,下一個踩到類似主機環境的客戶上線時,也不會再撞上同一個坑。

目錄

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料

Picture of 賴俊吾 / Oberon Lai
賴俊吾 / Oberon Lai

現為全職 WordPress 工程師,網站開發經歷 11 年,專攻前端工程與 WordPress 佈景主題、外掛客製化開發

訂閱電子報

Hi,我是 Oberon,我會固定在每週五早上發送接案心得以及與 WordPress 相關的電子報,同時也會分享一些實用的開發知識,讓你在 WordPress 的接案路上不孤單!

專注於分享 WordPress 開發、接案技巧、專案管理等自由工作者必備知識與心得

© 2025 想點創意科技有限公司

想點創意科技有限公司 | 統一編號 90516823
Designed by Hend Design | 隱私權政策

訂閱電子報

Hi,我是 Oberon,我會固定在每週五早上發送接案心得以及與 WordPress 相關的電子報,同時也會分享一些實用的開發知識,讓你在 WordPress 的接案路上不孤單!