feat: authorize scoped symlink paths lexically
This commit is contained in:
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use globset::Glob;
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink};
|
||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult};
|
||||
|
||||
/// Execute a bounded glob entirely inside the provider process.
|
||||
pub fn run_glob(
|
||||
@@ -18,21 +18,13 @@ pub fn run_glob(
|
||||
if !access.is_readable(base) {
|
||||
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
|
||||
}
|
||||
if let Some(info) = direct_symlink(base)
|
||||
&& info.target_exists
|
||||
&& info.resolved_path.is_dir()
|
||||
{
|
||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Glob",
|
||||
path: PathBuf::from(request.path.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
let matcher = Glob::new(&request.pattern)
|
||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
||||
.compile_matcher();
|
||||
let mut matches = Vec::new();
|
||||
for entry in WalkBuilder::new(base).hidden(false).build().flatten() {
|
||||
let mut walker = WalkBuilder::new(base);
|
||||
walker.hidden(false).follow_links(false);
|
||||
for entry in walker.build().flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() || !access.is_readable(path) {
|
||||
continue;
|
||||
|
||||
@@ -477,13 +477,14 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
|
||||
fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
std::fs::create_dir(root.join("target-dir")).unwrap();
|
||||
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
|
||||
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
|
||||
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
|
||||
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
|
||||
@@ -501,18 +502,35 @@ mod tests {
|
||||
assert_eq!(file_result.match_count, 1);
|
||||
assert!(file_result.output.starts_with("file-link.rs\n"));
|
||||
|
||||
let directory_error = run_grep(
|
||||
let directory_result = run_grep(
|
||||
&root,
|
||||
root.join("directory-link"),
|
||||
request("directory-link"),
|
||||
&readable,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
directory_error,
|
||||
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
|
||||
if path == root.join("directory-link")
|
||||
));
|
||||
.unwrap();
|
||||
assert_eq!(directory_result.match_count, 1);
|
||||
assert!(
|
||||
directory_result
|
||||
.output
|
||||
.starts_with("directory-link/nested.rs\n")
|
||||
);
|
||||
|
||||
let glob_result = run_glob(
|
||||
&root,
|
||||
&root.join("directory-link"),
|
||||
GlobRequest {
|
||||
pattern: "**/*.rs".to_string(),
|
||||
path: FsPath::new("directory-link").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
glob_result.paths,
|
||||
vec![FsPath::new("directory-link/nested.rs").unwrap()]
|
||||
);
|
||||
|
||||
let broken_error = run_grep(
|
||||
&root,
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn run_read(
|
||||
) -> Result<ReadResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let path = require_access(&path, &logical, access, false)?;
|
||||
let path = require_access(&path, &logical, access, false, false)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if metadata.is_dir() {
|
||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||
@@ -99,7 +99,7 @@ pub fn run_write(
|
||||
let path = resolve(root, &logical)?;
|
||||
let created = !path.exists();
|
||||
if path.exists() {
|
||||
let target = require_access(&path, &logical, access, true)?;
|
||||
let target = require_access(&path, &logical, access, true, false)?;
|
||||
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
|
||||
if metadata.is_dir() {
|
||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||
@@ -117,7 +117,7 @@ pub fn run_write(
|
||||
FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
|
||||
})?;
|
||||
let parent_logical = logical_parent(&logical);
|
||||
require_access(parent, &parent_logical, access, true)?;
|
||||
require_access(parent, &parent_logical, access, true, true)?;
|
||||
atomic_write(&path, &request.content, &logical)?;
|
||||
}
|
||||
Ok(WriteResult {
|
||||
@@ -133,7 +133,7 @@ pub fn run_edit(
|
||||
) -> Result<EditResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let target = require_access(&path, &logical, access, true)?;
|
||||
let target = require_access(&path, &logical, access, true, false)?;
|
||||
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
|
||||
let actual_hash = hash_bytes(&bytes);
|
||||
if actual_hash != request.expected_hash {
|
||||
@@ -173,7 +173,7 @@ pub fn run_list(
|
||||
) -> Result<ListResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let path = require_access(&path, &logical, access, false)?;
|
||||
let path = require_access(&path, &logical, access, false, true)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
||||
@@ -247,6 +247,7 @@ fn require_access(
|
||||
logical: &FsPath,
|
||||
access: &dyn FsAccessPolicy,
|
||||
write: bool,
|
||||
allow_symlink_directory: bool,
|
||||
) -> Result<PathBuf, FsError> {
|
||||
if let Some(info) = direct_symlink(path) {
|
||||
if !info.target_exists {
|
||||
@@ -257,9 +258,9 @@ fn require_access(
|
||||
});
|
||||
}
|
||||
let allowed = if write {
|
||||
access.is_writable(&info.resolved_path)
|
||||
access.is_writable(path)
|
||||
} else {
|
||||
access.is_readable(&info.resolved_path)
|
||||
access.is_readable(path)
|
||||
};
|
||||
if !allowed {
|
||||
return Err(FsError::SymlinkOutOfScope {
|
||||
@@ -268,7 +269,7 @@ fn require_access(
|
||||
required_permission: if write { "write" } else { "read" },
|
||||
});
|
||||
}
|
||||
if write && info.resolved_path.is_dir() {
|
||||
if !allow_symlink_directory && info.resolved_path.is_dir() {
|
||||
return Err(FsError::SymlinkTargetIsDirectory {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
|
||||
@@ -259,16 +259,6 @@ pub fn run_grep(
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if base_meta.is_dir()
|
||||
&& let Some(info) = symlink.as_ref()
|
||||
{
|
||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Grep",
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let filter_base = if base_meta.is_file() { root } else { &base };
|
||||
let types = build_types(p.file_type.as_deref())?;
|
||||
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
|
||||
|
||||
Reference in New Issue
Block a user