Skip to content

Commit 1053b84

Browse files
authored
fix(security): enforce trust boundary before UMLS deserialize_unchecked Refs #1733
1 parent 5244838 commit 1053b84

2 files changed

Lines changed: 86 additions & 22 deletions

File tree

crates/terraphim_automata/src/medical_artifact.rs

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -104,22 +104,31 @@ pub fn save_umls_artifact(
104104

105105
/// Load a UMLS artifact: returns (header, shard_bytes_list)
106106
pub fn load_umls_artifact(path: &Path) -> anyhow::Result<(ArtifactHeader, Vec<Vec<u8>>)> {
107-
// Advisory permission check: a world-writable artifact file allows an
108-
// attacker to forge both bytes and stored checksums together, bypassing
109-
// the integrity gate. Log a warning but do not abort — this is P2
110-
// advisory (see Gitea #1587). Binding this to Unix keeps Windows builds clean.
107+
// Permission enforcement: any write bit beyond the owner is a hard
108+
// error. An attacker who can write the artifact can forge both bytes
109+
// and stored checksums together, bypassing the integrity gate. The
110+
// checksum guard is only effective when the file is owner-writable
111+
// exclusively. Binding this to Unix keeps Windows builds clean;
112+
// Windows callers should apply ACLs externally.
111113
#[cfg(unix)]
112114
{
113115
use std::os::unix::fs::PermissionsExt;
114116
if let Ok(meta) = std::fs::metadata(path) {
115117
let mode = meta.permissions().mode();
118+
let mut reasons = Vec::new();
116119
if mode & 0o002 != 0 {
117-
log::warn!(
118-
"Artifact {:?} is world-writable (mode {:04o}). \
119-
An attacker with filesystem write access could forge a \
120-
valid artifact and bypass checksum verification.",
120+
reasons.push("world-writable".to_string());
121+
}
122+
if mode & 0o020 != 0 {
123+
reasons.push("group-writable".to_string());
124+
}
125+
if !reasons.is_empty() {
126+
anyhow::bail!(
127+
"Artifact {:?} has insecure permissions (mode {:04o}): {}. \
128+
Restrict to owner-only write (e.g. chmod 600) and re-save.",
121129
path,
122-
mode
130+
mode,
131+
reasons.join(", ")
123132
);
124133
}
125134
}
@@ -290,15 +299,13 @@ mod tests {
290299
assert!(msg.contains("checksum mismatch"), "error: {}", msg);
291300
}
292301

293-
/// Verify that a world-writable artifact still loads successfully.
302+
/// Verify that a world-writable artifact is rejected as a hard error.
294303
///
295-
/// The permission check is advisory (P2, Gitea #1587): it emits a log
296-
/// warning but does not abort loading. The checksum gate still protects
297-
/// against _tampered_ bytes; the warning alerts operators to tighten
298-
/// filesystem permissions.
304+
/// An attacker who can write the file can forge both bytes and
305+
/// checksums. The permission gate rejects before any data is read.
299306
#[test]
300307
#[cfg(unix)]
301-
fn test_world_writable_artifact_loads_ok() {
308+
fn test_world_writable_artifact_rejected() {
302309
use std::os::unix::fs::PermissionsExt;
303310
let dir = tempdir().unwrap();
304311
let path = dir.path().join("world_writable.bin.zst");
@@ -312,11 +319,63 @@ mod tests {
312319
perms.set_mode(0o666);
313320
std::fs::set_permissions(&path, perms).unwrap();
314321

315-
// Loading must succeed — the warning is advisory, not fatal
322+
let result = load_umls_artifact(&path);
323+
assert!(result.is_err(), "World-writable artifact must be rejected");
324+
let msg = result.err().unwrap().to_string();
325+
assert!(
326+
msg.contains("world-writable"),
327+
"error must mention world-writable, got: {msg}"
328+
);
329+
}
330+
331+
/// Verify that a group-writable artifact is rejected.
332+
#[test]
333+
#[cfg(unix)]
334+
fn test_group_writable_artifact_rejected() {
335+
use std::os::unix::fs::PermissionsExt;
336+
let dir = tempdir().unwrap();
337+
let path = dir.path().join("group_writable.bin.zst");
338+
339+
let shard_bytes = vec![vec![1u8; 10], vec![2u8; 8]];
340+
let header = make_test_header(&shard_bytes);
341+
save_umls_artifact(&header, &shard_bytes, &path).unwrap();
342+
343+
// Make group-writable (0o620)
344+
let mut perms = std::fs::metadata(&path).unwrap().permissions();
345+
perms.set_mode(0o620);
346+
std::fs::set_permissions(&path, perms).unwrap();
347+
348+
let result = load_umls_artifact(&path);
349+
assert!(result.is_err(), "Group-writable artifact must be rejected");
350+
let msg = result.err().unwrap().to_string();
351+
assert!(
352+
msg.contains("group-writable"),
353+
"error must mention group-writable, got: {msg}"
354+
);
355+
}
356+
357+
/// Verify that an artifact with secure owner-only permissions (0o600)
358+
/// loads successfully.
359+
#[test]
360+
#[cfg(unix)]
361+
fn test_secure_permissions_artifact_loads_ok() {
362+
use std::os::unix::fs::PermissionsExt;
363+
let dir = tempdir().unwrap();
364+
let path = dir.path().join("secure.bin.zst");
365+
366+
let shard_bytes = vec![vec![1u8; 10], vec![2u8; 8]];
367+
let header = make_test_header(&shard_bytes);
368+
save_umls_artifact(&header, &shard_bytes, &path).unwrap();
369+
370+
// Secure: owner read+write only
371+
let mut perms = std::fs::metadata(&path).unwrap().permissions();
372+
perms.set_mode(0o600);
373+
std::fs::set_permissions(&path, perms).unwrap();
374+
316375
let result = load_umls_artifact(&path);
317376
assert!(
318377
result.is_ok(),
319-
"World-writable artifact must still load (advisory warning only); got: {:?}",
378+
"Owner-writable-only artifact must load; got: {:?}",
320379
result.err()
321380
);
322381
let (loaded_header, loaded_shards) = result.unwrap();

crates/terraphim_automata/src/sharded_extractor.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,16 @@ impl ShardedUmlsExtractor {
214214
let shards: Vec<DoubleArrayAhoCorasick<u32>> = shard_bytes
215215
.iter()
216216
.map(|bytes| {
217-
// SAFETY: bytes were produced by `DoubleArrayAhoCorasick::serialize()` on
218-
// this machine and have passed SHA-256 checksum verification inside
219-
// `load_umls_artifact()` before reaching this point. The checksum gate
220-
// guarantees the bytes are bit-for-bit identical to what `serialize()`
221-
// wrote; no tampered or externally-sourced bytes can reach this call.
217+
// SAFETY: bytes have passed two independent gates inside
218+
// `load_umls_artifact()` before reaching this point:
219+
// 1. Permission enforcement — the artifact file is owner-writable
220+
// exclusively (world/group write bits rejected). An attacker
221+
// who cannot write the file cannot forge its contents.
222+
// 2. SHA-256 checksum verification — every shard's decompressed
223+
// bytes match the digest stored in the header. Together with
224+
// gate (1), this guarantees the bytes are bit-for-bit
225+
// identical to what `serialize()` wrote; tampered or
226+
// externally-sourced bytes cannot reach this call.
222227
let (automaton, _remaining) =
223228
unsafe { DoubleArrayAhoCorasick::<u32>::deserialize_unchecked(bytes) };
224229
automaton

0 commit comments

Comments
 (0)