Skip to content

Fearlessly build arbitrary untrusted Cargo projects

Notifications You must be signed in to change notification settings

madsmtm/cargo-sandbox

Repository files navigation

Sandboxing Cargo

Goal: Allow fearlessly running cargo-sandbox build within an arbitrary untrusted Rust project.

This includes running build scripts and procedural macros for arbitrary untrusted dependencies.

Obligatory XKCD.

Platforms

Platform Support Sandboxing mechanism
macOS ⚠️ sandbox_init
Linux TODO
FreeBSD TODO
Windows TODO

Setup

Install using:

$ cargo install cargo-sandbox

This will install the binary cargo-sandbox and the helper dylib cargo-sandbox-interceptor.

If you intend to use this in untrusted projects (as opposed to just for untrusted dependencies), you probably also want to also run:

$ rustup set auto-install disable

To avoid issues with a malicious project-local rust-toolchain.toml automatically installing a vulnerable or local toolchain.

(As a bonus, this will also make your cargo invocations slightly faster, see rust-lang/rustup#2626).

Usage

$ cd your_project
$ cargo-sandbox check
$ cargo-sandbox build --whatever-flag
$ cargo-sandbox +nightly doc
$ # etc.

This invokes cargo $YOUR_CMD, and will sandbox all rustc and build script invocations that Cargo makes.

TODO: Document how to use with rust-analyzer.

Configuration

By default, cargo-sandbox will allow read and execute access to files in to the crate's directory, system files, as well as anything in $PATH or $PATH/../lib. This should allow most projects to build out of the box, but if you find that this is too restrictive, you can configure the sandboxing using the configuration file in $CARGO_HOME/cargo-sandbox.toml (usually ~/.cargo/cargo-sandbox.toml).

Configuration can be global, scoped to a specific build script or scoped to a specific proc-macro. After modifying the configuration, you might need to run cargo clean.

An example configuration file could be:

# Proc macros from the `sqlx` crate requires local network access to the
# database for the `query!` macro to work.
# TODO: Should specify the actual proc-macro crate `sqlx-macros`.
[proc-macros.sqlx.network]
local = "allow"

# Allow global network access to `mylib-sys`'s build script, e.g. if it pulled
# in its contents from the network.
[build-scripts.mylib-sys.network]
external = "allow"

# Allow read access to some global `sdkconfig.defaults` that is set in
# `[env] ESP_IDF_SDKCONFIG_DEFAULTS=...` for the `esp-idf-sys` build script.
[[build-scripts.esp-idf-sys.paths]]
path = "/Users/username/sdkconfig.defaults"
read = "allow"

# When using a temporarily downloaded `zig cc` as the C compiler or linker, e.g.
# with `CC=zig cc` or `[build] linker = "~/Downloads/zig-cc-wrapper"`, allow
# read and execute access to it.
[[paths]]
path = "/Users/username/Downloads/zig"
read = "allow"
# TODO: exec = "allow"

# Allow read and execute access to packages in Homebrew.
[[paths]]
path = "/opt/homebrew/bin"
read = "allow"
# TODO: exec = "allow"
[[paths]]
path = "/opt/homebrew/lib"
read = "allow"
# TODO: exec = "allow"

# Allow read and execute access to everything in the Nix store.
[[paths]]
path = "/nix/store"
read = "allow"
# TODO: exec = "allow"

# Disable sandboxing of the `foo` package altogether.
[build-scripts.foo]
default = "allow"

# Allow read, write, execute, delete etc. access to ~/foo.
[[paths]]
path = "/Users/username/foo"
default = "allow"
# But deny access to ~/foo/subdir.
[[paths]]
path = "/Users/username/foo/subdir"
default = "deny"

The full format of the configuration file looks as follows:

# Whether sandboxing is globally enabled.
#
# allow -> Disable sandboxing.
# warn -> Disable sandboxing and warn on accesses that would have been denied.
# deny -> Enable sandboxing.
global = "allow" | "warn" | "deny"

# The global network configuration.
[network]
all = "allow" | "warn" | "deny"
# Allow access to the local network.
# TODO: Or just localhost?
local = "allow" | "warn" | "deny"
# Allow access to the public / global network.
external = "allow" | "warn" | "deny"

# Additional paths to globally allow access to.
#
# These are parsed in order, with later entries overriding earlier ones.
[[paths]]
# TODO: Use `shellexpand` or similar on these?
# TODO: Allow `~/` and crate-relative dirs to work.
path = <path>
# Configure access to the path in general.
default = "allow" | "warn" | "deny"
# Configure read access to files in the path.
read = "allow" | "warn" | "deny"
# Configure write access to files in the path.
write = "allow" | "warn" | "deny"
# TODO: Configure execute access to files in the path.
# exec = "allow" | "warn" | "deny"


# Configure individual build-scripts.
#
# NOTE: This uses just the package name; we don't actually care about which
# version or the source of it (TODO: We do care about source), if the user has
# allowed network access for this package, they'll probably want to keep
# allowing that even across versions.
[build-scripts.<package-name>]
default = "allow" | "warn" | "deny"

[build-scripts.<package-name>.network]
# ... overrides [network] above ...

[[build-scripts.<package-name>.paths]]
# ... extends [[paths]] above ...


# Configure individual proc-macros.
#
# This is a bit weird, because the sandbox doesn't actually apply to the crate,
# **it applies to all dependent crates** of the crate.
#
# But maybe we can change this in the future? Maybe compile `proc-macros`
# differently, or have a shim helper that is sandboxed instead, and which
# dlopens the proc macro (instead of the compiler doing so), and then copies
# data to/from it? Something similar to `rust-analyzer`'s `proc-macro-srv`.
#
# That would probably be an okay trade-off between raw speed when unsandboxed,
# and still reasonable performance when sandboxed. And we wouldn't need a
# recompile / massively change how proc-macros work (at least on the surface).
[proc-macros.<package-name>]
default = "allow" | "warn" | "deny"

[proc-macros.<package-name>.network]
# ... overrides [network] above ...

[[proc-macros.<package-name>.paths]]
# ... extends [[paths]] above ...

Project-local configs (e.g. $PROJECT_DIR/.cargo/cargo-sandbox.toml) are not supported, as that'd allow recently cloned projects to just configure away the sandbox. We might allow this in the future if RFC 3279 progresses further.

How it works

cargo-sandbox forwards its arguments to a new cargo subprocess, but with the DYLD_INSERT_LIBRARIES (macOS) or LD_PRELOAD (other unixes) environment variable set to the cargo-sandbox-interceptor dylib, which causes it to be injected into the cargo subprocess.

The interceptor then loads the configuration file, and intercepts all further process spawning that the cargo subprocess does (such as std::process::Command::spawn or fork + execve).

When the interceptor detects that the cargo subprocess is invoking rustc or a build script, the interceptor wraps this invocation in the system's sandboxing mechanism, according to the configuration loaded.

TODO: How can we allow the interceptor to communicate back to the main cargo-sandbox process (e.g. for tracing / debugging)? Maybe use proc_pidinfo to find the parent process' PID (the PID of cargo-sandbox), and match that against some socket-like thing? socketpair?

TODO

Trust roots; we'd probably trust packages from crates.io, but probably not git sources, or at least not by default.

[hints] keys to allow library authors to request certain sandboxing opt-outs?

  • E.g. hints.sandbox.build-script.allow-network = "message" or hints.sandbox.proc-macro.allow-network = "message".

Deterministic builds:

  • Disallow Xcode?

How do we ensure that this stays secure?

  • Feature additions to Cargo must have a # Security section.
  • Help ensure process spawning and file system access is wrapped with clippy.toml deny methods.

Add an option to run as a different less-privileged user / w. ACLs?

Networking:

  • Socket vs. TCP/UDP, local vs. external.

TODO: Learn Scheme

TODO: How does sandboxing work when dlopening stuff?

What's the difference between sandboxing and entitlements?

Alternative:

  • Create app with specific entitlements that launches the process we want to sandbox?
  • Or maybe enable sandboxing com.apple.security.app-sandbox with entitlements for build scripts in Cargo.
    • Does the build script need to be bundled for that to work?

TODO: What about access to CPU hardware such as the AMX, the GPU, the ANE etc.? Is this restricted?

NOTE: Writing sandbox profile to disk first should result in a small speed-up if we can avoid re-writing to disk every time, since the sandbox_init function can then internally cache the compiled profile.

An alternative would be to just sandbox the entire cargo process itself (with a given configuration). This is a fair bit simpler, but Cargo would still have to:

  1. Read cfg tomls.
  2. Have access to keychain/~/.cargo/config.toml (for cargo publish token)

It also doesn't help much, in larger projects (which is where sandboxing becomes really important) you'd definitely want each build script's sandbox to be configurable.

Limitations

With the current design, we cannot protect against a build script doing:

println!("cargo::rustc-env=DYLD_INSERT_LIBRARIES=");

And then later invoking a malicious proc-macro with such a rustc configuration (Cargo gives build scripts higher env var precedence than the user).

Similarly broken Cargo features:

  • cargo::rerun-if-changed= - the path is not sandboxed.
  • cargo::rerun-if-env-changed= - the env var is not sandboxed.
  • cargo::rustc-link-arg* - the env var is not sandboxed.
  • cargo::rerun-if-env-changed= - the env var is not sandboxed.
  • cargo::rerun-if-env-changed= - the env var is not sandboxed.

CORRECTION: Actually, we only need to overwrite DYLD_INSERT_LIBRARIES in the Cargo process, so this is actually fine.

Alternative designs

TODO.

Resources

About

Fearlessly build arbitrary untrusted Cargo projects

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Languages