blob: 25a0ddbd724b19c22c4089ce10e003dc644d6b22 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::{WriteAccess, WriteRule};
use maka_sandbox::filesystem::{Access, Policy, Rule, Scope};
use std::{collections::BTreeMap, io, os::windows::fs::MetadataExt, path::Path};
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
// Native targets include deny descendants, not just caller-supplied rules.
pub(super) struct Plan {
pub reads: Vec<Rule>,
pub writes: Vec<WriteRule>,
}
impl Plan {
pub fn compile(
policy: &Policy,
helper: &Path,
executable: &Path,
cwd: &Path,
) -> io::Result<Self> {
let compiled = policy.compile().map_err(io::Error::other)?;
let policy = compiled.policy();
if policy.default != Access::Read || !policy.deny_globs.is_empty() {
return Err(unsupported(
"Windows execution requires a read-default path policy without deny globs",
));
}
if !compiled.access(cwd).can_read()
|| !compiled.access(executable).can_read()
|| !compiled.access(helper).can_read()
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"sandbox denies its working directory or executable",
));
}
let mut reads = BTreeMap::new();
// Only launch-critical reads belong to account preparation. Broad
// default reads are prepared independently for the installation group.
reads.insert(cwd.to_owned(), (Scope::Subtree, compiled.access(cwd)));
let owner = maka_event_log::root::windows::account_sid()?;
if let Ok((_, file)) = maka_sandbox::windows::acl::Target::capture(executable)
&& maka_sandbox::windows::acl::manageable_by(&file, &owner)?
{
reads.insert(executable.to_owned(), (Scope::Exact, Access::Read));
}
for rule in &policy.rules {
let metadata = rule.path.symlink_metadata().map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
unsupported(format!(
"sandbox boundary {} does not exist; request access to an existing parent and create the path inside the sandbox",
rule.path.display()
))
} else {
error
}
})?;
if rule.scope == Scope::Exact && metadata.is_dir() {
return Err(unsupported(
"exact directory policy needs a distinct descendant boundary",
));
}
if reads
.insert(rule.path.clone(), (rule.scope, rule.access))
.is_some_and(|(scope, _)| scope != rule.scope)
{
return Err(unsupported("conflicting Windows ACL scopes"));
}
}
// Inherited denies do not override a child's explicit allow and never
// reach protected DACLs. Pin direct denies for the existing subtree;
// inherited entries still protect ordinary future children. A command
// cannot create or change a DACL inside its denied surface.
let mut directories = policy
.rules
.iter()
.filter(|rule| rule.access == Access::Deny && rule.scope == Scope::Subtree)
.map(|rule| rule.path.clone())
.collect::<Vec<_>>();
let mut visited = std::collections::BTreeSet::new();
while let Some(directory) = directories.pop() {
if !visited.insert(directory.clone()) {
continue;
}
if !directory.symlink_metadata()?.is_dir() {
continue;
}
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if compiled.access(&path) != Access::Deny {
continue;
}
let metadata = entry.metadata()?;
if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(unsupported(
"Windows denied subtree contains a reparse point",
));
}
let scope = if metadata.is_dir() {
Scope::Subtree
} else {
Scope::Exact
};
reads.entry(path.clone()).or_insert((scope, Access::Deny));
if metadata.is_dir() {
directories.push(path);
}
}
}
// Provider-based shells inspect ancestor metadata when entering cwd.
// Do not reopen an explicitly denied ancestor merely for convenience.
for path in [cwd, executable, helper] {
for ancestor in path.ancestors() {
if compiled.access(ancestor).can_read() && !reads.contains_key(ancestor) {
// Never acquire optional system-directory ACLs merely
// because this invocation happens to be elevated.
match maka_sandbox::windows::acl::Target::capture(ancestor) {
Ok((_, file)) => {
if !maka_sandbox::windows::acl::manageable_by(&file, &owner)? {
continue;
}
}
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => continue,
Err(error) => return Err(error),
}
reads
.entry(ancestor.to_owned())
.or_insert((Scope::Exact, Access::Read));
}
}
}
let mut writes = BTreeMap::new();
for rule in &policy.rules {
let access = if rule.access == Access::Write {
if policy.rules.iter().any(|child| {
child.path != rule.path
&& child.path.starts_with(&rule.path)
&& child.access != Access::Write
}) {
WriteAccess::Preserve
} else {
WriteAccess::Allowed
}
} else {
WriteAccess::Denied
};
writes.insert(rule.path.clone(), (rule.scope, access));
}
// A guard is useless if a writable intermediate directory can move
// away with it. Protect every writable ancestor, not just rule roots.
for rule in policy
.rules
.iter()
.filter(|rule| rule.access != Access::Write)
{
for ancestor in rule.path.ancestors().skip(1) {
if compiled.access(ancestor) == Access::Write {
// Only this directory must resist rename/delete. Its children
// already inherit the writable root's grant; propagating it
// again at every intermediate ancestor repeatedly walks the
// same tree during both preparation and settlement.
writes
.entry(ancestor.to_owned())
.and_modify(|(_, access)| *access = WriteAccess::Preserve)
.or_insert((Scope::Exact, WriteAccess::Preserve));
reads
.entry(ancestor.to_owned())
.or_insert((Scope::Exact, Access::Write));
}
}
}
reads.insert(helper.to_owned(), (Scope::Exact, Access::Read));
Ok(Self {
reads: reads
.into_iter()
.map(|(path, (scope, access))| Rule {
path,
scope,
access,
})
.collect(),
writes: writes
.into_iter()
.map(|(path, (scope, access))| WriteRule {
path,
scope,
access,
})
.collect(),
})
}
}
fn unsupported(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::Unsupported, message.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intermediate_guards_do_not_repropagate_the_writable_tree() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().to_owned();
let parent = root.join("nested");
let protected = parent.join("protected");
std::fs::create_dir_all(&protected).unwrap();
let executable = std::env::current_exe().unwrap();
let plan = Plan::compile(
&Policy {
default: Access::Read,
rules: vec![
Rule::subtree(&root, Access::Write),
Rule::subtree(&protected, Access::Read),
],
deny_globs: Vec::new(),
},
&executable,
&executable,
&root,
)
.unwrap();
let root_write = plan.writes.iter().find(|rule| rule.path == root).unwrap();
assert_eq!(root_write.scope, Scope::Subtree);
assert!(matches!(root_write.access, WriteAccess::Preserve));
let parent_write = plan.writes.iter().find(|rule| rule.path == parent).unwrap();
assert_eq!(parent_write.scope, Scope::Exact);
assert!(matches!(parent_write.access, WriteAccess::Preserve));
let parent_read = plan.reads.iter().find(|rule| rule.path == parent).unwrap();
assert_eq!(parent_read.scope, Scope::Exact);
}
}