重點摘要

  • 把Mistboard存放在Railway上的憑證全部輪替一遍,用了9次輪替,刪除了22個變數,在19小時內花了約2小時實際操作,外加一次我自己造成的12分鐘資料庫故障。
  • 我最大的曝險是Postgres密碼:資料庫開著一個公開的TCP代理,外洩的連線字串在網際網路上任何地方都能用。
  • 故障的起因是:執行中的容器還拿著舊密碼,我就改了這個密碼。再加一個資料庫角色,新舊密碼就能並存,同樣的輪替可以做到零停機。
  • 一次指紋比對揪出一個過期的發布權杖,它原本會擋住下一次部署,而比對全程沒有印出任何一方的值。

太平洋時間10月21日下午4:55,Railway寄來一封信,主旨是「Urgent: Railway Security Disclosure - Rotate Credentials」。第一條指示是:「立即撤銷並重新核發存放在Railway中的所有環境變數,包括所有API金鑰。」

Mistboard是我一個人經營的象棋及其變體網站。它在Railway上以三個服務執行:web(Node伺服器和網站)、engine-worker(機器人背後的對弈引擎)和Postgres。這些服務用到的每一組金鑰,都是Railway的環境變數。

Railway通報資安事件,並暫停建置一天

Railway的揭露公告指出,他們「偵測到涉及儲存客戶環境變數之系統的異常活動」,未經授權的第三方「可能存取了加密的環境變數,並取得其中部分變數的解密內容」。影響「可能僅限於部分使用者」,這些使用者會收到電子郵件。我收到的是一般通知,所以我把每個值都當作已經外洩。兩天後,第二封信縮小了範圍:我的工作區中的變數符合攻擊者用來搜尋AI與雲端服務商API金鑰的模式,不過Railway無法確定其中是否有變數遭到解密。那時,工作區裡的每組金鑰都已經輪替過了。

建置暫停左右了之後的一切。執行中的容器還在用某組金鑰時就把它撤銷,相應功能會一直壞著,直到新的建置上線;而在大半天裡,沒有任何新建置能上線。

資料庫密碼是我最大的曝險

我依照持有者能對網站或玩家做什麼,替每組金鑰排序。

金鑰 持有者能做什麼 我查到的情況
Postgres密碼(帶公開TCP代理) 從任何地方讀寫任何資料表 沒有新增角色,沒有無法解釋的管理工作階段
電子郵件API金鑰 以mistboard.com的名義寄信,例如偽造的登入驗證碼 寄送紀錄無異常
LLM API金鑰(論壇翻譯) 用我的帳單跑推論 這組金鑰30天內的每次呼叫,都和伺服器儲存的一筆翻譯對得上
分析平台個人API金鑰 透過完整API讀取網站的分析資料 已輪替
私有引擎儲存庫的部署金鑰 讀取引擎原始碼 已更換,舊金鑰已刪除
金流金鑰 很少:計費尚未上線 已輪替
兩組內部服務權杖 呼叫網站自己的內部端點 已重新產生

各服務透過Railway的私有網路連到資料庫,但資料庫還在一個公開連接埠上開著TCP代理,留著它是為了能在筆電上跑管理腳本。有了這個代理,連線字串在任何機器上都是一組能用的登入資訊。刪掉它只要點一下,正式環境沒有任何影響,這也是我最先做的事之一。現在管理腳本透過一條短期的SSH通道進入私有網路來連資料庫。

資料庫的副本裡不會有任何密碼。登入用的是透過電子郵件寄送的一次性驗證碼,網站只以雜湊形式儲存登入驗證碼和工作階段權杖,所以讀到的內容不足以讓人以玩家身分登入。

從收到信到最後一次檢查,花了19小時

時間(PDT) 誰 發生什麼事
10月21日 00:24 Railway 第一則狀態公告;暫停建置
10月21日 04:33 Railway 儀表板、API和CLI停擺;所有區域暫停建置
10月21日 16:55 Railway 揭露信:全部輪替
10月21日 17:30 我 建立追蹤表;盤點程式碼讀取的每一組金鑰
10月21日 17:40 我 資料庫審查:沒有新增角色,沒有無法解釋的管理工作階段
10月21日 18:04 我 停用LLM金鑰;論壇翻譯關閉,直到下次部署
10月21日 18:08 我 移除資料庫公開代理;電子郵件寄送紀錄無異常
10月22日 約00:10 Railway 付費方案恢復建置,接著幾小時建置緩慢
10月22日 10:31 我 在各供應商處輪替分析、金流和Webhook金鑰
10月22日 10:38 我 推送8個新值;web和engine-worker重新建置,各需20分鐘以上
10月22日 10:52 我 刪除22個失效變數
10月22日 11:04 我 engine-worker的建置用新部署金鑰複製了引擎儲存庫;舊金鑰已刪除
10月22日 11:23 我 重新產生資料庫密碼;故障開始
10月22日 11:25 我 Postgres恢復;engine-worker在重新啟動時當掉
10月22日 11:35 我 web使用新密碼上線;故障結束
10月22日 11:38 我 engine-worker使用新密碼上線
10月22日 11:50 我 透過新電子郵件金鑰收到登入驗證碼,舊金鑰已刪除;機器人對局和論壇翻譯正常
10月22日 11:55 我 發現並修正過期的發布權杖;輪替完成

實際操作時間是當晚約40分鐘,隔天上午90分鐘。第一個晚上做的是不需要建置的事。其餘的一切都在等建置佇列。

