Skip to content

fix: 修補班表分享連結 stored XSS 漏洞,PDF 加密改用 Root Key 自動輪替 - #21

Merged
Cody8722 merged 5 commits into
releasefrom
develop
Sep 7, 2026
Merged

Cody8722 merged 5 commits into
releasefrom
develop

Conversation

@Cody8722

@Cody8722 Cody8722 commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • 安全修復(重點):班表分享連結(免登入公開端點)可被串接成 stored XSS。POST /api/schedules 原本只檢查 data 是不是非空陣列,完全沒驗證內部結構;scheduleRenderer.js 渲染時又有欄位漏轉義,且 escapeHtml() 本身對陣列/物件型別會直接原樣放行,繞過所有轉義。已在本機與正式環境(Render)用真實攻擊鏈完整驗證:存入惡意資料 → 建立分享連結 → 開啟連結執行注入內容 → 修補後同一條連結回傳安全轉義文字。
  • PDF 隱藏資料加密改版:從單一固定金鑰改為 Root Key + HKDF 版本衍生,只需要備份一個 Root,之後金鑰輪替免另外備份、現場算得出來;並把「目前版本號」從環境變數改存 MongoDB,改為每 30 天自動輪替,不用再手動編輯 .env + 重啟。
  • 雜項:frontend/dist/(build 產物)補進 .gitignore。

部署後需確認

  • PDF_PAYLOAD_ROOT_SECRET 是否已經設定在正式環境(此 PR 合併前已提供給使用者一組正式用的 Root Secret,需確認 Render 環境變數已設定)。
  • 合併後正式環境的分享連結功能會立即套用新的結構驗證與轉義修補。

Test plan

  • 後端 348/348 測試通過
  • 前端 83/83 測試通過
  • CI(backend/frontend/E2E 三個 job)全綠
  • 本機以隔離測試資料庫實機驗證 PDF Root+HKDF 加解密、版本輪替(含強制觸發一次真正的 30 天到期輪替)
  • 對正式環境(Render)實測 XSS 修補前後的攻擊鏈行為,確認漏洞已補上,且未留下任何測試資料
  • 對正式環境實測 rate limiter(enforcement 正確、X-Forwarded-For 偽造無法繞過)

🤖 Generated with Claude Code

https://claude.ai/code/session_0117v1Qxx7foQEH4cd6pfnLS

Cody8722 and others added 5 commits September 7, 2026 17:11
需求:舊制單一金鑰(PDF_PAYLOAD_SECRET)換金鑰後舊 PDF 就永久解不開,
且每次輪替都要另外備份一組新的隨機字串。改成只需要備份一個 Root,
之後輪替版本完全不用額外備份,現場算得出來;某一版金鑰萬一外洩,
因為 HKDF 是單向函式,不會牽連 Root 本身或其他版本。

環境變數:
- PDF_PAYLOAD_ROOT_SECRET:唯一需要手動備份的值,產生後永遠不變。
- PDF_PAYLOAD_KEK_CURRENT:目前加密要用哪一版(例如 v1),換版本只
  改這裡,不用新增任何環境變數。實際 KEK=HKDF(Root, info=版本號)
  現場算出來,不另外存。
- PDF_PAYLOAD_SECRET:改版前的舊制金鑰,現在只作為 v0(無版號的舊
  格式)解密相容用,不再用來加密任何新資料。

payload 格式改成 {版本號}:{base64(iv+authTag+密文)};沒有版號欄位
(不含冒號)的舊格式視為隱含 v0,直接用 PDF_PAYLOAD_SECRET 當 KEK
解密(不經 HKDF),確保改版前已經匯出、流通在外的 PDF 不用重新匯出
照樣能匯入。前端完全不用改——組裝/解析 metadata 字串的邏輯本來就是
格式無關的,只是把後端回傳的 payload 原樣轉發。

isConfigured()(加密新資料是否可用)與新增的 isDecryptionPossible()
(解密是否有任何可能)拆開:只設定舊制金鑰、沒設 Root 的部署,加密
會回 503(新資料一律走新機制),但解密舊格式 PDF 仍然可用。

已用隔離測試資料庫實機驗證:Root+v1 加密後正常解密;把
PDF_PAYLOAD_KEK_CURRENT 改成 v2 重啟後端(Root 不變),確認新加密
的 payload 帶 v2 前綴,且先前 v1 加密的 payload 仍正常解密(驗證版本
輪替不影響舊版本);只設定舊制 PDF_PAYLOAD_SECRET(無 Root)時,
加密回 503,但手動組出的 v0 舊格式 payload 仍正確解密。後端
306/306、前端 83/83 測試全過。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117v1Qxx7foQEH4cd6pfnLS
改版前 Root Key + HKDF 機制的「目前版本號」存在 PDF_PAYLOAD_KEK_CURRENT
環境變數,換版本需要手動改 .env 並重啟後端。改為存進 MongoDB
(pdfPayloadKeyState collection,單一文件),架構仿照 holidayService.js
既有的「記憶體快取 + 定期背景刷新」模式:

