Skip to content

Commit 80f724f

Browse files
authored
harden file writes and reject empty kv/queue capability values (#35)
1 parent 94adf9c commit 80f724f

2 files changed

Lines changed: 147 additions & 0 deletions

File tree

cli/provenact-cli/src/fileio.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,58 @@ pub fn read_file_limited(
3535
}
3636

3737
pub fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
38+
match fs::symlink_metadata(path) {
39+
Ok(meta) if meta.file_type().is_symlink() => {
40+
return Err(format!(
41+
"refusing to write through symlink: {}",
42+
path.display()
43+
))
44+
}
45+
Ok(_) => {}
46+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
47+
Err(err) => return Err(format!("{}: {err}", path.display())),
48+
}
3849
fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display()))
3950
}
51+
52+
#[cfg(test)]
53+
mod tests {
54+
use super::*;
55+
use std::path::PathBuf;
56+
use std::time::{SystemTime, UNIX_EPOCH};
57+
58+
fn test_temp_dir(prefix: &str) -> PathBuf {
59+
let mut path = std::env::temp_dir();
60+
let nonce = SystemTime::now()
61+
.duration_since(UNIX_EPOCH)
62+
.map(|duration| duration.as_nanos())
63+
.unwrap_or(0);
64+
path.push(format!("{prefix}-{}-{nonce}", std::process::id()));
65+
fs::create_dir_all(&path).expect("temp dir should be created");
66+
path
67+
}
68+
69+
#[test]
70+
fn write_file_writes_regular_paths() {
71+
let dir = test_temp_dir("provenact-fileio-write");
72+
let path = dir.join("regular.txt");
73+
write_file(&path, b"hello").expect("regular path write should succeed");
74+
assert_eq!(fs::read(&path).expect("read"), b"hello");
75+
let _ = fs::remove_dir_all(&dir);
76+
}
77+
78+
#[cfg(unix)]
79+
#[test]
80+
fn write_file_rejects_symlink_targets() {
81+
let dir = test_temp_dir("provenact-fileio-symlink");
82+
let target = dir.join("target.txt");
83+
let link = dir.join("link.txt");
84+
fs::write(&target, b"original").expect("target write");
85+
std::os::unix::fs::symlink(&target, &link).expect("symlink create");
86+
87+
let err = write_file(&link, b"replacement").expect_err("symlink writes must fail");
88+
assert!(err.contains("refusing to write through symlink"));
89+
assert_eq!(fs::read(&target).expect("target read"), b"original");
90+
let _ = fs::remove_dir_all(&dir);
91+
}
92+
}

