From 21db054983fc735a972db291a6803d423ea2d75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Vask=C3=B3?= <1771332+vlaci@users.noreply.github.com> Date: Fri, 2 Jun 2023 15:23:05 +0200 Subject: [PATCH] feat: add landlock based access restriction functionality Landlock is a kernel API for unprivileged access control. We take advantage of it to limit where unblob can write to and read from on the filesystem. This is a Linux-only feature that won't be enabled on OSX. For more information, see https://docs.kernel.org/userspace-api/landlock.html We use Landlock ABI version 2 since it introduced the LANDLOCK_ACCESS_FS_REFER permission that's required to create hardlinks. Co-authored-by: Quentin Kaiser <quentin.kaiser@onekey.com> --- Cargo.lock | 71 ++++++++++++++++++++ Cargo.toml | 5 ++ python/unblob_native/_native/__init__.pyi | 4 +- python/unblob_native/_native/sandbox.pyi | 11 ++++ src/lib.rs | 4 ++ src/sandbox/linux.rs | 72 ++++++++++++++++++++ src/sandbox/mod.rs | 80 +++++++++++++++++++++++ src/sandbox/unsupported.rs | 9 +++ tests/test_sandbox.py | 61 +++++++++++++++++ 9 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 python/unblob_native/_native/sandbox.pyi create mode 100644 src/sandbox/linux.rs create mode 100644 src/sandbox/mod.rs create mode 100644 src/sandbox/unsupported.rs create mode 100644 tests/test_sandbox.py diff --git a/Cargo.lock b/Cargo.lock index 2035b28..cb7689b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arc-swap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + [[package]] name = "atty" version = "0.2.14" @@ -178,6 +184,26 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +[[package]] +name = "enumflags2" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c041f5090df68b32bcd905365fd51769c8b9d553fe87fde0b683534f10c01bd2" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9a1f9f7d83e59740248a6e14ecf93929ade55027844dfcea78beafccc15745" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + [[package]] name = "getrandom" version = "0.2.10" @@ -240,6 +266,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "landlock" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520baa32708c4e957d2fc3a186bc5bd8d26637c33137f399ddfc202adb240068" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -426,6 +463,17 @@ dependencies = [ "pyo3-build-config", ] +[[package]] +name = "pyo3-log" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c8b57fe71fb5dcf38970ebedc2b1531cf1c14b1b9b4c560a182a57e115575c" +dependencies = [ + "arc-swap", + "log", + "pyo3", +] + [[package]] name = "pyo3-macros" version = "0.18.3" @@ -650,6 +698,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.25", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -666,7 +734,10 @@ version = "0.1.1" dependencies = [ "approx", "criterion", + "landlock", + "log", "pyo3", + "pyo3-log", "rand", ] diff --git a/Cargo.toml b/Cargo.toml index 0390c58..c82c9ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,12 @@ crate-type = [ ] [dependencies] +log = "0.4.18" pyo3 = "0.18.3" +pyo3-log = "0.8.1" + +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.2.0" [dev-dependencies] approx = "0.5.0" diff --git a/python/unblob_native/_native/__init__.pyi b/python/unblob_native/_native/__init__.pyi index 023b129..a27e844 100644 --- a/python/unblob_native/_native/__init__.pyi +++ b/python/unblob_native/_native/__init__.pyi @@ -1,3 +1,3 @@ -from . import math_tools as math_tools +from . import math_tools, sandbox -__all__ = ["math_tools"] +__all__ = ["math_tools", "sandbox"] diff --git a/python/unblob_native/_native/sandbox.pyi b/python/unblob_native/_native/sandbox.pyi new file mode 100644 index 0000000..0005dc2 --- /dev/null +++ b/python/unblob_native/_native/sandbox.pyi @@ -0,0 +1,11 @@ +class AccessFS: + @staticmethod + def read(access_dir: str) -> AccessFS: ... + @staticmethod + def read_write(access_dir: str) -> AccessFS: ... + @staticmethod + def make_reg(access_dir: str) -> AccessFS: ... + @staticmethod + def make_dir(access_dir: str) -> AccessFS: ... + +def restrict_access(*args: AccessFS) -> None: ... diff --git a/src/lib.rs b/src/lib.rs index 14f0ef4..d9e70f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod math_tools; +pub mod sandbox; use pyo3::prelude::*; @@ -6,6 +7,9 @@ use pyo3::prelude::*; #[pymodule] fn _native(py: Python, m: &PyModule) -> PyResult<()> { math_tools::init_module(py, m)?; + sandbox::init_module(py, m)?; + + pyo3_log::init(); Ok(()) } diff --git a/src/sandbox/linux.rs b/src/sandbox/linux.rs new file mode 100644 index 0000000..d9bc06e --- /dev/null +++ b/src/sandbox/linux.rs @@ -0,0 +1,72 @@ +use landlock::{ + path_beneath_rules, Access, AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr, ABI, +}; +use log; + +use crate::sandbox::AccessFS; + +impl AccessFS { + fn read(&self) -> Option<&str> { + if let Self::Read(path) = self { + Some(path) + } else { + None + } + } + + fn read_write(&self) -> Option<&str> { + if let Self::ReadWrite(path) = self { + Some(path) + } else { + None + } + } + + fn make_reg(&self) -> Option<&str> { + if let Self::MakeReg(path) = self { + Some(path) + } else { + None + } + } + + fn make_dir(&self) -> Option<&str> { + if let Self::MakeDir(path) = self { + Some(path) + } else { + None + } + } +} + +pub fn restrict_access(access_rules: &[AccessFS]) -> Result<(), Box<dyn std::error::Error>> { + let abi = ABI::V2; + + let read_only: Vec<&str> = access_rules.iter().filter_map(AccessFS::read).collect(); + + let read_write: Vec<&str> = access_rules + .iter() + .filter_map(AccessFS::read_write) + .collect(); + + let create_file: Vec<&str> = access_rules.iter().filter_map(AccessFS::make_reg).collect(); + + let create_directory: Vec<&str> = access_rules.iter().filter_map(AccessFS::make_dir).collect(); + + let status = Ruleset::new() + .handle_access(AccessFs::from_all(abi))? + .create()? + .add_rules(path_beneath_rules(read_only, AccessFs::from_read(abi)))? + .add_rules(path_beneath_rules(read_write, AccessFs::from_all(abi)))? + .add_rules(path_beneath_rules(create_file, AccessFs::MakeReg))? + .add_rules(path_beneath_rules(create_directory, AccessFs::MakeDir))? + .restrict_self()?; + + log::info!( + "Activated FS access restrictions; rules={:?}, status={:?}", + access_rules, + status.ruleset + ); + + Ok(()) +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs new file mode 100644 index 0000000..2ff7eec --- /dev/null +++ b/src/sandbox/mod.rs @@ -0,0 +1,80 @@ +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(not(target_os = "linux"), path = "unsupported.rs")] +mod sandbox_impl; + +use pyo3::{create_exception, exceptions::PyException, prelude::*, types::PyTuple}; + +#[derive(Clone, Debug)] +pub enum AccessFS { + Read(String), + ReadWrite(String), + MakeReg(String), + MakeDir(String), +} + +/// Enforces access restrictions +#[pyfunction(name = "restrict_access", signature=(*rules))] +fn py_restrict_access(rules: &PyTuple) -> PyResult<()> { + sandbox_impl::restrict_access( + &rules + .iter() + .map(|r| Ok(r.extract::<PyAccessFS>()?.access)) + .collect::<PyResult<Vec<_>>>()?, + ) + .map_err(|err| SandboxError::new_err(err.to_string())) +} + +create_exception!(unblob_native.sandbox, SandboxError, PyException); + +#[pyclass(name = "AccessFS", module = "unblob_native.sandbox")] +#[derive(Clone)] +struct PyAccessFS { + access: AccessFS, +} + +impl PyAccessFS { + fn new(access: AccessFS) -> Self { + Self { access } + } +} + +#[pymethods] +impl PyAccessFS { + #[staticmethod] + fn read(dir: String) -> Self { + Self::new(AccessFS::Read(dir)) + } + + #[staticmethod] + fn read_write(dir: String) -> Self { + Self::new(AccessFS::ReadWrite(dir)) + } + + #[staticmethod] + fn make_reg(dir: String) -> Self { + Self::new(AccessFS::MakeReg(dir)) + } + + #[staticmethod] + fn make_dir(dir: String) -> Self { + Self::new(AccessFS::MakeDir(dir)) + } +} + +pub fn init_module(py: Python, root_module: &PyModule) -> PyResult<()> { + let module = PyModule::new(py, "sandbox")?; + module.add_function(wrap_pyfunction!(py_restrict_access, module)?)?; + module.add_class::<PyAccessFS>()?; + + root_module.add_submodule(module)?; + + let sys = PyModule::import(py, "sys")?; + let modules = sys.getattr("modules")?; + modules.call_method( + "__setitem__", + ("unblob_native.sandbox".to_string(), module), + None, + )?; + + Ok(()) +} diff --git a/src/sandbox/unsupported.rs b/src/sandbox/unsupported.rs new file mode 100644 index 0000000..2b7d6c7 --- /dev/null +++ b/src/sandbox/unsupported.rs @@ -0,0 +1,9 @@ +use log; + +use crate::sandbox::AccessFS; + +pub fn restrict_access(_access_rules: &[AccessFS]) -> Result<(), Box<dyn std::error::Error>> { + log::warn!("Sandboxing FS access is unavailable on this system"); + + Ok(()) +} diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..43fb8bf --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,61 @@ +import platform +from pathlib import Path + +import pytest + +from unblob_native.sandbox import AccessFS, restrict_access # type: ignore + +FILE_CONTENT = b"HELLO" + + +@pytest.mark.skipif(platform.system() == "Linux", reason="Linux is supported.") +def test_unsupported_platform(): + restrict_access(AccessFS.read("/")) + + +@pytest.fixture(scope="session") +def sandbox_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + sandbox_path = tmp_path_factory.mktemp("sandbox") + + file_path = sandbox_path / "file.txt" + dir_path = sandbox_path / "dir" + link_path = sandbox_path / "link" + + with file_path.open("wb") as f: + assert f.write(FILE_CONTENT) == len(FILE_CONTENT) + + dir_path.mkdir() + link_path.symlink_to(file_path) + + return sandbox_path + + +@pytest.mark.skipif( + platform.system() != "Linux" or platform.machine() != "x86_64", + reason="Only supported on Linux x86-64.", +) +def test_read_sandboxing(sandbox_path: Path): + restrict_access( + AccessFS.read("/"), AccessFS.read(sandbox_path.resolve().as_posix()) + ) + + with pytest.raises(PermissionError): + (sandbox_path / "some-dir").mkdir() + + with pytest.raises(PermissionError): + (sandbox_path / "some-file").touch() + + with pytest.raises(PermissionError): + (sandbox_path / "some-link").symlink_to("file.txt") + + for path in sandbox_path.rglob("**/*"): + if path.is_file() or path.is_symlink(): + with path.open("rb") as f: + assert f.read() == FILE_CONTENT + with pytest.raises(PermissionError): + assert path.open("r+") + with pytest.raises(PermissionError): + assert path.unlink() + elif path.is_dir(): + with pytest.raises(PermissionError): + path.rmdir()