299 lines
9.0 KiB
Rust
299 lines
9.0 KiB
Rust
use super::{CompiledService, SymlinkPolicy};
|
|
#[cfg(unix)]
|
|
use std::{ffi::CString, sync::Arc};
|
|
use std::{
|
|
fs::File,
|
|
io,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
pub(super) struct FilesystemRoot {
|
|
root: PathBuf,
|
|
symlink_policy: SymlinkPolicy,
|
|
#[cfg(unix)]
|
|
root_handle: Arc<File>,
|
|
}
|
|
|
|
impl FilesystemRoot {
|
|
pub(super) fn for_service(service: &CompiledService) -> Self {
|
|
Self {
|
|
root: service.root.clone(),
|
|
symlink_policy: service.symlink_policy,
|
|
#[cfg(unix)]
|
|
root_handle: service.root_handle.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(super) struct Opened {
|
|
pub(super) file: File,
|
|
pub(super) metadata: std::fs::Metadata,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(super) struct DirectoryEntry {
|
|
pub(super) name: String,
|
|
pub(super) directory: bool,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn cstring_component(value: &str) -> io::Result<CString> {
|
|
CString::new(value.as_bytes())
|
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub(super) fn open_root_directory(path: &Path) -> io::Result<File> {
|
|
use std::os::unix::{ffi::OsStrExt, io::FromRawFd};
|
|
let path = CString::new(path.as_os_str().as_bytes())
|
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
|
|
let fd = unsafe {
|
|
libc::open(
|
|
path.as_ptr(),
|
|
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
|
|
)
|
|
};
|
|
if fd < 0 {
|
|
Err(io::Error::last_os_error())
|
|
} else {
|
|
Ok(unsafe { File::from_raw_fd(fd) })
|
|
}
|
|
}
|
|
|
|
pub(super) fn path_components(path: &str) -> io::Result<Vec<String>> {
|
|
if !path.starts_with('/') {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"path is not absolute",
|
|
));
|
|
}
|
|
let mut result = Vec::new();
|
|
for part in path.split('/').filter(|part| !part.is_empty()) {
|
|
if part == "." || part == ".." || part.contains('\\') || part.contains('\0') {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"ambiguous path",
|
|
));
|
|
}
|
|
result.push(part.to_owned());
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> io::Result<Opened> {
|
|
use std::os::unix::io::{AsRawFd, FromRawFd};
|
|
|
|
if components.is_empty() {
|
|
let dot = cstring_component(".")?;
|
|
let fd = unsafe {
|
|
libc::openat(
|
|
root.root_handle.as_raw_fd(),
|
|
dot.as_ptr(),
|
|
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
|
|
)
|
|
};
|
|
if fd < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
let current = unsafe { File::from_raw_fd(fd) };
|
|
let metadata = current.metadata()?;
|
|
return Ok(Opened {
|
|
file: current,
|
|
metadata,
|
|
});
|
|
}
|
|
let mut current: Option<File> = None;
|
|
for (index, component) in components.iter().enumerate() {
|
|
let name = cstring_component(component)?;
|
|
let intermediate = index + 1 != components.len();
|
|
let mut flags = libc::O_RDONLY | libc::O_CLOEXEC;
|
|
if root.symlink_policy == SymlinkPolicy::Deny {
|
|
flags |= libc::O_NOFOLLOW;
|
|
}
|
|
if intermediate {
|
|
flags |= libc::O_DIRECTORY;
|
|
} else {
|
|
flags |= libc::O_NONBLOCK;
|
|
}
|
|
let parent = current.as_ref().unwrap_or(root.root_handle.as_ref());
|
|
let fd = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) };
|
|
if fd < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
if root.symlink_policy == SymlinkPolicy::InsideRoot {
|
|
#[cfg(target_os = "linux")]
|
|
verify_linux_fd_beneath_root(root, fd)?;
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
unsafe { libc::close(fd) };
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"inside_root symlink policy requires Linux",
|
|
));
|
|
}
|
|
}
|
|
current = Some(unsafe { File::from_raw_fd(fd) });
|
|
}
|
|
let current = current.expect("nonempty path opened its final component");
|
|
let metadata = current.metadata()?;
|
|
if !metadata.is_file() && !metadata.is_dir() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"Web Server serves only regular files and directories",
|
|
));
|
|
}
|
|
Ok(Opened {
|
|
file: current,
|
|
metadata,
|
|
})
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn verify_linux_fd_beneath_root(root: &FilesystemRoot, fd: libc::c_int) -> io::Result<()> {
|
|
let target = match std::fs::read_link(format!("/proc/self/fd/{fd}")) {
|
|
Ok(target) => target,
|
|
Err(error) => {
|
|
unsafe { libc::close(fd) };
|
|
return Err(error);
|
|
}
|
|
};
|
|
if target.starts_with(&root.root) {
|
|
Ok(())
|
|
} else {
|
|
unsafe { libc::close(fd) };
|
|
Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"symlink target escaped document root",
|
|
))
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub(super) fn secure_open_sync(root: &FilesystemRoot, components: &[String]) -> io::Result<Opened> {
|
|
let mut candidate = root.root.clone();
|
|
for component in components {
|
|
candidate.push(component);
|
|
}
|
|
let canonical = std::fs::canonicalize(&candidate)?;
|
|
if !canonical.starts_with(&root.root) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"path escaped document root",
|
|
));
|
|
}
|
|
let file = File::open(&canonical)?;
|
|
let metadata = file.metadata()?;
|
|
if !metadata.is_file() && !metadata.is_dir() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"Web Server serves only regular files and directories",
|
|
));
|
|
}
|
|
Ok(Opened { file, metadata })
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub(super) fn list_directory_sync(
|
|
root: &FilesystemRoot,
|
|
components: &[String],
|
|
) -> io::Result<Vec<DirectoryEntry>> {
|
|
use std::ffi::CStr;
|
|
use std::os::unix::io::IntoRawFd;
|
|
|
|
let opened = secure_open_sync(root, components)?;
|
|
if !opened.metadata.is_dir() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::NotADirectory,
|
|
"not a directory",
|
|
));
|
|
}
|
|
let raw = opened.file.try_clone()?.into_raw_fd();
|
|
let dir = unsafe { libc::fdopendir(raw) };
|
|
if dir.is_null() {
|
|
let error = io::Error::last_os_error();
|
|
unsafe { libc::close(raw) };
|
|
return Err(error);
|
|
}
|
|
let mut entries = Vec::new();
|
|
loop {
|
|
let entry = unsafe { libc::readdir(dir) };
|
|
if entry.is_null() {
|
|
break;
|
|
}
|
|
let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
|
|
if name == b"." || name == b".." {
|
|
continue;
|
|
}
|
|
let Ok(name) = std::str::from_utf8(name) else {
|
|
continue;
|
|
};
|
|
let mut child = components.to_vec();
|
|
child.push(name.to_owned());
|
|
let Ok(child_opened) = secure_open_sync(root, &child) else {
|
|
continue;
|
|
};
|
|
entries.push(DirectoryEntry {
|
|
name: name.to_owned(),
|
|
directory: child_opened.metadata.is_dir(),
|
|
});
|
|
}
|
|
unsafe { libc::closedir(dir) };
|
|
entries.sort_by(|a, b| {
|
|
b.directory
|
|
.cmp(&a.directory)
|
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
.then_with(|| a.name.cmp(&b.name))
|
|
});
|
|
Ok(entries)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub(super) fn list_directory_sync(
|
|
root: &FilesystemRoot,
|
|
components: &[String],
|
|
) -> io::Result<Vec<DirectoryEntry>> {
|
|
let opened = secure_open_sync(root, components)?;
|
|
if !opened.metadata.is_dir() {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::NotADirectory,
|
|
"not a directory",
|
|
));
|
|
}
|
|
let mut path = root.root.clone();
|
|
for component in components {
|
|
path.push(component);
|
|
}
|
|
let canonical = std::fs::canonicalize(path)?;
|
|
if !canonical.starts_with(&root.root) {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::PermissionDenied,
|
|
"path escaped document root",
|
|
));
|
|
}
|
|
let mut entries = Vec::new();
|
|
for entry in std::fs::read_dir(canonical)? {
|
|
let entry = entry?;
|
|
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
|
|
continue;
|
|
};
|
|
let mut child = components.to_vec();
|
|
child.push(name.clone());
|
|
let Ok(child_opened) = secure_open_sync(root, &child) else {
|
|
continue;
|
|
};
|
|
entries.push(DirectoryEntry {
|
|
name,
|
|
directory: child_opened.metadata.is_dir(),
|
|
});
|
|
}
|
|
entries.sort_by(|a, b| {
|
|
b.directory
|
|
.cmp(&a.directory)
|
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
|
.then_with(|| a.name.cmp(&b.name))
|
|
});
|
|
Ok(entries)
|
|
}
|