Skip to content

Commit

Permalink
Capsicum (#481)
Browse files Browse the repository at this point in the history
Capsicum is a security technology that restricts processes from
accessing any global namespaces. After entering capsicum mode, a process
may no longer use a syscall like open(); instead it's restricted to
openat().

This PR:
* Modifies unftp-sbe-fs to work in capability mode, using cap-std
* Modifies libunftp to provide hooks for Capsicum-using consumers
* Extends libunftp-auth-jsonfile to allow per-user home directories
* Adds an example to unftp-sbe-fs demonstrating Capsicum mode.

Fixes #475
  • Loading branch information
asomers authored Jan 11, 2024
1 parent bb1c3a2 commit e61951f
Show file tree
Hide file tree
Showing 32 changed files with 988 additions and 231 deletions.
8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@ getrandom = "0.2.11"
lazy_static = "1.4.0"
md-5 = "0.10.6"
moka = { version = "0.11.3", default-features = false, features = ["sync"] }
nix = { version = "0.26.2", default-features = false, features = ["fs"] }
prometheus = { version = "0.13.3", default-features = false }
proxy-protocol = "0.5.0"
rustls = "0.21.10"
rustls-pemfile = "1.0.4"
slog = { version = "2.7.0", features = ["max_level_trace", "release_max_level_info"] }
slog-stdlog = "4.1.1"
thiserror = "1.0.51"
tokio = { version = "1.35.1", features = ["macros", "rt", "net", "sync", "io-util", "time"] }
tokio = { version = "1.35.1", features = ["macros", "rt", "net", "process", "sync", "io-util", "time"] }
tokio-rustls = "0.24.1"
tokio-util = { version = "0.7.10", features = ["codec"] }
tracing = { version = "0.1.40", default-features = false }
Expand All @@ -61,3 +62,8 @@ libc = "0.2"
pretty_assertions = "1.4.0"
tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread"] }
unftp-sbe-fs = { path = "../libunftp/crates/unftp-sbe-fs"}


[patch.crates-io]
capsicum = { git = "https://github.com/asomers/capsicum-rs", rev = "24330ee"}
casper-sys = { git = "https://github.com/asomers/capsicum-rs", rev = "24330ee"}
12 changes: 8 additions & 4 deletions crates/unftp-auth-jsonfile/examples/jsonfile_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ use std::sync::Arc;
use unftp_auth_jsonfile::JsonFileAuthenticator;
use unftp_sbe_fs::ServerExt;

pub fn main() -> Result<(), Box<dyn std::error::Error>> {
#[tokio::main(flavor = "current_thread")]
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
pretty_env_logger::init();

let authenticator = JsonFileAuthenticator::from_file(String::from("credentials.json"))?;

let addr = "127.0.0.1:2121";
let server = libunftp::Server::with_fs(std::env::temp_dir()).authenticator(Arc::new(authenticator));
let server = libunftp::Server::with_fs(std::env::temp_dir())
.authenticator(Arc::new(authenticator))
.build()
.await
.unwrap();

println!("Starting ftp server on {}", addr);
let runtime = tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build().unwrap();
runtime.block_on(server.listen(addr))?;
server.listen(addr).await?;

Ok(())
}
13 changes: 8 additions & 5 deletions crates/unftp-auth-rest/examples/rest.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use std::env;
use std::sync::Arc;
use tokio::runtime::Builder as TokioBuilder;
use unftp_auth_rest::{Builder, RestAuthenticator};
use unftp_sbe_fs::ServerExt;

pub fn main() -> Result<(), Box<dyn std::error::Error>> {
#[tokio::main(flavor = "current_thread")]
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
pretty_env_logger::init();

let _args: Vec<String> = env::args().collect();
Expand All @@ -21,10 +21,13 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
.build()?;

let addr = "127.0.0.1:2121";
let server = libunftp::Server::with_fs(std::env::temp_dir()).authenticator(Arc::new(authenticator));
let server = libunftp::Server::with_fs(std::env::temp_dir())
.authenticator(Arc::new(authenticator))
.build()
.await
.unwrap();

println!("Starting ftp server on {}", addr);
let runtime = TokioBuilder::new_current_thread().enable_io().enable_time().build()?;
runtime.block_on(server.listen(addr))?;
server.listen(addr).await?;
Ok(())
}
10 changes: 10 additions & 0 deletions crates/unftp-sbe-fs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ readme = "README.md"
[dependencies]
async-trait = "0.1.75"
cfg-if = "1.0"
cap-std = "2.0"
futures = { version = "0.3.29", default-features = false, features = ["std"] }
lazy_static = "1.4.0"
libunftp = { version="0.19.1", path="../../"}
path_abs = "0.5.1"
tokio = { version = "1.35.1", features = ["rt", "net", "sync", "io-util", "time", "fs"] }
Expand All @@ -31,13 +33,21 @@ tracing-attributes = "0.1.27"

[dev-dependencies]
async_ftp = "6.0.0"
async-trait = "0.1.73"
more-asserts = "0.3.1"
pretty_assertions = "1.4.0"
pretty_env_logger = "0.5.0"
rstest = "0.18.2"
serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.107"
slog-async = "2.8.0"
slog-term = "2.9.0"
tempfile = "3.8.1"
tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread"] }
tracing-subscriber = "0.3.18"
getrandom = "0.2.11"

[target.'cfg(target_os = "freebsd")'.dev-dependencies]
capsicum = { version = "0.3.0", features = ["casper"] }
capsicum-net = { version = "0.1.0", features = ["tokio"], git = "https://github.com/asomers/capsicum-net", rev = "c6fc574" }

4 changes: 2 additions & 2 deletions crates/unftp-sbe-fs/examples/basic.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use unftp_sbe_fs::ServerExt;

#[tokio::main]
#[tokio::main(flavor = "current_thread")]
pub async fn main() {
pretty_env_logger::init();

let addr = "127.0.0.1:2121";
let server = libunftp::Server::with_fs(std::env::temp_dir());
let server = libunftp::Server::with_fs(std::env::temp_dir()).build().await.unwrap();

println!("Starting ftp server on {}", addr);
server.listen(addr).await.unwrap();
Expand Down
267 changes: 267 additions & 0 deletions crates/unftp-sbe-fs/examples/cap-ftpd-worker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
//! A libexec helper for cap-std. It takes an int as $1 which is interpreted as
//! a file descriptor for an already-connected an authenticated control socket.
//! Do not invoke this program directly. Rather, invoke it by examples/cap-ftpd
use std::{
env,
os::fd::{FromRawFd, RawFd},
process::exit,
sync::{Arc, Mutex},
};

use cfg_if::cfg_if;

use tokio::net::TcpStream;

use libunftp::Server;
use unftp_sbe_fs::Filesystem;

mod auth {
use std::{
collections::HashMap,
fmt, fs,
io::Read,
path::{Path, PathBuf},
time::Duration,
};

use async_trait::async_trait;
use libunftp::auth::{AuthenticationError, Authenticator, DefaultUser, UserDetail};
use serde::Deserialize;
use tokio::time::sleep;

#[derive(Debug)]
pub struct User {
username: String,
home: Option<PathBuf>,
}

#[derive(Deserialize, Clone, Debug)]
#[serde(untagged)]
enum Credentials {
Plaintext {
username: String,
password: Option<String>,
home: Option<PathBuf>,
},
}

#[derive(Clone, Debug)]
struct UserCreds {
pub password: Option<String>,
pub home: Option<PathBuf>,
}

impl User {
fn new(username: &str, home: &Option<PathBuf>) -> Self {
User {
username: username.to_owned(),
home: home.clone(),
}
}
}

impl UserDetail for User {
fn home(&self) -> Option<&Path> {
match &self.home {
None => None,
Some(p) => Some(p.as_path()),
}
}
}

impl fmt::Display for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.username.as_str())
}
}

