Skip to main content

qcode/desktop/
seccomp.rs

1//! The seccomp profile a window's container runs under on docker, and how it reaches the engine.
2//!
3//! Podman needs none of this. Rootless podman already lets a process inside a container open an
4//! unprivileged user namespace of its own, so a browser-engine application built its own sandbox
5//! under podman's default profile untouched: its renderer processes came up in their own user, PID
6//! and network namespaces, each with a filter of its own on top of the container's.
7//!
8//! Docker's default profile allows `unshare` and `setns` only to CAP_SYS_ADMIN and masks the
9//! namespace flags out of `clone`. Under it the application cannot build the sandbox at all: it
10//! prints that moving to a new namespace was not permitted and exits with 133. The two ways out
11//! are giving the container no filter at all, which loses every other syscall rule with it, or
12//! giving it the default profile plus those three calls. QCode carries the second, in
13//! `assets/seccomp/desktop.json`, and hands it to docker by path. What it costs is stated in that
14//! file's own comment: a process in the container can make a user namespace, so the host kernel's
15//! unprivileged-user-namespace surface is open to it — which is what podman gives by default
16//! anyway.
17//!
18//! `--no-sandbox` is not an option here, and the record's flags are tested for its absence. It
19//! would let a page or an extension that takes over a renderer reach everything in the container
20//! with the person's own rights: the workspace's files, the profile's home volume, the login.
21//!
22//! The file is carried in the binary and written out when it is first needed, named after a digest
23//! of its own content, so a QCode that has been updated never hands the engine an older file left
24//! in the temporary folder.
25
26use std::io;
27use std::path::PathBuf;
28
29/// The profile, as it is carried: docker's own default with `clone`, `setns` and `unshare` allowed.
30pub const PROFILE: &str = include_str!("../../assets/seccomp/desktop.json");
31
32/// Writes the profile where the engine can read it and answers its path.
33///
34/// Writing it again over an existing one is fine and is what happens when two QCodes start
35/// together: the content is the same, because the name is made from it.
36///
37/// # Errors
38///
39/// When the temporary folder cannot be written to.
40pub fn file() -> io::Result<PathBuf> {
41    let path = std::env::temp_dir().join(format!("qcode-seccomp-{}.json", digest(PROFILE)));
42    std::fs::write(&path, PROFILE)?;
43    Ok(path)
44}
45
46/// FNV-1a over the bytes of `text`, as the base image's revision uses: the question is only
47/// whether this is the same text, never whether someone made it collide.
48fn digest(text: &str) -> String {
49    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
50    const PRIME: u64 = 0x0000_0100_0000_01b3;
51    let hash = text.bytes().fold(OFFSET, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(PRIME));
52    format!("{hash:016x}")
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn the_profile_is_dockers_default_with_the_three_calls_a_sandbox_needs() {
61        // Read as text rather than parsed: the point is that the file QCode carries still says
62        // these things, and a parser here would only restate the engine's.
63        assert!(PROFILE.contains("\"defaultAction\": \"SCMP_ACT_ERRNO\""), "the default must stay a refusal");
64        assert!(PROFILE.contains("\"SCMP_ARCH_X86_64\"") && PROFILE.contains("\"SCMP_ARCH_AARCH64\""));
65        let extra = PROFILE.rfind("\"unshare\"").expect("unshare is allowed");
66        let last_allow = PROFILE[extra..].find("\"SCMP_ACT_ALLOW\"").expect("and allowed, not refused");
67        assert!(last_allow < 400, "the allow belongs to the group that names it");
68        for call in ["\"clone\"", "\"setns\"", "\"unshare\""] {
69            assert!(PROFILE[extra - 200..].contains(call), "{call} is not in the group QCode adds");
70        }
71        // Never the whole filter thrown away, and never the application's own sandbox turned off.
72        assert!(!PROFILE.contains("unconfined"), "{PROFILE}");
73        assert!(PROFILE.contains("QCode adds these three"), "the file says why it is not the default");
74    }
75
76    #[test]
77    fn the_file_is_named_after_its_own_content_so_an_old_one_is_never_handed_over() {
78        let written = file().expect("the temporary folder is writable");
79        assert_eq!(std::fs::read_to_string(&written).expect("it was written"), PROFILE);
80        assert_eq!(written, file().expect("again"), "the same profile is the same file");
81        let name = written.file_name().expect("a name").to_string_lossy().into_owned();
82        assert!(name.starts_with("qcode-seccomp-") && name.ends_with(".json"), "{name}");
83        assert_ne!(digest(PROFILE), digest(&format!("{PROFILE} ")), "a changed profile is a changed name");
84        let _ = std::fs::remove_file(&written);
85    }
86}