의존성 트리의 모든 크레이트는 /etc/passwd를 열거나 ~/.ssh 디렉터리에 쓰거나, 프로젝트의 모든 파일을 열거할 수 있다. Rust 표준 라이브러리는 접근 권한을 묻지 않는다. std::fs::File::open을 호출할 수 있는 코드라면 운영체제가 허용하는 어떤 경로든 접근할 수 있다고 가정한다.

cap-std는 이러한 가정을 뒤집는다. Rust의 std 입출력 모듈을 그대로 대체할 수 있는 라이브러리로, 주변 권한을 명시적 기능으로 대체한다. 파일을 열고 싶다면 먼저 해당 파일이 들어 있는 디렉터리에 접근할 수 있음을 증명하는 기능이 필요하다.

이는 생각보다 중요하다. 공급망 공격이 항상 정교한 취약점을 필요로 하는 것은 아니다. 때로는 빌드 스크립트나 전이 의존성이 테스트가 실행되는 동안 조용히 파일을 유출하기만 하면 된다. 컨테이너나 가상 머신 없이 라이브러리 수준에서 해당 위험을 샌드박스로 격리하는 것이 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가 표준 라이브러리를 모방하면서도 보안 모델을 복사하지 않는 방법

cap-std는 std::fs, std::net, std::os::unix::net의 직접적인 대체재로 구성된다. 타입은 의도적으로 친숙하게 설계되었다. cap_std::fs::Filestd::fs::File을 감싸고, cap_std::fs::Dir은 새로운 진입점으로 대략 chroot 내부에서 작업하는 것과 유사하지만 특권 있는 운영체제 호출이 아닌 타입 시스템에 의해 강제된다.

이 크레이트는 cap-primitives라는 하위 수준 추상화를 통해 이를 달성한다. 내부적으로 cap-std는 Unix에서는 openat 스타일의 시스템 호출을, Windows에서는 유사한 제한된 상대적 작업을 사용한다. 표준 라이브러리의 제한 없는 File::open은 절대 호출하지 않는다. Linux에서는 사용 가능할 때 심볼릭 링크 탈출을 방지하기 위해 openat2RESOLVE_BENEATH를 사용한다. Windows에서는 제한된 플래그와 함께 NtCreateFile을 사용한다.

이것은 컨테이너나 seccomp를 감싸는 래퍼가 아니다. 위험한 작업을 단순히 생략한 표준 파일 시스템 API의 재구현이다.

Dir 타입은 예상하는 대부분의 기능을 지원한다. open, create_dir, rename, remove_file, read_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를 인식하거나 직접 파일을 읽어 문자열 내용을 파서에 전달해야 한다.

이는 async Rust로의 전환과 동일한 이야기다. std::fs::File을 받는 함수가 비동기 파일 핸들을 기대하는 비동기 코드에서 호출될 수 없는 것처럼, &Path를 받는 함수는 &Dir로 호출될 수 없다. 경계는 명확하고 실재한다.

cap-std가 의도적으로 지원하지 않는 작업들도 있다. 디렉터리 트리를 벗어나는 하드 링크를 생성할 수 없다. 기능 경계 외부를 가리키는 임의의 심볼릭 링크를 따라갈 수 없다. std::env::current_dir을 호출하고 기능 기반 프로그램에서 이것이 무언가를 의미한다고 가정할 수 없다. 이것들은 버그가 아니다. 이것이 보안 모델이다.

성능은 보통 문제가 되지 않는다. openat2가 있는 Linux에서는 기존 시스템 호출에 추가 플래그 몇 개만 더 붙는 수준이다. 사용자 공간 대체 구현을 사용하는 플랫폼에서는 경로 해석이 더 느리지만, 일반적으로 실제 입출력과 비교하면 여전히 무시할 수 있는 수준이다.

cap-std의 마찰이 감수할 가치가 있는 때

모든 명령줄 도구나 웹 서버에 cap-std가 필요한 것은 아니다. 의존성을 신뢰하고 위협 모델이 악의적인 크레이트가 아닌 외부 공격자라면, 컨테이너와 일반적인 운영체제 접근 권한으로 충분하다.

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 호출을 제거해보라. 코드가 얼마나 많은 주변 권한을 떠안고 있었는지 놀랄 것이다.