/// This structure implements the libunftp `Authenticator` trait
#[derive(Clone, Debug)]
pub struct JsonFileAuthenticator {
credentials_map: HashMap<String, UserCreds>,
}

impl JsonFileAuthenticator {
/// Initialize a new [`JsonFileAuthenticator`] from file.
pub fn from_file<P: AsRef<Path>>(filename: P) -> Result<Self, Box<dyn std::error::Error>> {
let mut f = fs::File::open(&filename)?;

let mut json = String::new();
f.read_to_string(&mut json)?;

Self::from_json(json)
}

/// Initialize a new [`JsonFileAuthenticator`] from json string.
pub fn from_json<T: Into<String>>(json: T) -> Result<Self, Box<dyn std::error::Error>> {
let credentials_list: Vec<Credentials> = serde_json::from_str::<Vec<Credentials>>(&json.into())?;
let map: Result<HashMap<String, UserCreds>, _> = credentials_list.into_iter().map(Self::list_entry_to_map_entry).collect();
Ok(JsonFileAuthenticator { credentials_map: map? })
}

fn list_entry_to_map_entry(user_info: Credentials) -> Result<(String, UserCreds), Box<dyn std::error::Error>> {
let map_entry = match user_info {
Credentials::Plaintext { username, password, home } => (username.clone(), UserCreds { password, home }),
};
Ok(map_entry)
}

fn check_password(given_password: &str, actual_password: &Option<String>) -> Result<(), ()> {
if let Some(pwd) = actual_password {
if pwd == given_password {
Ok(())
} else {
Err(())
}
} else {
Err(())
}
}
}

