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()