依存ツリーのどのクレートも、/etc/passwd を開いたり、~/.ssh ディレクトリに書き込んだり、プロジェクト内のすべてのファイルを列挙したりできる。Rust の標準ライブラリは許可を求めない。std::fs::File::open を呼べるコードなら、オペレーティングシステムが許すあらゆるパスに触れる権限を持つものとして扱う。

cap-std はこの前提を覆す。Rust の std I/O モジュールの代替として設計され、暗黙的な権限を明示的な能力に置き換える。ファイルを開きたければ、まずそのファイルを含むディレクトリへのアクセス権を証明する能力が必要だ。

これは思った以上に重要だ。サプライチェーン攻撃は必ずしも高度な脆弱性を必要としない。ビルドスクリプトや推移的依存ライブラリが、テスト実行中にこっそりとファイルを持ち出すだけで十分なこともある。コンテナや仮想マシンを使わずにライブラリレベルでそのリスクをサンドボックス化するのが、cap-std が実現することだ。

能力ベースのファイルシステムアクセスは実際にどう見えるか

標準の 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::Filestd::fs::File をラップする。cap_std::fs::Dir は新しい入り口で、特権的な OS 呼び出しではなく型システムによって強制される chroot のような働きをする。

このクレートは cap-primitives と呼ばれる低レベルな抽象化を通じてこれを実現している。内部的に、cap-std は Unix では openat スタイルのシステムコールを、Windows では同様の制限付き相対操作を使用する。標準ライブラリの制限なし File::open は決して呼ばない。Linux では可能な場合にシンボリックリンクの脱出を防ぐため openat2RESOLVE_BENEATH を指定して使う。Windows では制限付きフラグを伴う NtCreateFile を使う。

これはコンテナや 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 サンドボックス、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 クレートは &PathPathBuf を受け入れる。それらは暗黙的な権限を前提としている。cap-std と、たとえばディスク上のインクルードファイルを読む設定解析クレートを併用したい場合、そのクレートが cap-std に対応しているか、自分でファイルを読んで文字列の内容をパーサに渡す必要がある。

これは非同期 Rust への移行と同じ話だ。std::fs::File を受け取る関数は、非同期ファイルハンドルを期待する非同期コードから呼べないのと同じように、&Path を受け取る関数は &Dir で呼べない。境界は明確だが、実在する。

cap-std が意図的にサポートしない操作もある。ディレクトリツリーの外にハードリンクを作成することはできない。能力境界の外を指す任意のシンボリックリンクを辿ることはできない。能力ベースのプログラムにおいて std::env::current_dir を呼んでそれが何かを意味すると仮定することはできない。これらはバグではない。セキュリティモデルなのだ。

パフォーマンスは通常問題にならない。Linux の openat2 では、オーバーヘッドは既存のシステムコールにいくつかの追加フラグを渡すだけだ。ユーザ空間のフォールバックを使用するプラットフォームでは、パス解決は遅くなるが、通常は実際の入出力と比較して無視できる程度だ。

摩擦を伴っても cap-std を使う価値がある場面

すべてのコマンドラインツールやウェブサーバーに cap-std が必要なわけではない。依存ライブラリを信頼しており、脅威モデルが悪意あるクレートではなく外部からの攻撃者である場合、コンテナや通常の OS パーミッションで十分だ。

cap-std が輝くのは、いくつかの特定の場面だ:

プラグインシステム。 アプリケーションが信頼できない WASM モジュールやネイティブプラグインを読み込む場合、cap-std を使えば各プラグインにサンドボックスを表す Dir 能力を付与できる。プラグインはカーネルの脆弱性を突かない限り脱出できない。なぜなら API 自体が「任意のファイルを開く」という概念を表現しないからだ。

ビルドツールとパッケージマネージャー。 これらはインターネットから任意のコードを実行する。cap-std を使う build.rs スクリプトは、ホームディレクトリへの能力を明示的に渡さない限り SSH 鍵を読めない。

WASI ターゲット。 WebAssembly System Interface は能力ベースで構築されている。cap-std の API 設計は WASI に直接影響を与え、cap-std を使うことで WASI への移植が容易になる。なぜならセキュリティモデルがすでに一致しているからだ。

テストと再現性。 &Dir を受け取り、カレントワーキングディレクトリに依存しないテストは、デフォルトで密閉的だ。chdir して状態を復元する必要はない。テストに一時ディレクトリの能力を渡すだけでよい。

はじめよう

Cargo.toml に cap-std を追加する:

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

ファイルを読むモジュールを一つ選び、公開 API を &Path の代わりに &Dir を受け取るように変更し、呼び出し元を更新する。すべてを一度に移行する必要はない。cap-std と std::fs は同じバイナリ内で共存できる。移行はモジュールごとにオプトインだ。

ファイルを読むライブラリをメンテナンスしているなら、既存のパスベースの API に加えて cap_std::fs::Dir ベースの API を追加することを検討しよう。WASI ユーザは感謝し、セキュリティを意識するユーザはアプリケーションのサンドボックス化を容易にできる。

cap-std のリポジトリは github.com/bytecodealliance/cap-std にある。クレートのドキュメントにはプラットフォームサポートの注記と完全な API 一覧が含まれている。入り口として Dir::open_ambient_dir から始め、次に絶対パスを受け入れる std::fs の呼び出しをすべて削除しよう。自分のコードがどれだけ多くの暗黙的な権限を持ち歩いていたか、驚くはずだ。