#[async_trait]
impl Authenticator<User> for JsonFileAuthenticator {
#[tracing_attributes::instrument]
async fn authenticate(&self, username: &str, creds: &libunftp::auth::Credentials) -> Result<User, AuthenticationError> {
let res = if let Some(actual_creds) = self.credentials_map.get(username) {
let pass_check_result = match &creds.password {
Some(ref given_password) => {
if Self::check_password(given_password, &actual_creds.password).is_ok() {
Some(Ok(User::new(username, &actual_creds.home)))
} else {
Some(Err(AuthenticationError::BadPassword))
}
}
None => None,
};

match pass_check_result {
None => Err(AuthenticationError::BadPassword),
Some(pass_res) => {
if pass_res.is_ok() {
Ok(User::new(username, &actual_creds.home))
} else {
pass_res
}
}
}
} else {
Err(AuthenticationError::BadUser)
};

if res.is_err() {
sleep(Duration::from_millis(1500)).await;
}

res
}

fn name(&self) -> &str {
std::any::type_name::<Self>()
}
}

#[async_trait]
impl Authenticator<DefaultUser> for JsonFileAuthenticator {
#[tracing_attributes::instrument]
async fn authenticate(&self, username: &str, creds: &libunftp::auth::Credentials) -> Result<DefaultUser, AuthenticationError> {
let _: User = self.authenticate(username, creds).await?;
Ok(DefaultUser {})
}
}
}

use auth::{JsonFileAuthenticator, User};

cfg_if! {
if #[cfg(target_os = "freebsd")] {
use std::{
io,
net::IpAddr,
ops::Range
};
use async_trait::async_trait;
use capsicum::casper::Casper;
use capsicum_net::{CapNetAgent, CasperExt, tokio::TcpSocketExt};
use tokio::net::TcpSocket;

#[derive(Debug)]
struct CapBinder {
agent: CapNetAgent
}

impl CapBinder {
fn new(agent: CapNetAgent) -> Self {
Self{agent}
}
}

#[async_trait]
impl libunftp::options::Binder for CapBinder {
async fn bind(&mut self, local_addr: IpAddr, passive_ports: Range<u16>) -> io::Result<TcpSocket> {
const BIND_RETRIES: u8 = 10;

for _ in 1..BIND_RETRIES {
let mut data = [0u8; 2];
getrandom::getrandom(&mut data).expect("Error generating random port");
let r16 = u16::from_ne_bytes(data);
let p = passive_ports.start + r16 % (passive_ports.end - passive_ports.start);
let socket = TcpSocket::new_v4()?;
let addr = std::net::SocketAddr::new(local_addr, p);
match socket.cap_bind(&mut self.agent, addr) {
Ok(()) => return Ok(socket),
Err(_) => todo!()
}
}
panic!()
}
}
}
}

#[tokio::main(flavor = "current_thread")]
#[allow(unused_mut)] // Not unused on all OSes.
async fn main() {
println!("Starting helper");
let args: Vec<String> = env::args().collect();

if args.len() != 3 {
eprintln!("Usage: {} <AUTH_FILE> <FD>", args[0]);
exit(2);
}
let fd: RawFd = if let Ok(fd) = args[2].parse() {
fd
} else {
eprintln!("Usage: {} <FD>\nFD must be numeric", args[0]);
exit(2)
};

let std_stream = unsafe { std::net::TcpStream::from_raw_fd(fd) };

let control_sock = TcpStream::from_std(std_stream).unwrap();

let auth = Arc::new(JsonFileAuthenticator::from_file(args[1].clone()).unwrap());
// XXX This would be a lot easier if the libunftp API allowed creating the
// storage just before calling service.
let storage = Mutex::new(Some(Filesystem::new(std::env::temp_dir())));
let sgen = Box::new(move || storage.lock().unwrap().take().unwrap());

let mut sb = libunftp::ServerBuilder::with_authenticator(sgen, auth);
cfg_if! {
if #[cfg(target_os = "freebsd")] {
// Safe because we're single-threaded
let mut casper = unsafe { Casper::new().unwrap() };

let cap_net = casper.net().unwrap();
let binder = CapBinder::new(cap_net);
sb = sb.binder(binder);
}
}
let server: Server<Filesystem, User> = sb.build().await.unwrap();
cfg_if! {
if #[cfg(target_os = "freebsd")] {
capsicum::enter().unwrap();
}
}
server.service(control_sock).await.unwrap()
}
Loading

0 comments on commit e61951f

Please sign in to comment.