core/verifier/src/lib.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,9 @@ fn is_capability_allowed(capability: &Capability, ceiling: &CapabilityCeiling) -
973973
"time.now" => !capability.value.is_empty() && ceiling.time.unwrap_or(false),
974974
"random.bytes" => !capability.value.is_empty() && ceiling.random.unwrap_or(false),
975975
"kv.read" => {
976+
if capability.value.is_empty() {
977+
return false;
978+
}
976979
let Some(kv) = &ceiling.kv else {
977980
return false;
978981
};
@@ -984,6 +987,9 @@ fn is_capability_allowed(capability: &Capability, ceiling: &CapabilityCeiling) -
984987
.any(|item| item == "*" || item == &capability.value)
985988
}
986989
"kv.write" => {
990+
if capability.value.is_empty() {
991+
return false;
992+
}
987993
let Some(kv) = &ceiling.kv else {
988994
return false;
989995
};
@@ -995,6 +1001,9 @@ fn is_capability_allowed(capability: &Capability, ceiling: &CapabilityCeiling) -
9951001
.any(|item| item == "*" || item == &capability.value)
9961002
}
9971003
"queue.publish" => {
1004+
if capability.value.is_empty() {
1005+
return false;
1006+
}
9981007
let Some(queue) = &ceiling.queue else {
9991008
return false;
10001009
};
@@ -1006,6 +1015,9 @@ fn is_capability_allowed(capability: &Capability, ceiling: &CapabilityCeiling) -
10061015
.any(|item| item == "*" || item == &capability.value)
10071016
}
10081017
"queue.consume" => {
1018+
if capability.value.is_empty() {
1019+
return false;
1020+
}
10091021
let Some(queue) = &ceiling.queue else {
10101022
return false;
10111023
};
@@ -1171,13 +1183,23 @@ fn validate_policy_constraints(policy: &Policy) -> Result<(), VerifyError> {
11711183
}
11721184
if let Some(kv) = &ceiling.kv {
11731185
if let Some(read) = &kv.read {
1186+
if read.iter().any(String::is_empty) {
1187+
return Err(VerifyError::PolicyConstraint(
1188+
"capability_ceiling.kv.read items must be non-empty".to_string(),
1189+
));
1190+
}
11741191
if has_duplicates(read) {
11751192
return Err(VerifyError::PolicyConstraint(
11761193
"capability_ceiling.kv.read items must be unique".to_string(),
11771194
));
11781195
}
11791196
}
11801197
if let Some(write) = &kv.write {
1198+
if write.iter().any(String::is_empty) {
1199+
return Err(VerifyError::PolicyConstraint(
1200+
"capability_ceiling.kv.write items must be non-empty".to_string(),
1201+
));
1202+
}
11811203
if has_duplicates(write) {
11821204
return Err(VerifyError::PolicyConstraint(
11831205
"capability_ceiling.kv.write items must be unique".to_string(),
@@ -1187,13 +1209,23 @@ fn validate_policy_constraints(policy: &Policy) -> Result<(), VerifyError> {
11871209
}
11881210
if let Some(queue) = &ceiling.queue {
11891211
if let Some(publish) = &queue.publish {
1212+
if publish.iter().any(String::is_empty) {
1213+
return Err(VerifyError::PolicyConstraint(
1214+
"capability_ceiling.queue.publish items must be non-empty".to_string(),
1215+
));
1216+
}
11901217
if has_duplicates(publish) {
11911218
return Err(VerifyError::PolicyConstraint(
11921219
"capability_ceiling.queue.publish items must be unique".to_string(),
11931220
));
11941221
}
11951222
}
11961223
if let Some(consume) = &queue.consume {
1224+
if consume.iter().any(String::is_empty) {
1225+
return Err(VerifyError::PolicyConstraint(
1226+
"capability_ceiling.queue.consume items must be non-empty".to_string(),
1227+
));
1228+
}
11971229
if has_duplicates(consume) {
11981230
return Err(VerifyError::PolicyConstraint(
11991231
"capability_ceiling.queue.consume items must be unique".to_string(),
@@ -1756,6 +1788,68 @@ capability_ceiling:
17561788
));
17571789
}
17581790

1791+
#[test]
1792+
fn denies_empty_kv_and_queue_capability_values_even_with_wildcards() {
1793+
let policy = Policy {
1794+
version: 1,
1795+
trusted_signers: vec!["alice.dev".to_string()],
1796+
capability_ceiling: CapabilityCeiling {
1797+
fs: None,
1798+
net: None,
1799+
kv: Some(PolicyKv {
1800+
read: Some(vec!["*".to_string()]),
1801+
write: Some(vec!["*".to_string()]),
1802+
}),
1803+
queue: Some(PolicyQueue {
1804+
publish: Some(vec!["*".to_string()]),
1805+
consume: Some(vec!["*".to_string()]),
1806+
}),
1807+
env: None,
1808+
exec: Some(false),
1809+
time: Some(false),
1810+
random: Some(false),
1811+
},
1812+
};
1813+
let requested = vec![
1814+
Capability {
1815+
kind: "kv.read".to_string(),
1816+
value: String::new(),
1817+
},
1818+
Capability {
1819+
kind: "kv.write".to_string(),
1820+
value: String::new(),
1821+
},
1822+
Capability {
1823+
kind: "queue.publish".to_string(),
1824+
value: String::new(),
1825+
},
1826+
Capability {
1827+
kind: "queue.consume".to_string(),
1828+
value: String::new(),
1829+
},
1830+
];
1831+
assert!(matches!(
1832+
enforce_capability_ceiling(&requested, &policy),
1833+
Err(VerifyError::CapabilityDenied(_))
1834+
));
1835+
}
1836+
1837+
#[test]
1838+
fn denies_policy_with_empty_kv_or_queue_entries() {
1839+
let raw = br#"{
1840+
"version": 1,
1841+
"trusted_signers": ["alice.dev"],
1842+
"capability_ceiling": {
1843+
"kv": { "read": [""] },
1844+
"queue": { "publish": ["*"] }
1845+
}
1846+
}"#;
1847+
assert!(matches!(
1848+
parse_policy_document(raw),
1849+
Err(VerifyError::PolicyConstraint(_))
1850+
));
1851+
}
1852+
17591853
#[test]
17601854
fn denies_policy_net_prefix_with_query_or_fragment() {
17611855
let raw = br#"{

0 commit comments

Comments
 (0)