具代表性的面試主題

Go 程式設計面試:如何用 os.Root 安全處理不受信任的檔名?

程式題中等
Offer.cc 編輯團隊發佈 更新

題幹

一個壓縮檔解壓服務會收到不受信任的檔名,要求所有輸出都留在指定目錄內。請用 Go 1.24+ 設計安全實作,並解釋 ..、符號連結、TOCTOU、資源關閉與跨平台限制。

題幹與適用場景

一個壓縮檔解壓服務會把使用者上傳的檔案寫入 /srv/uploads/job-42。壓縮檔項目的檔名完全由外部輸入提供,攻擊者可能提交 ../../etc/passwd、絕對路徑,或指向目錄外的符號連結。服務需要建立目錄、寫入檔案,並保證每次檔案操作都不能越過工作目錄。

請用 Go 1.24 或更新版本說明實作。假設程序使用一般應用程式權限,Linux 是主要生產平台,但客戶端也可能在 Windows 或 WASI 執行。回答要區分「檔名不受信任」和「使用者明確選擇任意輸出目錄」兩種需求。

面試官考察點

  • 是否先定義根目錄與攻擊者可控輸入,再選擇目錄限制 API,而不是只做字串替換。
  • 是否知道 os.Root 會阻止 .. 與越過根目錄的符號連結,並理解它和 filepath.Clean 的邊界不同。
  • 是否能辨識 EvalSymlinks 再檢查存在 TOCTOU 視窗,說明為何要讓一次開啟操作承擔限制。
  • 是否處理 Root、檔案控制代碼與暫存檔的關閉、失敗清理和權限策略。
  • 是否主動說明 bind mount、GOOS=js、WASI 實作能力及過深目錄帶來的限制。

回答前需要澄清的問題

  1. 要解壓讀取、寫入,還是兩者都要?唯讀和建立需要不同的 OpenFile 旗標與權限。
  2. 根目錄是服務固定的,還是呼叫者可選?若呼叫者可以選任意目錄,os.Root 不會額外形成沙箱邊界。
  3. 是否允許壓縮檔建立符號連結、硬連結、裝置檔或重新命名?不允許的類型要在壓縮檔解析層拒絕,不能期待 Root 補齊所有策略。
  4. 目標平台是否包含 GOOS=js 或 WASI?官方資料指出這些平台的路徑安全保證和 Unix 檔案描述元實作不同。

30 秒回答框架

「我會把工作目錄當成唯一根,所有外部項目都作為相對名稱交給 os.OpenRoot 回傳的 Root,不再用 filepath.Join 後呼叫普通 os.Create。建立、開啟和刪除都透過 Root 方法執行;.. 或逃逸符號連結直接失敗。寫入採用暫存檔與受限權限,再按業務規則提交。測試涵蓋路徑逃逸、符號連結競態、並行寫入、Windows 保留裝置名稱和根目錄關閉。Root 能防止目錄外路徑,但不能取代壓縮檔類型檢查、權限隔離或 bind mount 防護。」

分步驟深入解答

1. 先固定安全邊界

不要把「不包含 ..」當成安全定義。攻擊者可以利用符號連結,或在檢查和開啟之間替換目錄項目。安全不變量應寫成:每次由壓縮檔名稱觸發的檔案系統操作,其解析結果都位於工作根目錄樹內;任何無法證明的操作都失敗。

Go 1.24 的 os.OpenRoot 會開啟根目錄,Root.OpenRoot.CreateRoot.OpenFileRoot.MkdirRoot.Stat 等方法接受相對於該根的名稱。官方實作會拒絕透過 .. 或符號連結離開根目錄。根物件應在任務生命週期內重複使用,並在所有檔案控制代碼關閉後 Close

go
func writeEntry(rootDir, name string, data []byte) error {
    root, err := os.OpenRoot(rootDir)
    if err != nil {
        return err
    }
    defer root.Close()

    f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
    if err != nil {
        return err
    }
    defer f.Close()

    _, err = f.Write(data)
    return err
}

這個範例保證名稱解析不會逃逸,但生產實作仍要限制檔案大小、項目數量和目錄深度,並在寫入失敗時刪除不完整檔案。O_EXCL 只能表達「不覆寫已存在檔案」;它不負責壓縮檔去重或業務冪等。

2. 解釋為何字串清理不足

filepath.Clean 可以把 a/../b 正規化,卻不能證明 a 不是指向根目錄外的符號連結。先呼叫 EvalSymlinks、再檢查前綴也有 TOCTOU:檢查完成後,目錄項目可能被替換。普通 filepath.Joinos.Create 更沒有目錄邊界保證。

Root 在 Unix 上以根目錄檔案描述元與相對開啟操作為基礎,限制解析過程,而不是把安全性寄託在一次獨立檢查。它允許根內相對路徑和根內符號連結;../ 指向根外或絕對符號連結則應失敗。呼叫者仍需拒絕壓縮檔中的符號連結項目,除非產品明確支援它們。

3. 處理壓縮檔類型與寫入流程

解壓器先讀取項目類型、大小、權限和名稱,拒絕裝置檔、FIFO、硬連結以及產品不允許的符號連結。對普通檔案,使用 Root.MkdirMkdirAll 建立父目錄,再用 Root.OpenFile 建立目標;對目錄,使用 Root.Mkdir。總大小、單檔大小、路徑元件數和並行任務數都要有預算。

更穩妥的寫入流程是「暫存名稱 → 完整寫入 → 校驗 → 原子提交」。暫存名稱也必須透過 Root 建立,避免暫存檔落在系統暫存目錄後再跨目錄移動。若 Root 版本缺少所需的重新命名能力,應把這項能力作為平台相容層或產品限制明確化,不能偷偷退回不受限制的普通路徑 API。

4. 說明平台與權限邊界

官方資料指出,Unix 實作通常追蹤根目錄檔案描述元,根目錄重新命名後仍指向原目錄;Windows 使用控制代碼並阻止某些保留裝置名稱。GOOS=js 的檔案 API 缺少 openat 家族呼叫,符號連結驗證存在 TOCTOU 限制;WASI 的保證取決於具體實作。Root 也不阻止 Linux bind mount、/proc 特殊檔案或 Unix 裝置檔存取。

因此,容器隔離、掛載策略、程序權限和壓縮檔類型白名單仍是獨立控制。應用程式不應把 Root 描述成完整容器沙箱,也不應把客戶端選擇的任意目錄誤稱為受限根目錄。

5. 設計可執行的驗證

測試至少包含:../escape、絕對路徑、根內符號連結、指向根外的符號連結、名稱以 ../ 結尾、並行建立同一檔案、超大項目和根目錄關閉後的呼叫。Go 1.24.3 修復了 Root 路徑以 ../ 結尾時可能開啟父目錄的問題,因此 CI 應固定包含修復版本的工具鏈,並保留這個回歸案例。

在 Linux 上用暫存目錄和真實符號連結測試成功與失敗路徑;用 -race 檢查共享狀態,但不能把 race detector 當作檔案系統邊界證明。跨平台建置不能證明每個平台擁有相同的核心保證,必須把 GOOS 作為測試矩陣的一部分。

高品質示範回答

「我先把工作目錄固定為根,規定不受信任的壓縮檔名稱只能作為 os.Root 的相對路徑。解壓流程拒絕裝置檔、硬連結和不允許的符號連結,限制項目數量、單檔大小和目錄深度;普通檔案透過 Root.OpenFile 建立,父目錄也透過 Root 建立,寫入失敗就刪除暫存目標。這樣 .. 和指向根外的符號連結由同一次受限制的開啟操作拒絕,避免先清理、再開啟的 TOCTOU 視窗。

我不會把它說成完整沙箱:Linux bind mount、程序權限、容器掛載和壓縮檔類型策略仍要分開處理。主要生產平台用真實符號連結和並行測試驗證逃逸、重複建立、資源關閉和路徑末尾 ../;Windows、WASI 與 GOOS=js 分別驗證它們的實作邊界。若產品要求呼叫者寫入任意目錄,我會取消 Root 的假設,改用明確的權限與稽核策略。」

常見錯誤

  • 錯誤表現: filepath.Join(base, name) 後直接 os.Create失敗原因: .. 和符號連結可以讓解析結果離開 base → 修正方法: 固定根並透過 Root 方法執行每次操作。
  • 錯誤表現:EvalSymlinks 檢查,再呼叫普通開啟 → 失敗原因: 檢查與使用之間存在 TOCTOU → 修正方法: 讓目錄限制參與實際開啟,並用競態測試驗證失敗路徑。
  • 錯誤表現: 認為 os.Root 會阻止所有檔案系統越界 → 失敗原因: bind mount、裝置檔和權限不屬於同一 API 保證 → 修正方法: 加上掛載、權限和壓縮檔類型白名單。
  • 錯誤表現: 忽略 GOOS=js、WASI 和 Go 修補版本 → 失敗原因: 平台實作和安全修復並不一致 → 修正方法: 固定工具鏈,建立跨平台矩陣和針對性回歸案例。
  • 錯誤表現: 只測試成功解壓 → 失敗原因: 路徑安全的證據來自拒絕逃逸與資源清理 → 修正方法: 測試 ..、絕對路徑、符號連結、並行衝突、超限和關閉後的呼叫。

追問及應對

追問一:如果壓縮檔包含指向根內的符號連結,應該允許嗎?

先根據產品需求決定。若必須保留連結,應透過 Root 驗證連結目標仍在根內,並限制連結數量與深度;若只是解壓普通檔案,拒絕符號連結更容易稽核。無論選擇哪種策略,都不能讓 Root 自動取代壓縮檔類型白名單。

追問二:Linux bind mount 如何處理?

Root 不提供 bind mount 隔離。生產環境應讓容器或宿主機掛載策略把工作目錄放在專用檔案系統,限制應用程式權限,並在部署檢查中禁止不受信任的掛載。若威脅模型包含特權攻擊者,需要更強的沙箱或獨立 worker,而不是繼續堆疊路徑字串檢查。

追問三:為什麼不能把暫存檔寫到系統暫存目錄,再移到根目錄?

跨目錄移動會引入權限、掛載和原子性差異,而且暫存檔本身可能外洩。暫存名稱應透過 Root 建立並在同一根內完成寫入和提交;若平台 API 無法滿足原子提交,就明確降低功能或採用經過稽核的相容實作,不能無提示地退回普通路徑拼接。

追問四:GOOS=js 是否可以宣稱同樣安全?

不能。官方說明該平台缺少 openat 家族能力,符號連結驗證存在 TOCTOU 風險。應在建置目標上收緊功能、使用瀏覽器或執行環境提供的沙箱,或把不受信任檔案處理移到具備更強目錄限制的伺服器端;文件和 API 必須公開這項差異。

公開來源

同類題目

相關面試工具

用 Screenshot 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具