123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427 | package sandbox
import (
"errors"
"fmt"
"io/fs"
"net"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"syscall"
"blinker/internal/policy"
)
// Linux backend: bubblewrap builds the sandbox's view of the filesystem
// out of bind mounts in fresh namespaces. Nothing is visible unless
// mounted, so reads are denied by default. A path that must be hidden
// inside a visible tree is shadowed: an empty read-only tmpfs over a
// directory, /dev/null over a file.
//
// Commands see the scratch dir as /tmp, and nothing of the real one. In a
// project inside /tmp (not /tmp itself), though, the real /tmp is the project's surroundings,
// shown as the policy says like any other dir's, so commands see the
// scratch dir where it is instead.
//
// The network namespace is always new, so the sandbox has no network at
// all. A mode that may reach the network gets a bridge: blinker itself runs
// inside the sandbox ("blinker __bridge"), listens on 127.0.0.1:bridgePort,
// and forwards each connection to the proxy's Unix socket, which is the
// only way out. Programs that ignore HTTP(S)_PROXY get no network.
const (
// sandboxProxyDir is where the proxy's socket directory appears inside
// the sandbox. It is under /tmp because that is the one place bubblewrap
// can always make its mount point: /tmp is the session's scratch dir,
// where anywhere else may be bound from the host (a rule for "/", say),
// and so not writable. In a project under /tmp it appears where it is,
// under the scratch root, which the floor hides behind an empty tmpfs.
sandboxProxyDir = "/tmp/.blinker-proxy"
// bridgePort is the proxy port inside the sandbox; the network
// namespace is private, so it cannot collide with the host's.
bridgePort = 3128
)
// linuxPlatform runs commands under bubblewrap.
type linuxPlatform struct {
bwrap string // the bwrap executable
exe string // blinker's own executable, for the bridge
}
func newLinuxPlatform(exe string) (platform, error) {
bwrap, err := exec.LookPath("bwrap")
if err != nil {
return nil, errors.New("bubblewrap (bwrap) is not installed or not on $PATH; install it (e.g. `apt install bubblewrap`)")
}
return &linuxPlatform{bwrap: bwrap, exe: exe}, nil
}
func (l *linuxPlatform) Name() string { return "Linux bubblewrap" }
// ScratchRoot is a dir of the user's own in /tmp, not /tmp itself, so that
// the floor can hide every session's dir from a project under /tmp. Anyone
// can make it first, so it must be a real directory, the user's, and
// closed to everyone else.
func (l *linuxPlatform) ScratchRoot() (string, error) {
root := fmt.Sprintf("/tmp/blinker-%d", os.Getuid())
if err := os.Mkdir(root, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {
return "", err
}
fi, err := os.Lstat(root)
if err != nil {
return "", err
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !fi.IsDir() || !ok || int(st.Uid) != os.Getuid() || fi.Mode().Perm()&0o077 != 0 {
return "", fmt.Errorf("%s is not a directory that only you can use", root)
}
return root, nil
}
// SystemReadPaths: bwrap gives each sandbox its own /dev, with only null,
// zero, full, random, urandom and tty, so /dev is not listed. Of /sys only
// the CPU list is shown, which glibc reads to count processors.
func (l *linuxPlatform) SystemReadPaths() []string {
return []string{"/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32",
"/etc", "/opt", "/var/lib", "/nix/store", "/run/current-system",
"/sys/devices/system/cpu"}
}
func (l *linuxPlatform) BaselineWritePaths() []string { return nil }
func (l *linuxPlatform) SystemSecrets() []string {
return []string{
"/etc/shadow", "/etc/shadow-", "/etc/gshadow", "/etc/gshadow-",
"/etc/security/opasswd",
"/etc/ssh/ssh_host_rsa_key", "/etc/ssh/ssh_host_ecdsa_key",
"/etc/ssh/ssh_host_ed25519_key", "/etc/ssh/ssh_host_dsa_key",
"/etc/ssl/private", "/etc/pki/tls/private", "/etc/letsencrypt",
"/etc/krb5.keytab", "/etc/ipsec.secrets", "/etc/ipsec.d/private",
"/etc/wireguard", "/etc/NetworkManager/system-connections",
"/etc/wpa_supplicant", "/var/lib/iwd", "/var/lib/bluetooth",
"/var/lib/sss", "/etc/credstore", "/etc/credstore.encrypted",
"/var/lib/systemd/credential.secret",
}
}
func (l *linuxPlatform) UserHomes() ([]string, error) { return userHomes(passwdFile, "/home") }
func (l *linuxPlatform) SystemTempDirs() []string { return []string{"/tmp", "/var/tmp"} }
// TempDir: the scratch dir is mounted over /tmp, and nowhere else, unless
// the project is inside /tmp.
func (l *linuxPlatform) TempDir(scratch, project string) string {
if inTmp(project) {
return scratch
}
return "/tmp"
}
func (l *linuxPlatform) PrepareScratch(scratch string) error { return nil }
// ListenProxy listens on a Unix socket in dir, which the sandbox mounts;
// the bridge inside forwards to it.
func (l *linuxPlatform) ListenProxy(dir string) (net.Listener, error) {
return net.Listen("unix", filepath.Join(dir, proxySocketName))
}
// hostWritable reports whether blinker can make files in dir on the host.
// Where it cannot (a read-only file system, or a directory of root's), a
// command cannot either: it runs as the same user, with no capabilities.
// So a missing path there that the policy protects needs no blocking,
// which bubblewrap could not do anyway: it would have to make the mount
// point.
func hostWritable(dir string) bool {
const wOK = 2 // W_OK, for access(2)
return syscall.Access(dir, wOK) == nil
}
func (l *linuxPlatform) Wrap(w wrap) (argv, env, made []string, err error) {
opts := bwrapOpts{exe: l.exe, empty: w.Empty, terminal: w.Terminal != "", preview: w.Preview, helper: w.Helper != ""}
if w.Helper != "" {
w.Argv = []string{l.exe, w.Helper}
}
if w.Create != "" {
// Creating a file needs its directory writable, and a mount cannot
// make a directory writable for one name only. The command is
// WriteFile's fixed script, though, with no code from the model,
// so the nearest existing directory is writable for it.
opts.createIn = filepath.Dir(w.Create)
}
if w.Proxy != nil {
addr, ok := w.Proxy.(*net.UnixAddr)
if !ok {
return nil, nil, nil, fmt.Errorf("the proxy is not on a Unix socket: %s", w.Proxy)
}
opts.proxyDir = filepath.Dir(addr.Name)
opts.proxyMount = sandboxProxyDir
if inTmp(w.Policy.Env.Project) {
opts.proxyMount = opts.proxyDir
}
env = append(env, proxyEnv(bridgePort)...)
}
args, made, err := bwrapArgs(w.Policy, opts)
if err != nil {
return nil, nil, made, err
}
argv = append([]string{l.bwrap}, args...)
argv = append(argv, "--")
if opts.proxyDir != "" {
argv = append(argv, opts.exe, BridgeCommand,
filepath.Join(opts.proxyMount, proxySocketName), strconv.Itoa(bridgePort), "--")
}
return append(argv, w.Argv...), env, made, nil
}
// inTmp reports whether a project is inside /tmp, where commands see the
// real one. /tmp itself is hidden (see Sandbox.Hidden).
func inTmp(project string) bool {
return project != "/tmp" && policy.Under(project, "/tmp")
}
type bwrapOpts struct {
exe string // blinker's own executable, for the bridge
proxyDir string // the host directory holding the proxy socket; "" for no network
proxyMount string // where proxyDir appears in the sandbox
createIn string // a directory made writable for WriteFile; see Cmd.create
empty string // an empty read-only file (see wrap.Empty)
terminal bool // the command has a pty of its own (see Cmd.Terminal)
preview *Preview // set to keep the command's writes off the host (see Cmd.Preview)
helper bool // the command is blinker's own (see Cmd.helper)
}
// bwrapArgs builds bubblewrap's arguments for a mode, up to the command,
// and lists the mount points bubblewrap will make on the host.
func bwrapArgs(c *policy.Compiled, opts bwrapOpts) (args, made []string, err error) {
env := c.Env
tmp := (&linuxPlatform{}).TempDir(env.Scratch, env.Project) // where commands see the scratch dir
args = []string{"--die-with-parent"}
if !opts.terminal {
// No controlling terminal: no TIOCSTI keystroke injection. A
// command with a pty of its own keeps that as its terminal.
args = append(args, "--new-session")
}
args = append(args,
"--unshare-all", // including the network
"--cap-drop", "ALL",
"--proc", "/proc",
"--dev", "/dev",
)
// visible maps each mount point to what it shows; a path shows what its
// deepest mounted ancestor does. Empty tmpfs mounts show nothing.
visible := map[string]state{}
var readOnly []string // tmpfs mounts to make read-only at the end
homeUnderSystem := slices.ContainsFunc(env.SystemRead, func(p string) bool { return policy.Under(env.Home, p) })
if !homeUnderSystem {
args = append(args, "--tmpfs", env.Home)
visible[env.Home] = none
readOnly = append(readOnly, env.Home)
}
// at records a mount point, which a preview's reader skips: bubblewrap
// makes those missing in a writable directory, not the command.
at := func(p string) string {
if opts.preview != nil {
opts.preview.mounts[p] = true
}
return p
}
// bind mounts a writable directory: in a preview, an overlay, whose
// writes are kept for the reader where they can touch the project, and
// dropped elsewhere (caches, the temp dir, say).
bind := func(src, dst string) error {
if opts.preview == nil {
args = append(args, "--bind-try", src, at(dst))
return nil
}
fi, err := os.Stat(src)
if errors.Is(err, fs.ErrNotExist) {
return nil // as --bind-try would
}
if err != nil {
return err
}
if !fi.IsDir() {
// A file writable on its own, which an overlay cannot be: the
// command's writes to it fail.
args = append(args, "--ro-bind", src, at(dst))
return nil
}
if src != dst || !policy.Under(src, env.Project) && !policy.Under(env.Project, src) {
args = append(args, "--overlay-src", src, "--tmp-overlay", at(dst))
return nil
}
upper, work, err := opts.preview.layer(src)
if err != nil {
return err
}
args = append(args, "--overlay-src", src, "--overlay", upper, work, at(dst))
return nil
}
for _, p := range env.SystemRead {
args = append(args, "--ro-bind-try", p, p)
visible[p] = ro
}
shows := func(p string) state {
for cur := p; ; cur = filepath.Dir(cur) {
if v, ok := visible[cur]; ok {
return v
}
if cur == "/" {
return none
}
}
}
mounts, err := plan(c)
if err != nil {
return nil, nil, err
}
if opts.createIn != "" {
// In order with the policy's mounts, so those under it still apply.
i := slices.IndexFunc(mounts, func(m mount) bool { return m.path == opts.createIn })
if i < 0 {
mounts = append(mounts, mount{path: opts.createIn})
i = len(mounts) - 1
}
mounts[i] = mount{path: opts.createIn, state: rw, exists: true, dir: true}
}
for _, m := range guard(c, mounts) {
if policy.Under(m.path, tmp) || policy.Under(m.path, env.Scratch) {
// The scratch dir is open to everything. Seen as /tmp, the real
// /tmp and the scratch dir's host path are not mounted at all.
continue
}
writableDir := shows(filepath.Dir(m.path)) == rw
switch {
case m.block:
if !writableDir || !hostWritable(filepath.Dir(m.path)) {
continue // nothing can create it anyway
}
if m.dir {
// An empty directory, read-only at the end, so a program
// that lists it finds nothing rather than a file.
args = append(args, "--tmpfs", at(m.path))
readOnly = append(readOnly, m.path)
visible[m.path] = none
made = append(made, m.path)
continue
}
// A read-only empty file, made on the host (see guard). Not
// /dev/null: binds are nodev, so opening that fails, which
// programs reading an optional file (git, ~/.gitconfig) take
// worse than an empty one.
if opts.empty == "" {
return nil, made, fmt.Errorf("no empty file to protect %s with", m.path)
}
args = append(args, "--ro-bind", opts.empty, at(m.path))
visible[m.path] = none
made = append(made, m.path)
continue
case m.pin && !m.exists:
if !writableDir || m.state != rw {
continue
}
if !hostWritable(filepath.Dir(m.path)) {
// Nothing can make the directory, so nothing under it can
// appear, and the blocks there are not needed.
visible[m.path] = ro
continue
}
// A directory above a blocked path, made on the host and pinned
// (see guard). bubblewrap checks every source before it makes
// anything, so blinker makes it, where the mode could anyway.
if err := os.Mkdir(m.path, 0o755); err != nil {
return nil, made, fmt.Errorf("protecting %s: %w", m.path, err)
}
made = append(made, m.path)
if err := bind(m.path, m.path); err != nil {
return nil, made, err
}
visible[m.path] = rw
continue
case m.state == shows(m.path) && !(m.pin && writableDir && m.exists && m.dir):
continue // the enclosing mount already gives this access
case !m.exists:
// Absent from the host, so absent here too (guard blocks it if
// it must stay so). A bind would do nothing, or fail where the
// path is under a file: bubblewrap's -try forgives only ENOENT.
continue
}
// The mount buries those under it, which then show nothing of
// their own.
for p := range visible {
if p != m.path && policy.Under(p, m.path) {
delete(visible, p)
}
}
readOnly = slices.DeleteFunc(readOnly, func(p string) bool { return policy.Under(p, m.path) })
switch m.state {
case ro:
args = append(args, "--ro-bind-try", m.path, at(m.path))
case rw:
if err := bind(m.path, m.path); err != nil {
return nil, made, err
}
case none:
if m.dir {
args = append(args, "--tmpfs", at(m.path))
readOnly = append(readOnly, m.path)
} else {
args = append(args, "--ro-bind", "/dev/null", at(m.path))
}
}
visible[m.path] = m.state
}
// The links the rules followed, where the mounts hide them (home's
// tmpfs, say): made again in the sandbox, after the mounts so none
// buries them, and before the tmpfs mounts are made read-only. Not
// where a mount already is (the system's /bin, a link to usr/bin):
// bubblewrap made a directory there for it.
mounted := func(p string) bool {
for v := range visible {
if policy.Under(v, p) {
return true
}
}
return false
}
for _, l := range shownLinks(c) {
if policy.Under(l, tmp) || policy.Under(l, env.Scratch) || shows(filepath.Dir(l)) != none || mounted(l) {
continue
}
target, err := os.Readlink(l)
if err != nil {
continue // gone: nothing to show
}
args = append(args, "--symlink", target, at(l))
}
// After the policy's mounts, so none of them (a rule for "/", say) can
// cover it with the real /tmp, or hide it in the scratch root.
if err := bind(env.Scratch, tmp); err != nil {
return nil, made, err
}
// After the scratch dir, which may hold them.
if opts.proxyDir != "" || opts.helper {
args = append(args, "--ro-bind", opts.exe, at(opts.exe)) // for a hidden subcommand (see RunHidden)
}
if opts.proxyDir != "" {
args = append(args, "--ro-bind", opts.proxyDir, at(opts.proxyMount))
}
for _, p := range readOnly {
if visible[p] == none {
args = append(args, "--remount-ro", p)
}
}
dir := env.Project
if !c.Access(env.Project).Read {
dir = tmp
}
args = append(args, "--chdir", dir)
return args, made, nil
}
|