Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hardening

The default sandbox is a rootless convenience for code you trust: a controlled filesystem view, a clean environment, and isolated namespaces. Confining untrusted code is this layer’s work. It adds three opt-in restrictions to the command — Landlock filesystem rules, seccomp syscall filters, and capability drops — plus a fully denied network. The default profile alone is not a boundary against hostile code.

The layer is behind the hardening feature, compiled in only when a consumer enables it. Every restriction is configured on the builder, applies only to the command and the processes it starts, and is enforced after the root pivot and immediately before the command executes.

More precisely, it is enforced after the command enters its nested user namespace, and that ordering is a requirement rather than a detail of the implementation. Entering a user namespace has the kernel re-derive the whole credential: the securebits return to their defaults, the capability bounding set is refilled, and the inheritable and ambient sets are cleared. Anything on this page applied before the entry would be undone without a word. What the entry itself establishes — the locks on the sandbox’s mount flags — needs none of this layer, and holds in every build.

Landlock filesystem rules

landlock_fs grants a set of accesses beneath a path. Configuring any grant enrolls the sandbox in Landlock: from then on the command reaches a path only where a grant allows it, and every other path is denied.

use ferroday_cage::{Cage, FsAccess};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/usr/bin/build")
    .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
    .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
    .landlock_fs(FsAccess::READ | FsAccess::WRITE, "/work")
    .build()?
    .run()?;
let _ = status;
Ok(())
}

FsAccess composes three primitives with |: READ (read files and list directories), WRITE (write files and create, remove, and rename entries), and EXECUTE (execute files). Paths are sandbox paths, resolved after the pivot, so a grant describes the mounted view — the rootfs, the bind mounts, and the /proc, /dev, and /tmp mounts alike. A read-write grant on the tmpfs /tmp governs exactly the files the sandbox mounts there.

The rules are enforced on any kernel that offers Landlock, best-effort down to the kernel’s supported ABI: a rule referencing an access right a kernel does not define is narrowed to what that kernel enforces, never widened. A kernel with no Landlock support at all is a setup error, not a silent success — a requested restriction never fails to apply unnoticed. host::landlock_abi reports the running kernel’s supported ABI version, or None when Landlock is absent.

Landlock network rules

landlock_net grants a set of accesses on a TCP port. The kernel keys network rights by port rather than by path, so these are the network counterpart of the filesystem rules: configuring any grant — filesystem or network — enrolls the sandbox in Landlock, and a network grant then governs both binding and connecting.

use ferroday_cage::{Cage, FsAccess, NetAccess};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/usr/bin/proxy")
    .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
    .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
    .landlock_net(NetAccess::BIND, 8080)
    .landlock_net(NetAccess::CONNECT, 443)
    .build()?
    .run()?;
let _ = status;
Ok(())
}

NetAccess composes two primitives with |: BIND (bind a TCP socket to the port) and CONNECT (connect a TCP socket to the port). The example lets the command listen on 8080 and reach HTTPS services on 443, and denies every other bind and connect — enrolling a network grant denies all network access not granted, exactly as a filesystem grant denies all paths not granted. A BIND grant on port 0 permits binding to a kernel-assigned ephemeral port.

Network rights arrive in Landlock ABI 4. On an older kernel that still offers Landlock they are narrowed away best-effort, the same downgrade an unsupported filesystem right receives; a network-only request on such a kernel becomes a no-op rather than an error, and no ruleset is built at all. Check host::landlock_abi for a version of 4 or more when a command must not run unless its network restriction can be enforced.

The one place that downgrade is refused rather than taken is where the grant is the sandbox’s only network boundary: a host-network cage, and the restriction fallback. Both fail the build on a kernel that cannot enforce the grant. An isolated cage keeps its network namespace either way, which is why the same kernel is tolerated there.

Seccomp syscall filters

seccomp applies a syscall filter to the command. Three policies are available through SeccompPolicy:

  • Curated — a built-in deny-list of dangerous and rarely-legitimate syscalls (mounting, kernel modules, the keyring, tracing, time-setting, and similar), answering each with EPERM and allowing the rest. Two entries name an argument rather than a whole syscall: ioctl is denied for the TIOCSTI and TIOCLINUX requests, which write to a terminal’s input queue, and allowed for everything else. A convenience posture, not a boundary claim; its roster grows as new syscalls warrant denial.
  • Rules — a caller-authored SeccompRules allow-list or deny-list. An allow-list names the syscalls the command may make and denies the rest, so it must include every syscall the command needs. A deny-list names the syscalls to refuse and allows the rest. Syscalls are named by their kernel number for the host architecture.
  • Program — a pre-compiled BPF program (SockFilter instructions), installed verbatim, for a policy produced by an external compiler.
use ferroday_cage::{Cage, SeccompPolicy};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/usr/bin/convert")
    .seccomp(SeccompPolicy::Curated)
    .build()?
    .run()?;
let _ = status;
Ok(())
}

Argument conditions

A listed syscall may carry conditions on its arguments through SeccompRules::rule, so the listed action applies only to invocations whose arguments match — an allow-list that permits ioctl only for a specific request, a deny-list that refuses socket only for AF_INET. Each condition is a SeccompArg: an argument index (0 through 5), a width, a comparison, and a value. Repeating a syscall accepts any of several condition sets; a syscall listed both plainly and with conditions matches unconditionally.

use ferroday_cage::{Cage, SeccompArg, SeccompPolicy, SeccompRules};
const TIOCGWINSZ: u64 = 0x5413;
fn main() -> ferroday_cage::Result<()> {
// Allow read, write, and ioctl, but ioctl only for the TIOCGWINSZ request.
let rules = SeccompRules::allowing([libc::SYS_read, libc::SYS_write])
    .rule(libc::SYS_ioctl, [SeccompArg::eq_dword(1, TIOCGWINSZ)]);
let status = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/usr/bin/convert")
    .seccomp(SeccompPolicy::Rules(rules))
    .build()?
    .run()?;
let _ = status;
Ok(())
}

The width is a deliberate choice. The kernel presents every argument as a 64-bit value, and the _dword constructors compare only its low 32 bits while the _qword constructors compare all 64. Match the width to the argument’s kernel type: dword for an int or a 32-bit flag set, qword for a pointer or a 64-bit value. Comparing the wrong width lets a value hide in the bits that are not checked. The masked_eq_* constructors test individual flag bits — for example, that CLONE_NEWUSER is clear in a clone flags argument.

The filter binds the command and its descendants. Seccomp filtering is available on the x86_64, aarch64, and riscv64 architectures; a policy on any other is a build error. A rootfs run for a foreign architecture through qemu-user executes host-architecture syscalls, so a native filter does not describe the guest’s behavior — do not combine the two. host::seccomp_available reports whether the kernel offers seccomp at all.

Capability drops

In a user namespace the command is mapped to root and holds every capability within that namespace. None of them confers authority over the host, but they do grant power inside the sandbox. drop_all_capabilities sheds them all; keep_capabilities retains only a named few:

use ferroday_cage::{Cage, Capability};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/usr/sbin/service")
    .keep_capabilities([Capability::NetBindService])
    .build()?
    .run()?;
let _ = status;
Ok(())
}

The drop narrows the bounding, ambient, permitted, effective, and inheritable sets, so a dropped capability cannot be regained across the command’s execve.

A capability posture composes with a non-root run-as identity. Without a keep request the identity switch itself clears the command’s capabilities, which is usually the point; with one, the launch locks the securebits that carry the kept set across the switch, so the command runs as its non-root identity holding exactly the kept capabilities. Keeping CAP_SETUID, CAP_SETGID, or CAP_SETPCAP alongside a non-root identity is rejected at build time — it would let the command return to the mapped uid 0.

Denied network

Beyond the default isolated network — a namespace whose loopback is brought up, so the sandbox can talk to itself — Network::None leaves loopback down. The command has no connectivity at all, not even to 127.0.0.1.

use ferroday_cage::{Cage, Network};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/usr/bin/analyze")
    .network(Network::None)
    .build()?
    .run()?;
let _ = status;
Ok(())
}

Resource limits

The restrictions above bound what the command can reach. They say nothing about what it can consume, and the two are independent: a command confined to a private root, denied the network, filtered by seccomp, and stripped of every capability can still fork without end, allocate without end, and fill the filesystem it was given.

[rlimit] sets the kernel’s own per-process limits on the command, and every process it starts inherits them:

use ferroday_cage::{Cage, Limit, Resource, SeccompPolicy};
fn main() -> ferroday_cage::Result<()> {
let status = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/usr/bin/analyze")
    .seccomp(SeccompPolicy::Curated)
    .drop_all_capabilities()
    // 64 processes, 512 MiB of address space, 64 MiB of file, 10 CPU seconds.
    .rlimit(Resource::Processes, 64, 64)
    .rlimit(Resource::AddressSpace, 512 << 20, 512 << 20)
    .rlimit(Resource::FileSize, 64 << 20, 64 << 20)
    .rlimit(Resource::CpuTime, 10, Limit::UNLIMITED)
    .build()?
    .run()?;
let _ = status;
Ok(())
}

Each call names a [Resource] and its soft and hard values in that resource’s own unit; Limit::UNLIMITED is the kernel’s RLIM_INFINITY, and a soft limit above its hard limit is rejected at build time. Repeating a resource replaces the earlier setting. The limits are applied to the command process before the hardening layer, so a seccomp filter cannot deny the call that sets them.

The resources, with the kernel limit each sets and the unit it counts in. The name in the first column is what a profile’s [rlimit] table and the --rlimit flag both spell:

NameKernel limitUnit
address-spaceRLIMIT_ASbytes of mapped address space
core-dumpRLIMIT_COREbytes
cpu-timeRLIMIT_CPUseconds of CPU time
dataRLIMIT_DATAbytes of heap and anonymous mappings
file-sizeRLIMIT_FSIZEbytes, the largest file created
locked-memoryRLIMIT_MEMLOCKbytes lockable into RAM
open-filesRLIMIT_NOFILEone past the highest descriptor
pending-signalsRLIMIT_SIGPENDINGqueued signals
processesRLIMIT_NPROCprocesses and threads
stackRLIMIT_STACKbytes of main-thread stack

Two carry kernel semantics worth knowing before relying on them:

  • Processes is RLIMIT_NPROC, which the kernel counts per real user id across the whole system, not per namespace. Under the default single-identity map the command’s real uid outside the sandbox is the calling user’s, so the cap is a ceiling on the caller and the sandbox together rather than on the sandbox alone. A range identity map gives the sandbox distinct host ids, and the count becomes its own.
  • AddressSpace counts mapped address space, not resident memory. A runtime that maps far more than it touches — many do — hits the limit well before its real memory use approaches the value.

On the command line, --rlimit RESOURCE=SOFT[:HARD] sets one, repeatably: --rlimit processes=64 --rlimit cpu-time=10:unlimited. fcage --help lists the accepted names, and so does the message rejecting one it does not know.

A profile carries the same limits in an [rlimit] table, keyed by the resource names above. One value sets the soft and hard limits alike; a { soft, hard } table sets them apart. Either position takes an amount or "unlimited".

[rlimit]
processes = 64
address-space = 536870912
file-size = 67108864
cpu-time = { soft = 10, hard = "unlimited" }

What this boundary does not cover

Two things a reader configuring the strongest posture on this page might reasonably assume are covered, and are not:

  • Resource governance beyond the per-process axis. The limits above are setrlimit values, which is all an unprivileged process can set on itself. CPU shares, memory ceilings that count resident rather than mapped pages, I/O throttling, and PID caps that are genuinely per-sandbox all live in cgroup controllers, and disk consumption lives in filesystem quotas. Both are the host’s to configure, and neither is something this library sets up. A consumer that needs them places the calling process in a cgroup of its own before launching, and the sandbox inherits it.
  • The rootfs itself. The command is root inside the sandbox and the rootfs is mounted read-write, so it can modify anything in the provisioned tree that the calling user could modify on the host. A Landlock grant narrows this; so does overlay_rootfs, which leaves the base untouched and sends every write to a disposable upper. The root is mounted nosuid, so what the command writes into the tree cannot be executed back with elevated ids — but the tree’s contents are still the command’s to change.

Neither gap is silent: what the layer does enforce, it enforces completely, and what it does not, it does not claim.

In a profile

With the serde feature, every restriction on this page is expressible in a profile: a [hardening] table carries the capability posture, the seccomp policy, and the Landlock filesystem and network grants, so a sandbox’s security configuration is a reviewable, versionable artifact rather than a sequence of builder calls.

[hardening]
keep-caps = ["net-bind-service"]
seccomp = "curated"

[[hardening.landlock-fs]]
access = "rx"
path = "/usr"

[[hardening.landlock-net]]
access = "c"
port = 443

The profiles chapter documents the table in full. A library built without the hardening feature refuses a profile that configures it, rather than dropping the restrictions silently.

Order and interaction

The restrictions are applied to the command in a fixed order, wrapped around the identity switch: the securebits are locked (when a non-root identity keeps capabilities), no-new-privileges is set, and Landlock is enforced; then the identity switch is performed; then capabilities are dropped and the seccomp filter is installed last, so the active filter governs only the command’s execve and what follows. No-new-privileges is set a second time by the switch itself when a run-as identity is configured, so that a build without this feature — and a sandbox that asks for a non-root identity and no hardening at all — still gets it. The ordering is deliberate. Landlock and its grant-path opens run while the process still holds its in-namespace root credentials, before the switch and the capability drop take them away, so the grants are established successfully; and the seccomp filter, installed after the switch, never governs the switch’s own syscalls. Each restriction is inherited across execve, and none touches the sandbox init or the supervisor.

On the command line, --landlock-ro and --landlock-rw add filesystem grants, --landlock-bind and --landlock-connect add network grants by port, --seccomp curated applies the curated profile, --seccomp-allow and --seccomp-deny filter syscalls by name, --seccomp-allow-rule and --seccomp-deny-rule add argument conditions in the profile’s arg/len/op/ value terms (for example --seccomp-allow-rule 'ioctl arg=1 len=dword op=eq value=0x5413'), --drop-caps and --keep-caps control capabilities, and --deny-net selects the denied network.

The Landlock and seccomp primitives also stand on their own: on hosts without unprivileged user namespaces, the restriction fallback applies them to a plain spawned command, with no sandbox around it.