Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick 906a146503 Sandbox: test the socket owner ioctls with a real pointer
The merged test asserts FIOSETOWN and FIOASYNC with the ioctl argument passed
by value, where the kernel reads the number as an address and fails with
EFAULT whether or not the filter refuses it, so the refusal alone is not the
reason the call came back. Passing a pointer, as a C caller does, the call
succeeds unconfined and is EPERM confined.

This covers FIOSETOWN and SIOCSPGRP that way on a socket the parent made and
the program inherited as a standard stream, after reading its socket name so a
pipe cannot pass for one. SIOCSPGRP is in the rules and in the filter's count
assertion but in no test until now.

Deleting either rule line fails this test at that ioctl's assertion; restored,
it passes. The test above is untouched.
2026-11-15 04:26:05 +01:00
skylar taylor-barrick 2f52ae5c7e Sandbox: refuse fcntl's asynchronous notification on Linux (#3846)
* Sandbox: refuse fcntl's asynchronous notification on Linux

* Sandbox: tests for fcntl's notification rules and the new probe

* Sandbox: the socket ioctls that notify another process, too
2026-11-14 21:12:49 -06:00
Dylan M. Taylor a62a532753 Merge pull request #4242 from storytold/ci/sonar-pinned-fonts
ci: provision pinned fonts for coverage tests
2026-11-14 20:53:30 -05:00
Dylan M. Taylor 69219c0d5c ci: provision pinned fonts for coverage tests 2026-11-14 20:38:23 -05:00
7 changed files with 200 additions and 23 deletions
+3
View File
@@ -18,4 +18,7 @@ jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with:
# Match the main CI gate so coverage exercises every glyph-sensitive UI case.
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
secrets: inherit
Generated
+18 -18
View File
@@ -2126,7 +2126,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"arboard",
"chrono",
@@ -2154,7 +2154,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2166,7 +2166,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -2177,7 +2177,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -2190,7 +2190,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"chrono",
"js-sys",
@@ -2201,7 +2201,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"craft-csv",
"gridcraft-calc",
@@ -2225,7 +2225,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"serde",
@@ -2234,7 +2234,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"regex",
@@ -2242,7 +2242,7 @@ dependencies = [
[[package]]
name = "gridcraft-math"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"ratex-font",
"ratex-layout",
@@ -2252,7 +2252,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-engine",
"log",
@@ -2261,7 +2261,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2274,18 +2274,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.20.0"
version = "0.19.1"
[[package]]
name = "gridcraft-sandbox"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"landlock",
"libc",
@@ -2296,7 +2296,7 @@ dependencies = [
[[package]]
name = "gridcraft-ui-egui"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -2323,7 +2323,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -2342,7 +2342,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"craft-cfb",
"craft-csv",
@@ -6836,7 +6836,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.20.0"
version = "0.19.1"
dependencies = [
"gridcraft-engine",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.20.0"
version = "0.19.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+107 -1
View File
@@ -78,6 +78,8 @@ fn the_self_test_finds_every_denial() {
] {
assert!(out.contains(line), "missing `{line}` in:\n{out}");
}
#[cfg(target_os = "linux")]
assert!(out.contains("denied: switch a descriptor's notifications on"), "missing the notification switch in:\n{out}");
std::fs::remove_dir_all(home).unwrap();
}
@@ -181,7 +183,9 @@ fn system_python() -> Option<(String, String)> {
/// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can
/// still signal itself and its own threads. On Linux, where the filter names them, it also can't
/// change another process's limits or priority, or type into a terminal: no call here reaches a real
/// terminal, since its standard input isn't one.
/// terminal, since its standard input isn't one. Linux also refuses the `fcntl` commands that would
/// have the kernel signal a process for it: naming the owner, choosing the signal, or switching the
/// notification on.
#[test]
fn a_confined_program_leaves_other_processes_alone() {
if !confines() {
@@ -226,6 +230,13 @@ if sys.platform == "linux":
assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority"
assert refused(os.sched_setaffinity, other, {0}), "another process's processors"
assert refused(os.pidfd_open, other), "a handle on another process"
read, write = os.pipe()
assert refused(fcntl.fcntl, write, fcntl.F_SETOWN, other), "giving a descriptor's notifications to another process"
# 15 is F_SETOWN_EX and 10 is F_SETSIG, which Python's fcntl doesn't name; the filter refuses
# them whatever owner or signal they name, so the argument doesn't matter.
assert refused(fcntl.fcntl, write, 15), "naming a descriptor's owner the extended way"
assert refused(fcntl.fcntl, write, 10), "choosing a descriptor's notification signal"
assert refused(fcntl.fcntl, write, fcntl.F_SETFL, fcntl.fcntl(write, fcntl.F_GETFL) | os.O_ASYNC), "switching a descriptor's notifications on"
resource.prlimit(0, resource.RLIMIT_NOFILE)
resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE)
os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0))
@@ -240,6 +251,101 @@ print("left alone")
assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o));
}
/// A socket the parent made, which the program inherited as a standard stream, can't be pointed at
/// another process through the ioctls that do what `fcntl`'s notification commands do: `FIOSETOWN`
/// (0x8901) and `FIOASYNC` (0x5452), which Python's `fcntl` passes straight through. Linux only:
/// macOS's profile has no rule for them, and its standard streams here are pipes.
#[cfg(target_os = "linux")]
#[test]
fn a_confined_program_cant_point_an_inherited_socket_at_another_process() {
if !confines() {
return;
}
let Some((python, prefix)) = system_python() else {
return;
};
let code = r#"
import fcntl, sys
other = int(sys.argv[1])
def refused(call, *args):
try:
call(*args)
except PermissionError:
return True
except OSError as e:
print("other error", e)
return False
assert refused(fcntl.ioctl, 0, 0x8901, other), "giving a socket's notifications to another process"
assert refused(fcntl.ioctl, 0, 0x5452, 1), "switching a socket's notifications on"
print("socket quiet")
"#;
let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap();
let socket: std::os::fd::OwnedFd = mine.into();
let me = std::process::id().to_string();
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
.args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me])
.stdin(std::process::Stdio::from(socket))
.output()
.unwrap();
let out = text(&o);
assert!(o.status.success(), "{out}");
assert!(String::from_utf8_lossy(&o.stdout).contains("socket quiet"), "{out}");
}
/// The same inherited socket, by both ioctls that name the process to notify, `FIOSETOWN` (0x8901)
/// and `SIOCSPGRP` (0x8902), with the argument a real pointer. Named by value the kernel reads the
/// number as an address and fails with `EFAULT` whether or not the filter refuses it, so that form
/// shows only that a rule matched a request number; by pointer each call succeeds unconfined and is
/// `EPERM` confined, which is the transition the page claims. Kept apart from the test above so a
/// rule dropped for one request number fails for that request, not behind another. Linux only, as
/// those rules are: macOS's profile has none, and its standard streams here are pipes.
#[cfg(target_os = "linux")]
#[test]
fn a_confined_program_cant_point_an_inherited_socket_at_another_process_by_pointer() {
if !confines() {
return;
}
let Some((python, prefix)) = system_python() else {
return;
};
let code = r#"
import fcntl, socket, struct, sys
other = int(sys.argv[1])
def refused(call, *args):
try:
call(*args)
except PermissionError:
return True
except OSError as e:
print("other error", e)
return False
# Read first, so a standard stream that is not a socket can't pass for one: a pipe has no socket name.
# Detached, not dropped: a socket object closes the descriptor it wrapped when it is collected, and then
# the refusals below would be read off an invalid descriptor.
wrapped = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, fileno=0)
name = wrapped.getsockname()
wrapped.detach()
assert name == ""
# The process group is a pointer, as a C caller passes it; naming it by value has the kernel read the
# number as an address and fail with EFAULT, which would leave the refusal unexplained by the filter.
group = bytearray(struct.pack("i", other))
assert refused(fcntl.ioctl, 0, 0x8901, group), "giving a socket's notifications to another process"
assert refused(fcntl.ioctl, 0, 0x8902, group), "giving a socket's notifications to another process's group"
print("socket owner quiet")
"#;
let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap();
let socket: std::os::fd::OwnedFd = mine.into();
let me = std::process::id().to_string();
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
.args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me])
.stdin(std::process::Stdio::from(socket))
.output()
.unwrap();
let out = text(&o);
assert!(o.status.success(), "{out}");
assert!(String::from_utf8_lossy(&o.stdout).contains("socket owner quiet"), "{out}");
}
/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or
/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags.
/// Skipped when there is no `python3`.
+44 -1
View File
@@ -268,12 +268,38 @@ const TIOCLINUX: u64 = libc::TIOCLINUX as u64;
const IOC_WRITE: u64 = 0x4000_0000;
/// Changes a directory, though its number says it only reads, as the kernel's headers have it.
const FS_IOC_SET_ENCRYPTION_POLICY: u64 = 0x800C_6613;
#[allow(clippy::unnecessary_cast)]
const F_SETOWN: u64 = libc::F_SETOWN as u64;
#[allow(clippy::unnecessary_cast)]
const F_SETFL: u64 = libc::F_SETFL as u64;
#[allow(clippy::unnecessary_cast)]
const O_ASYNC: u64 = libc::O_ASYNC as u64;
/// The two `fcntl` commands of the same family that the kernel's headers name and `libc` doesn't;
/// the numbers are the same on every architecture (`asm-generic/fcntl.h`).
const F_SETOWN_EX: u64 = 15;
const F_SETSIG: u64 = 10;
/// Socket ioctls that do what those commands do: `FIOSETOWN` and `SIOCSPGRP` name the process to
/// notify, and `FIOASYNC` switches the notification on. Their requests carry no data-direction
/// bit, so the [`IOC_WRITE`] rule doesn't name them. `libc` doesn't name them; the numbers are
/// the kernel's, the same on every architecture (`asm-generic/sockios.h`).
const FIOSETOWN: u64 = 0x8901;
const SIOCSPGRP: u64 = 0x8902;
const FIOASYNC: u64 = 0x5452;
/// What the main filter refuses with `EPERM`: the calls above, and these when their arguments match.
/// - `clone` without `CLONE_THREAD`: a new process.
/// - A signal to any process but `me`, whose ID `exec` keeps. Landlock's signal scope needs a newer
/// kernel than the rest (ABI 6), and `kill(-1, 9)` must not get through on an older one.
/// - A change to the limits, priority or scheduling of any process but `me`.
/// - The `fcntl` commands that arrange asynchronous notification: naming the process to notify
/// (`F_SETOWN`, `F_SETOWN_EX`), choosing its signal (`F_SETSIG`), and `F_SETFL` when it sets
/// `O_ASYNC`, the switch without which nothing is sent. None is a signal call, and the only rule
/// that would cover them is Landlock's signal scope, which needs ABI 6, newer than a launch
/// requires; without these a confined program could have the kernel signal any process of its
/// user. `F_SETFL` itself still works, as `O_NONBLOCK` comes from it. A socket answers the same
/// arrangement through ioctls — `FIOSETOWN` and `SIOCSPGRP` to name the process, `FIOASYNC` to
/// switch the notification on — and those requests carry no data-direction bit, so they are
/// named alongside the others.
/// - `TIOCSTI` and `TIOCLINUX`, which type into a terminal the program inherited, as when
/// `gridcraft-cli sandbox-check` runs a program from a shell.
/// - An `ioctl` that hands the kernel data ([`IOC_WRITE`]), as those that change a file's flags or
@@ -307,10 +333,22 @@ fn main_rules(me: u32) -> Result<BTreeMap<i64, Vec<SeccompRule>>> {
vec![
rule(vec![word(1, SeccompCmpOp::Eq, TIOCSTI)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, TIOCLINUX)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, FIOSETOWN)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, SIOCSPGRP)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, FIOASYNC)?])?,
rule(vec![word(1, SeccompCmpOp::MaskedEq(IOC_WRITE), IOC_WRITE)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, FS_IOC_SET_ENCRYPTION_POLICY)?])?,
],
);
rules.insert(
libc::SYS_fcntl,
vec![
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN_EX)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, F_SETSIG)?])?,
rule(vec![word(1, SeccompCmpOp::Eq, F_SETFL)?, word(2, SeccompCmpOp::MaskedEq(O_ASYNC), O_ASYNC)?])?,
],
);
Ok(rules)
}
@@ -378,6 +416,9 @@ mod tests {
#[test]
fn the_calls_that_reach_another_process_have_rules() {
let rules = main_rules(4242).unwrap();
// `fcntl`'s notification commands: naming the owner to notify, both ways, choosing its
// signal, and the switch itself.
assert_eq!(rules.get(&libc::SYS_fcntl).map(Vec::len), Some(4), "the asynchronous-notification commands");
for nr in [
libc::SYS_kill,
libc::SYS_tkill,
@@ -415,7 +456,9 @@ mod tests {
] {
assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{nr} is refused outright");
}
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(4));
// The terminal rules, the socket notification ioctls (`FIOSETOWN`, `SIOCSPGRP`,
// `FIOASYNC`), the data-carrying rule and the encryption rule.
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(7));
}
#[test]
+25
View File
@@ -44,6 +44,8 @@ pub fn enforcement(canary: &Path, exe: &Path, target: u32) -> Vec<Check> {
Check { name: "TCP connection", outcome: denied(tcp_probe()) },
Check { name: "start a process", outcome: spawn_probe(exe) },
Check { name: "signal another process", outcome: signal_probe(target) },
#[cfg(target_os = "linux")]
Check { name: "switch a descriptor's notifications on", outcome: async_probe() },
]
}
@@ -128,6 +130,20 @@ fn write_probe(dir: &Path) -> Outcome {
denied(r.map(drop))
}
/// `O_ASYNC` on a descriptor of the program's own: the switch that has the kernel signal the
/// descriptor's owner when its state changes. The owner may be a process the program didn't name, a
/// standard stream the parent made, so on Linux the filter refuses setting it, the same way it
/// refuses the commands that name another owner. Naming one can't be asked here without `fcntl`'s
/// variable-argument form, which no safe API wraps; the rules refuse those commands, and the
/// confined-program test covers them.
#[cfg(target_os = "linux")]
fn async_probe() -> Outcome {
let r = nix::unistd::pipe().map_err(std::io::Error::from).and_then(|(_, write)| {
nix::fcntl::fcntl(write, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_ASYNC)).map(drop).map_err(std::io::Error::from)
});
denied(r)
}
fn tcp_probe() -> std::io::Result<()> {
TcpStream::connect_timeout(&SocketAddr::from(TCP_TARGET), Duration::from_secs(2)).map(drop)
}
@@ -216,6 +232,15 @@ mod tests {
}
}
/// Unconfined, the notification probe reports what the system allows; confined, the launch
/// check needs it `Denied`. Either way it comes out of the same three calls, so this proves
/// the probe runs and classifies rather than failing outright.
#[cfg(target_os = "linux")]
#[test]
fn the_notification_probe_classifies() {
assert!(matches!(async_probe(), Outcome::Allowed(_)), "unconfined the switch can be set");
}
#[test]
fn the_checks_on_the_canary_change_nothing_where_they_are_allowed() {
let canary = crate::Canary::create().unwrap();
+2 -2
View File
@@ -1,6 +1,6 @@
# The sandbox for helper processes
> **Last updated:** 2026-11-07 · **Change:** minor (no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
> **Last updated:** 2026-11-12 · **Change:** minor (no signals to another process on Linux, not even indirectly: `fcntl`'s notification commands and the socket ioctls that do the same are refused; earlier: no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
The sandbox is the layer GridCraft is to run code it doesn't trust in, such as a workbook's
Python cells: a separate process that confines itself before the code starts. Here only
@@ -24,7 +24,7 @@ other programs. The sandbox's job is that it can do none of these:
- **Network:** no sockets, no connections and no listeners. The exception is a standard stream the
parent made a socket (see below).
- **Other processes and the system:** no debugging or reading another process, no System V shared
memory, message queues or semaphores, no POSIX message queues, no signals to another process, no typing into a terminal it inherited, no mounts or namespaces, no kernel
memory, message queues or semaphores, no POSIX message queues, no signals to another process — on Linux not even indirectly, as the `fcntl` commands and socket ioctls that have the kernel send one are refused — no typing into a terminal it inherited, no mounts or namespaces, no kernel
modules, BPF, keyrings or performance counters. On Linux it also can't change another process's
limits, priority or scheduling.