大多數供應商我都讓新舊並存。電子郵件金鑰一直保持有效,直到透過替換後的金鑰收到一封登入驗證碼;舊的部署金鑰則在一次建置用新金鑰複製了引擎儲存庫之後才刪除。Postgres是例外。

這些工作我是和Claude Code一起完成的。Claude維護追蹤表,根據程式碼建立清單,執行資料庫和紀錄審查,撰寫腳本,並跑了每一項檢查。供應商那邊的每一步都由我來做:輪替每組金鑰,把每個新值輸入macOS Keychain,移除代理,刪除變數,按下Regenerate。Claude只看到金鑰的名稱,從沒看過值:一支腳本把每個值從Keychain帶到Railway,並遮蔽輸出,所以沒有任何金鑰出現在對話紀錄、日誌或檔案裡。

修改資料庫密碼,讓所有資料庫讀取中斷了12分鐘

Railway的Regenerate按鈕會同時修改資料庫密碼和對應的變數。我原以為web和engine-worker從已經建置好的映像檔重新部署時,只會有一分鐘左右的連線失敗。Railway的文件卻不是這麼說的:重新部署會「從該部署最初的原始碼重新建置映像檔」。重新啟動會沿用現有的部署,連同其中的變數,所以重新啟動後用的還是舊密碼。

Postgres兩分鐘就乾淨地恢復了,而執行中容器發出的每筆查詢都失敗了。網站本身沒有掛掉:頁面能載入,但凡是讀取資料庫的功能都失敗了。進行中的對局斷線,棋局研究、論壇、聊天和對局檢視器都在回傳錯誤,直到新的web建置上線。Railway的建置佇列還沒從事件中恢復,所以故障持續的時間恰好等於一次緩慢的建置;舊密碼已經沒了,也沒有更快的出路。

再加一個登入角色,同樣的輪替就能做到零停機。建立新角色,讓服務指向它,部署,等新部署運作正常後再停用舊角色。無論建置要多久,新舊角色都能同時運作(#614)。

engine-worker讓情況更糟。Postgres重新啟動時,worker的資料庫用戶端拋出一個沒有任何程式在監聽的錯誤,行程直接結束,而不是重新連線(#612)。資料庫重新啟動是例行狀況(維護、容錯移轉、更換方案),卻從來沒有測試讓worker經歷過一次。

一次指紋比對,在下一次發布前揪出過期權杖

發布時會先排空進行中的對局再重新啟動,而排空請求要帶一組權杖。發布腳本先在Keychain裡用舊的項目名稱查找,再用正式名稱查找。輪替時新值寫在正式名稱下,舊項目卻留在原處,所以下一次發布會送出舊權杖,收到401,無法排空。

九月有兩次發布因為排空時回傳401而失敗,之後我寫了drain:token-check。它依照發布讀取的順序,替本機每一份權杖副本計算指紋,並和正式環境容器裡的值逐一比對,全程不印出任何一個值。在下一次定期發布前4小時,它回報這組權杖被遮蔽了:正式環境的值確實在筆電上,但另一份較舊的副本會先被讀到。刪掉舊項目就修好了。

寫這個檢查的時候,我就知道有兩個名稱。我做了一個偵測器,卻留下了隱患,結果輪替又把它重新製造出來。修正方式是只用一個名稱,並讓推送腳本在每次輪替後執行這項檢查(#613)。

22個變數早已失效

第一版清單來自程式碼:伺服器和腳本讀取的每一個環境變數名稱。儀表板呈現的卻是另一番景象。22個變數已經失效(已下架遊戲變體的旗標、我不再建置的引擎的安裝開關),而程式碼讀取的金鑰名稱中有3個根本沒有在任何地方設定。目前還剩大約30對功能旗標,每個遊戲變體一對,兩個服務上都有。為了找出9組金鑰,我按名稱讀了每一個變數。

CI沒有需要輪替的東西。呼叫正式環境的排程工作流程使用短期的GitHub OIDC權杖驗證,由伺服器檢驗,所以儲存庫的secrets裡沒有存放任何憑證。

我要保留的7條規則

  1. 每組憑證都要新舊重疊。 在舊憑證失效之前,新憑證已經可用,所有供應商都一樣,包括資料庫。
  2. 切換之前,先測重新部署要多久。 如果舊憑證先失效,建置時間就是故障時間。讀清楚平台說的「重新部署」和「重新啟動」分別指什麼,然後兩者都計時。
  3. 每個長時間執行的行程,都要撐得過資料庫重新啟動。 測試方式:切斷用戶端的socket,斷言下一筆查詢成功。
  4. 每組金鑰只用一個名稱儲存。 舊名稱下的第二份副本,遲早會先被讀到。無法印出值的地方就比對指紋,並讓執行輪替的工具來做這項比對。
  5. 金鑰清單要短,並與設定分開。 輪替時讀的應該是一份9項的清單。功能旗標應該放在程式碼裡,或放在單獨一份清單裡(#615)。
  6. 關閉沒人需要的入站路徑。 資料庫公開代理替管理腳本省了一步,卻讓資料庫在好幾個月裡都能從網際網路連到,只隔著一組密碼。
  7. 優先使用不需要儲存的憑證。 有了OIDC,CI裡的輪替次數是零。

下一步是一次計時演練:把Postgres傾印檔還原到另一台主機上,再讓web連上它啟動,這樣在別處復原所需的時間就是一個已知的時數。