- pdfPayloadKeyStateRepository.js:getState/setState 讀寫該文件
- pdfPayloadKeyRotation.js:
  - initKeyState():啟動時讀 DB;DB 是空的才用 PDF_PAYLOAD_KEK_CURRENT
    當一次性種子值(之後這個環境變數就不再被讀取)
  - rotateIfDue():距上次輪替滿 30 天就自動遞增版本號並寫回 DB
  - 啟動時執行一次 + 每 24 小時檢查一次(若曾停機超過 30 天,啟動時
    會立即補做輪替)
- pdfPayloadCrypto.js 改為呼叫 getCurrentVersion() 取得目前版本,
  不再直接讀環境變數
- MongoDB 未連線時 initKeyState/rotateIfDue 為 no-op,isConfigured()
  正確回傳 false(無法加密新資料),但解密不受影響(HKDF 用的是
  payload 自帶版號 + Root 現算,v0 舊格式更完全不依賴 DB)
- GET /api/status 新增 pdfPayloadKeyRotation 欄位(目前版本、上次/
  下次輪替時間)

測試:新增 17 個測試(repository 3 個、rotation service 14 個),
既有 pdfPayloadCrypto/status 測試同步更新,後端全數 323/323 通過。

實機驗證:在隔離的 scheduleApp_test 資料庫中,直接把 rotatedAt 改成
31 天前並重啟後端,確認自動觸發 v1→v2 輪替、且舊版本加密過的資料
仍可正常解密——與真實 30 天後觸發走的是同一段程式碼路徑。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117v1Qxx7foQEH4cd6pfnLS
npm run build 產生的編譯輸出不需要進版控,部署時重新 build 即可。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117v1Qxx7foQEH4cd6pfnLS
安全稽核發現一條可利用的攻擊鏈:POST /api/schedules 只檢查 data 是不是
非空陣列,完全不驗證內部結構;scheduleRenderer.js 渲染時 task.priority
(兩處)與 task.count 又沒有呼叫 escapeHtml()(同檔案其他欄位如
task.name、personName 都有)。攻擊者可以在這兩個欄位塞入惡意 HTML,
存進班表後透過免登入的分享連結(/api/schedule-shares/:token)讓任何
打開連結的人在瀏覽器裡執行注入的內容——分享連結原本就是設計給完全
沒有後台存取權的第三方看的,這條鏈讓攻擊不需要任何權限就能觸發。

修法(兩處都補,缺一不可):
- scheduleRenderer.js:priorityCls/task.priority/task.count 補上
  escapeHtml(),跟同檔案其他欄位一致
- validators.js 新增 validateScheduleData(),比照既有 validateSettings
  的模式驗證班表週資料結構(dateRange/weekDayDates/scheduleDays/
  schedule/tasks 都要是正確型別,task.priority 限定 1-9 整數)
- schedules.js 的 POST /api/schedules 改用 validateScheduleData 取代
  原本只檢查「是不是非空陣列」的寬鬆判斷

新增 20 個測試(validators 15 個、scheduleRenderer 3 個、schedules
route 4 個,含用實際 XSS payload 字串驗證會被擋下/轉義),並更新
schedules.test.js 既有的 sampleData fixture 補齊新驗證要求的欄位。
後端 343/343、前端 83/83 全數通過。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117v1Qxx7foQEH4cd6pfnLS
前一個 commit(b8a2d1e)修的是 task.priority/task.count 這兩個欄位,
但實際對本機執行中的服務發動即時攻擊測試後,發現 escapeHtml() 本身
就有型別繞過漏洞:它只在 typeof unsafe === 'string' 時才轉義,陣列/
物件會原樣通過,之後樣板字串插值呼叫其 toString()(陣列會 join 內容)
時完全不會轉義,把原始 HTML 直接注入。

實測攻擊鏈(在本機 dev server 上完整跑過一次,非理論推測):
1. POST /api/schedules 送出 schedule[day][task][pi] 為陣列(不是字串)
   ["<img src=x onerror=alert(1)>"],繞過 validateScheduleData(它只
   檢查 Array.isArray(week.schedule),沒有深入驗證陣列內容型別)
2. 成功以 201 存進資料庫
3. 建立分享連結、以該連結的 GET 端點取回渲染後的 HTML
4. 確認回應內容包含未轉義的 <img src=x onerror=alert(document.domain)>
   ——證實可攻破,且分享連結本來就是設計給無登入權限的第三方看的

根本修法:escapeHtml() 一律先 String() 轉字串再轉義(null/undefined
仍照舊原樣回傳),不再只在 typeof === 'string' 時才處理,堵死透過
陣列/物件繞過轉義的路徑。同步修正 scheduleRenderer.js 與 validators.js
兩份 escapeHtml(後者未被生產程式碼引用,僅單元測試涵蓋,一併修正
避免留下地雷)。

修完後用同一個攻擊鏈重新測試,同一個分享連結回應已改為安全轉義的
&lt;img ...&gt;,確認漏洞已補上。測試完畢後已從真實資料庫清除攻擊
測試中建立的測試班表、分享連結、測試設定檔,未留下殘留資料。

新增 4 個回歸測試(validators 2 個、scheduleRenderer 2 個)直接重現
這次找到的陣列繞過手法,防止未來重構時再度引入。後端 348/348 通過。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117v1Qxx7foQEH4cd6pfnLS
@Cody8722
Cody8722 merged commit 9535660 into release Sep 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant