依賴樹中的任何 crate 都可以開啟 /etc/passwd、寫入你的 ~/.ssh 目錄,或是列舉專案中的每一個檔案。Rust 的標準函式庫不會要求任何權限。它預設任何有能力呼叫 std::fs::File::open 的程式碼,都有權碰觸作業系統允許的任何路徑。

cap-std 改變了這個預設。它是 Rust std I/O module 的直接替代方案,以明確的權能取代環境權限。如果你想開啟一個檔案,首先必須持有一個能證明你可以存取該檔案所在目錄的權能。

這件事的重要性超乎你的想像。供應鏈攻擊不一定需要複雜的漏洞利用。有時候只需要一個建置腳本,或是一個遞移依賴,就能在你執行測試時悄悄竊取檔案。cap-std 所實現的,就是在函式庫層級對這類風險進行sandbox 隔離,而且不需要 container或虛擬機器。

權能式檔案系統存取的實際樣貌

在標準 Rust 中,開啟檔案只需要一行,完全沒有任何前置條件:

use std::fs::File;

// Any code, anywhere, can do this
let file = File::open("/etc/passwd")?;

這個路徑是絕對路徑,權限是環境隱含的。作業系統會檢查權限,但程式本身從來不需要證明自己一開始就應該有能力建構出這個路徑。

cap-std 改用雙步驟模式來取代這種做法。你從一個權能開始,通常是一個目錄控制代碼,而且只能開啟相對於該目錄的路徑:

use cap_std::fs::Dir;
use std::path::Path;

// Open the current working directory as a capability
let cwd = Dir::open_ambient_dir(".", cap_std::ambient_authority())?;

// Now we can only open files inside this directory tree
let file = cwd.open("config/app.toml")?;

請留意 open_ambient_dir 這個呼叫。這就是逃生口。它將環境路徑轉換為權能,而且刻意設計得冗長且易於審查。一旦你取得了 Dir,就不存在接受絕對路徑的 open 方法。這個 API 根本不允許這麼做。

cap-std 如何鏡像 std,同時不複製其安全模型

cap-std 的結構設計為 std::fsstd::netstd::os::unix::net 的直接替代品。型別刻意設計得讓人感到熟悉。cap_std::fs::File 包裹了 std::fs::Filecap_std::fs::Dir 是新的進入點,大致類似於在 chroot 環境中操作,只不過這裡的強制執行來自型別系統,而非需要特權的作業系統呼叫。

這個 crate 透過一個稱為 cap-primitives 的低階抽象來達成這一點。在底層,cap-std 在 Unix 上使用 openat 風格的系統呼叫,在 Windows 上則使用類似的受限相對操作。它從不呼叫標準函式庫中不受限制的 File::open。在 Linux 上,當系統支援時,它會使用帶有 RESOLVE_BENEATHopenat2 來防止符號連結逃逸。在 Windows 上,則使用帶有受限旗標的 NtCreateFile

這不是 container或 seccomp 的外包裝。它是標準檔案系統 API 的重新實作,只是單純省略了危險的操作。

Dir 型別支援你預期的大部分操作:opencreate_dirrenameremove_fileread_dir 等等。關鍵差異在於每一個操作都相對於那個目錄控制代碼。如果你想把檔案移到樹狀結構之外,就需要兩個 Dir 權能,一個給來源、一個給目的地。這個 API 強迫你隨身攜帶存取證明。

cap-std 使用的可攜性技巧

檔案系統的權能模型因作業系統而異。Linux 有 openat2。FreeBSD 有 cap_rights_limitO_RESOLVE_BENEATH。Windows 有基於控制代碼的存取控制,但沒有 RESOLVE_BENEATH 的直接對應功能。macOS 是主要平台中支援最有限的一個。

cap-std 透過可攜性層來處理這個問題。在核心支援強大的系統上,它使用原生的限制機制。在缺乏這類支援的系統上,則退回到使用者空間的實作,手動解析符號連結,並驗證相對路徑的每一個組成部分都停留在權能邊界之內。

這種退避機制的成本較高,但這意味著你的安全模型具有可攜性。無論是 WASI sandbox、Linux 伺服器,還是開發者的 MacBook,都可以強制執行相同的權能邊界,而無需在應用程式中撰寫任何平台相關的程式碼。

在實際專案中使用 cap-std 的樣貌

轉換現有專案的痛苦程度比你預期的低,因為這些 API 刻意設計得接近 std。主要的改變在於,你不再把 &Path&str 當作檔案系統定址單位到處傳遞。你改為傳遞 &Dir

以下是一個簡化範例,示範如何從由呼叫方控制的目錄中讀取設定:

use cap_std::fs::Dir;
use std::io::{self, Read};

pub fn load_config(dir: &Dir, name: &str) -> io::Result<String> {
    let mut file = dir.open(name)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

load_config 函式無法存取指定目錄之外的檔案。它不需要知道那個目錄在磁碟上的實際位置。這讓隔離測試變得極其簡單:

use cap_std::fs::Dir;
use cap_tempfile::TempDir;

#[test]
fn test_load_config() -> std::io::Result<()> {
    let tmp = TempDir::new(Default::default())?;
    let dir = tmp.dir();

    {
        let mut f = dir.create("app.toml")?;
        std::io::Write::write_all(&mut f, b"key = \"value\"")?;
    }

    let config = load_config(dir, "app.toml")?;
    assert!(config.contains("value"));
    Ok(())
}

cap-tempfile 將臨時目錄以權能的形式提供,因此就連你的測試也不會授予環境權限。如果 load_config 函式試圖開啟 ../etc/passwd 或任何絕對路徑,無論測試或呼叫方提供了什麼,都一定會失敗。

切換之後會有什麼限制

最大的限制在於生態系統的相容性。大多數會接觸檔案系統的 Rust crate 都接受 &PathPathBuf。它們預設環境權限。如果你想將 cap-std 與某個會從磁碟讀取引入檔案的設定解析 crate 一起使用,那個 crate 必須支援 cap-std,或者你需要自己讀取檔案,再將字串內容傳給parser。

這與 async Rust 的遷移歷程如出一轍。就像接受 std::fs::File 的函式無法從預期 async 檔案控制代碼的 async 程式碼中呼叫一樣,接受 &Path 的函式也無法用 &Dir 來呼叫。這條邊界清晰而真實。

此外,cap-std 也刻意不支援某些操作。你不能建立會逃逸出目錄樹的硬連結。你不能追蹤指向權能邊界之外的任意符號連結。你不能呼叫 std::env::current_dir 並假設它在權能式程式中有任何意義。這些不是漏洞。它們就是安全模型本身。

效能通常不成問題。在支援 openat2 的 Linux 上,額外負擔只是現有系統呼叫多了幾個旗標。在使用使用者空間退避機制的平台上,路徑解析會較慢,但與實際的輸入輸出相比,通常仍然可以忽略不計。

什麼時候值得忍受 cap-std 帶來的摩擦

你大概不需要為每一個命令列工具或網頁伺服器都導入 cap-std。如果你信任自己的依賴項目,而且威脅模型是外部攻擊者而非惡意 crate,那麼 container和一般的作業系統權限就足夠了。

cap-std 在以下幾個特定情境中特別出色:

外掛系統。 如果你的應用程式載入不受信任的 WASM module 或原生外掛,cap-std 讓你可以授予每個外掛一個代表其 sandbox的 Dir 權能。外掛無法在沒有核心漏洞利用的情況下逃逸,因為這個 API 根本不存在「開啟任意檔案」的概念。

建置工具與套件管理器。 這類工具會執行來自網際網路的任意程式碼。使用 cap-std 的 build.rs 腳本無法讀取你的 SSH 金鑰,除非你明確地交給它一個指向家目錄的權能。

WASI 目標平台。 WebAssembly System Interface 建立在權能之上。cap-std 的 API 設計直接影響了 WASI,使用 cap-std 讓移植到 WASI 變得簡單明瞭,因為安全模型已經一致。

測試與可重複性。 接受 &Dir 而非依賴目前工作目錄的測試,預設就是密封的。你不需要 chdir 再恢復狀態。你只需要交給測試一個臨時目錄權能即可。

開始使用

將 cap-std 加入你的 Cargo.toml

[dependencies]
cap-std = "3.0"
cap-tempfile = "3.0"

選擇一個讀取檔案的 module,將其公開 API 改為接受 &Dir 而非 &Path,然後更新所有呼叫端。你不必一次遷移所有東西。cap-std 和 std::fs 可以在同一個執行檔中共存。遷移是以 module 為單位選擇性進行的。

如果你維護一個會讀取檔案的函式庫,可以考慮在現有的路徑式 API 之外,額外提供一個基於 cap_std::fs::Dir 的 API。你的 WASI 使用者會感謝你,而注重安全的使用者也會更容易為他們的應用程式建立 sandbox。

cap-std 的程式碼倉庫位於 github.com/bytecodealliance/cap-std。這個 crate 的文件包含了平台支援說明與完整的 API 介面。從 Dir::open_ambient_dir 這個進入點開始,然後試著移除所有其他接受絕對路徑的 std::fs 呼叫。你會驚訝地發現,原來你的程式碼一直帶著這麼多環境權限到處跑。