fix: enforce delegated workdir scope after resolution
This commit is contained in:
@@ -15,20 +15,23 @@ use crate::{
|
||||
WorkdirSessionHandle,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkdirDelegationPermission {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkdirDelegationRule {
|
||||
pub target: FsPath,
|
||||
pub permission: WorkdirDelegationPermission,
|
||||
pub recursive: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkdirDelegationRequest {
|
||||
pub rules: Vec<WorkdirDelegationRule>,
|
||||
pub cwd: FsPath,
|
||||
@@ -101,6 +104,7 @@ struct ActiveWriteLease {
|
||||
|
||||
struct DelegatingWorkdirSession {
|
||||
source: WorkdirSessionHandle,
|
||||
cwd: FsPath,
|
||||
scope: Option<Vec<WorkdirDelegationRule>>,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
validity: Arc<SessionValidity>,
|
||||
@@ -125,6 +129,7 @@ pub fn delegation_capable_session(source: WorkdirSessionHandle) -> WorkdirSessio
|
||||
let capabilities = source.capabilities();
|
||||
Arc::new(DelegatingWorkdirSession {
|
||||
source,
|
||||
cwd: FsPath::new("").expect("empty Workdir path is valid"),
|
||||
scope: None,
|
||||
capabilities,
|
||||
validity: SessionValidity::root(),
|
||||
@@ -180,6 +185,17 @@ impl DelegatingWorkdirSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
|
||||
if self.cwd.as_str().is_empty() {
|
||||
return Ok(path.clone());
|
||||
}
|
||||
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
|
||||
let joined = joined.to_str().ok_or_else(|| {
|
||||
WorkdirError::Denied("logical Workdir path is not valid UTF-8".into())
|
||||
})?;
|
||||
FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string()))
|
||||
}
|
||||
|
||||
fn ensure_read(
|
||||
&self,
|
||||
path: &FsPath,
|
||||
@@ -308,14 +324,17 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
||||
true
|
||||
}
|
||||
|
||||
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
async fn capture_delegation_source(
|
||||
&self,
|
||||
request: &WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
self.ensure_active()?;
|
||||
if self.scope.is_some() {
|
||||
return Err(WorkdirError::Denied(
|
||||
"scoped Workdir sessions cannot expose their provider source".into(),
|
||||
));
|
||||
}
|
||||
self.source.capture_delegation_source().await
|
||||
self.source.capture_delegation_source(request).await
|
||||
}
|
||||
|
||||
async fn delegate(
|
||||
@@ -333,7 +352,7 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
||||
request.cwd
|
||||
)));
|
||||
}
|
||||
let source = self.source.capture_delegation_source().await?;
|
||||
let source = self.source.capture_delegation_source(&request).await?;
|
||||
let validity = SessionValidity::child(self.validity.clone());
|
||||
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
|
||||
if request
|
||||
@@ -354,6 +373,7 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
||||
}
|
||||
let child: WorkdirSessionHandle = Arc::new(DelegatingWorkdirSession {
|
||||
source,
|
||||
cwd: request.cwd,
|
||||
scope: Some(request.rules),
|
||||
capabilities,
|
||||
validity: validity.clone(),
|
||||
@@ -374,37 +394,44 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
||||
})
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
request.path = self.resolve_path(&request.path)?;
|
||||
self.ensure_read(&request.path, WorkdirSessionCapability::Read)?;
|
||||
self.source.stat(request).await
|
||||
}
|
||||
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
request.path = self.resolve_path(&request.path)?;
|
||||
self.ensure_read(&request.path, WorkdirSessionCapability::Read)?;
|
||||
self.source.read(request).await
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
request.path = self.resolve_path(&request.path)?;
|
||||
self.ensure_write(&request.path, WorkdirSessionCapability::Write)?;
|
||||
self.source.write(request).await
|
||||
}
|
||||
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
request.path = self.resolve_path(&request.path)?;
|
||||
self.ensure_write(&request.path, WorkdirSessionCapability::Edit)?;
|
||||
self.source.edit(request).await
|
||||
}
|
||||
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
request.path = self.resolve_path(&request.path)?;
|
||||
self.ensure_read(&request.path, WorkdirSessionCapability::Read)?;
|
||||
self.source.list(request).await
|
||||
}
|
||||
|
||||
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
request.path = self.resolve_path(&request.path)?;
|
||||
self.ensure_read(&request.path, WorkdirSessionCapability::Glob)?;
|
||||
self.source.glob(request).await
|
||||
}
|
||||
|
||||
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
request.path = self.resolve_path(&request.path)?;
|
||||
self.ensure_read(&request.path, WorkdirSessionCapability::Grep)?;
|
||||
self.source.grep(request).await
|
||||
}
|
||||
@@ -571,7 +598,10 @@ fn rule_contains_rule(parent: &WorkdirDelegationRule, child: &WorkdirDelegationR
|
||||
if !path_in_rule(parent, &child.target) {
|
||||
return false;
|
||||
}
|
||||
parent.recursive || !child.recursive
|
||||
if parent.recursive {
|
||||
return true;
|
||||
}
|
||||
!child.recursive && parent.target == child.target
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -650,7 +680,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_only_delegation_allows_prefix_and_denies_siblings_and_mutation() {
|
||||
async fn read_only_delegation_allows_prefix_and_denies_mutation() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("docs")).unwrap();
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
@@ -666,18 +696,14 @@ mod tests {
|
||||
assert_eq!(
|
||||
child
|
||||
.scoped_session
|
||||
.read(read("docs/readme.md"))
|
||||
.read(read("readme.md"))
|
||||
.await
|
||||
.unwrap()
|
||||
.bytes,
|
||||
b"visible"
|
||||
);
|
||||
assert!(matches!(
|
||||
child.scoped_session.read(read("secret/key")).await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
child.scoped_session.write(write("docs/new.md", "no")).await,
|
||||
child.scoped_session.write(write("new.md", "no")).await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
assert!(
|
||||
@@ -687,6 +713,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_scope_denies_read_through_symlink_outside_grant() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
fs::write(root.path().join("secret/key"), "hidden").unwrap();
|
||||
symlink("../secret/key", root.path().join("granted/link")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let child = parent
|
||||
.delegate(request("granted", WorkdirDelegationPermission::Read))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = child.scoped_session.read(read("link")).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"symlink read escaped provider scope: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn provider_scope_denies_write_through_symlink_outside_grant() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let child = parent
|
||||
.delegate(request("granted", WorkdirDelegationPermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = child
|
||||
.scoped_session
|
||||
.write(write("outside/new", "forbidden"))
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"symlink write escaped provider scope: {result:?}"
|
||||
);
|
||||
assert!(!root.path().join("secret/new").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_lease_blocks_parent_region_until_release() {
|
||||
let root = TempDir::new().unwrap();
|
||||
@@ -705,7 +780,7 @@ mod tests {
|
||||
parent.write(write("other/file", "parent")).await.unwrap();
|
||||
child
|
||||
.scoped_session
|
||||
.write(write("leased/file", "child"))
|
||||
.write(write("file", "child"))
|
||||
.await
|
||||
.unwrap();
|
||||
child.release();
|
||||
@@ -714,7 +789,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
child.scoped_session.read(read("leased/file")).await,
|
||||
child.scoped_session.read(read("file")).await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
));
|
||||
}
|
||||
@@ -737,15 +812,14 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
nested
|
||||
.scoped_session
|
||||
.read(read("docs/sub/a"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
nested.scoped_session.read(read("docs/peer/b")).await,
|
||||
Err(WorkdirError::Denied(_))
|
||||
));
|
||||
nested.scoped_session.read(read("a")).await.unwrap();
|
||||
assert!(
|
||||
child
|
||||
.scoped_session
|
||||
.delegate(request("other", WorkdirDelegationPermission::Read))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
child
|
||||
.scoped_session
|
||||
@@ -756,7 +830,7 @@ mod tests {
|
||||
|
||||
child.release();
|
||||
assert!(matches!(
|
||||
nested.scoped_session.read(read("docs/sub/a")).await,
|
||||
nested.scoped_session.read(read("a")).await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
));
|
||||
}
|
||||
@@ -774,7 +848,7 @@ mod tests {
|
||||
|
||||
parent.close().await.unwrap();
|
||||
assert!(matches!(
|
||||
child.scoped_session.read(read("docs/a")).await,
|
||||
child.scoped_session.read(read("a")).await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -68,6 +68,15 @@ pub enum WorkdirSessionOperation {
|
||||
CommandCancel(CommandHandle),
|
||||
}
|
||||
|
||||
/// Wire envelope for an operation and its optional provider-enforced child scope.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkdirSessionOperationRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delegation: Option<crate::WorkdirDelegationRequest>,
|
||||
pub operation: WorkdirSessionOperation,
|
||||
}
|
||||
|
||||
/// Typed result paired with [`WorkdirSessionOperation`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
||||
@@ -207,6 +216,7 @@ mod client {
|
||||
workdir: Workdir,
|
||||
session_id: WorkdirSessionId,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
delegation: Option<crate::WorkdirDelegationRequest>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
@@ -259,6 +269,7 @@ mod client {
|
||||
workdir: Workdir::new(opened.workdir_id.as_str()),
|
||||
session_id: opened.session_id,
|
||||
capabilities: opened.capabilities,
|
||||
delegation: None,
|
||||
closed: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
@@ -285,6 +296,10 @@ mod client {
|
||||
"operations",
|
||||
],
|
||||
)?;
|
||||
let operation = WorkdirSessionOperationRequest {
|
||||
delegation: self.delegation.clone(),
|
||||
operation,
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
@@ -313,7 +328,10 @@ mod client {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
async fn capture_delegation_source(
|
||||
&self,
|
||||
request: &crate::WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(WorkdirError::SessionClosed);
|
||||
}
|
||||
@@ -324,6 +342,7 @@ mod client {
|
||||
workdir: self.workdir.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
capabilities: self.capabilities,
|
||||
delegation: Some(request.clone()),
|
||||
closed: AtomicBool::new(false),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -151,7 +151,10 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
/// Capture a provider-specific source for a delegated child session.
|
||||
/// Remote providers use this boundary to pin attachment identity without
|
||||
/// exposing transport handles or host paths.
|
||||
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
async fn capture_delegation_source(
|
||||
&self,
|
||||
_request: &WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
Err(WorkdirError::Denied(
|
||||
"workdir provider does not support delegated sessions".into(),
|
||||
))
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use manifest::{Scope, SharedScope};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
@@ -28,9 +28,10 @@ use tokio::task::JoinHandle;
|
||||
use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||
WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle,
|
||||
WriteRequest, WriteResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
|
||||
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
|
||||
WriteResult,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::{EntryKind, WriteOutcome};
|
||||
@@ -371,8 +372,46 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
self.inner.capabilities
|
||||
}
|
||||
|
||||
async fn capture_delegation_source(&self) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
Ok(Arc::new(self.clone()))
|
||||
async fn capture_delegation_source(
|
||||
&self,
|
||||
request: &WorkdirDelegationRequest,
|
||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
||||
let host_rules = request
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| ScopeRule {
|
||||
target: self.inner.root.join(rule.target.as_str()),
|
||||
permission: match rule.permission {
|
||||
WorkdirDelegationPermission::Read => Permission::Read,
|
||||
WorkdirDelegationPermission::Write => Permission::Write,
|
||||
},
|
||||
recursive: rule.recursive,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let parent_scope = self.inner.scope.snapshot();
|
||||
for rule in &host_rules {
|
||||
if !parent_scope
|
||||
.allows_rule(rule)
|
||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?
|
||||
{
|
||||
return Err(WorkdirError::Denied(format!(
|
||||
"delegated provider scope `{}` exceeds the parent session",
|
||||
rule.target.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let child_scope = Scope::from_config(&ScopeConfig {
|
||||
allow: host_rules,
|
||||
deny: Vec::new(),
|
||||
})
|
||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
|
||||
Ok(Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
self.inner.workdir.clone(),
|
||||
self.inner.root.clone(),
|
||||
self.inner.cwd.clone(),
|
||||
SharedScope::new(child_scope),
|
||||
self.inner.capabilities,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
|
||||
@@ -320,6 +320,8 @@ mod tests {
|
||||
pub struct WorkspaceWorkdirSessionOperationRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_session_fence: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delegation: Option<crate::WorkdirDelegationRequest>,
|
||||
pub operation: crate::http::WorkdirSessionOperation,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user