Skip to content

Commit 67e06ff

Browse files
committed
Harden snapshot store admission
1 parent c69bd62 commit 67e06ff

20 files changed

Lines changed: 412 additions & 39 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ behavior when the change improves security or project direction.
5454
for every byte received from a trusted proxy.
5555
- Refresh stream idle deadlines after every successful partial write so active
5656
backpressured connections are not terminated between write progress events.
57+
- Reject filesystem roots and pre-existing non-private snapshot directories
58+
without changing their permissions; only newly created dedicated snapshot
59+
directories are initialized as `0700`.
60+
- Reject serialized snapshots above the reader's 16 MiB limit before store
61+
locking, generation allocation, or publication.
62+
- Bound persisted self-healing state to 64 KiB and its variable diagnostics to
63+
4 KiB without control characters, using the same limit for reads and writes.
5764

5865
## 1.7.11 - 2026-07-14
5966

crates/fluxheim-config/src/config_admin.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ pub use crate::config_admin_transport::{
1414
AdminClientCertificateConfig, AdminClientCertificateConfigFragment, AdminRemoteTransportMode,
1515
AdminTransportConfig, AdminTransportConfigFragment,
1616
};
17-
use crate::config_path::{validate_non_world_writable_parent, validate_path};
17+
use crate::config_path::{
18+
validate_non_world_writable_parent, validate_path, validate_private_state_directory,
19+
};
1820

1921
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
2022
#[serde(deny_unknown_fields)]
@@ -167,6 +169,7 @@ impl AdminConfig {
167169
)?;
168170
validate_path("admin.token_file", self.token_file.as_deref())?;
169171
validate_path("admin.snapshot_store", self.snapshot_store.as_deref())?;
172+
validate_private_state_directory("admin.snapshot_store", self.snapshot_store.as_deref())?;
170173
validate_path(
171174
"admin.snapshot_integrity_key_file",
172175
self.snapshot_integrity_key_file.as_deref(),

crates/fluxheim-config/src/config_path.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::path::Component;
12
use std::path::{Path, PathBuf};
23

34
use crate::config::ConfigError;
@@ -63,6 +64,56 @@ pub fn validate_non_world_writable_parent(
6364
Ok(())
6465
}
6566

67+
pub fn validate_private_state_directory(
68+
field: impl Into<String>,
69+
path: Option<&Path>,
70+
) -> Result<(), ConfigError> {
71+
let field = field.into();
72+
let Some(path) = path else {
73+
return Ok(());
74+
};
75+
if path_is_filesystem_root(path) {
76+
return Err(ConfigError::UnsafePath {
77+
field,
78+
path: path.to_path_buf(),
79+
});
80+
}
81+
82+
let metadata = match path.symlink_metadata() {
83+
Ok(metadata) => metadata,
84+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
85+
Err(error) => return Err(path_inspection_failed(field, path, error)),
86+
};
87+
if metadata.file_type().is_symlink() || !metadata.is_dir() {
88+
return Err(ConfigError::UnsafePath {
89+
field,
90+
path: path.to_path_buf(),
91+
});
92+
}
93+
#[cfg(unix)]
94+
{
95+
use std::os::unix::fs::PermissionsExt as _;
96+
if metadata.permissions().mode() & 0o077 != 0 {
97+
return Err(ConfigError::UnsafePath {
98+
field,
99+
path: path.to_path_buf(),
100+
});
101+
}
102+
}
103+
Ok(())
104+
}
105+
106+
fn path_is_filesystem_root(path: &Path) -> bool {
107+
let mut components = path.components();
108+
match components.next() {
109+
Some(Component::RootDir) => components.next().is_none(),
110+
Some(Component::Prefix(_)) => {
111+
matches!(components.next(), Some(Component::RootDir)) && components.next().is_none()
112+
}
113+
_ => false,
114+
}
115+
}
116+
66117
pub fn validate_optional_process_path(
67118
field: &'static str,
68119
path: Option<&Path>,

crates/fluxheim-config/src/config_tests_admin_security.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,38 @@ fn rejects_admin_paths_under_world_writable_parent() {
148148
));
149149
}
150150

151+
#[cfg(unix)]
152+
#[test]
153+
fn rejects_existing_non_private_or_root_snapshot_store() {
154+
use std::os::unix::fs::PermissionsExt as _;
155+
156+
let snapshot_store = secure_test_dir("config-admin-snapshot-non-private");
157+
std::fs::set_permissions(&snapshot_store, std::fs::Permissions::from_mode(0o755)).unwrap();
158+
let config = Config {
159+
admin: AdminConfig {
160+
snapshot_store: Some(snapshot_store),
161+
..AdminConfig::default()
162+
},
163+
..Config::default()
164+
};
165+
assert!(matches!(
166+
config.validate(),
167+
Err(ConfigError::UnsafePath { field, .. }) if field == "admin.snapshot_store"
168+
));
169+
170+
let root = Config {
171+
admin: AdminConfig {
172+
snapshot_store: Some("/".into()),
173+
..AdminConfig::default()
174+
},
175+
..Config::default()
176+
};
177+
assert!(matches!(
178+
root.validate(),
179+
Err(ConfigError::UnsafePath { field, .. }) if field == "admin.snapshot_store"
180+
));
181+
}
182+
151183
#[test]
152184
fn rejects_invalid_admin_self_healing_window() {
153185
let config = Config {

crates/fluxheim-snapshot/src/operations_tests.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use fluxheim_common::test_support::{safe_child_path, unique_temp_path};
22
use fluxheim_config::{Config, ProxyConfig};
33
use std::sync::{Arc, Barrier};
44

5+
use crate::recovery::{MAX_RUNTIME_DIAGNOSTIC_BYTES, MAX_RUNTIME_STATE_BYTES};
56
use crate::store::MAX_SNAPSHOT_STORE_ENTRIES;
67
#[cfg(unix)]
78
use crate::store_fs::set_open_private_file_mode_for_test;
@@ -50,6 +51,7 @@ fn concurrent_snapshot_creation_cannot_exceed_capacity() {
5051
let dir = TestDir::new("snapshot-concurrent-capacity");
5152
let configs = dir.child("configs");
5253
std::fs::create_dir(&configs).unwrap();
54+
set_private_test_directory(&configs);
5355
for index in 0..MAX_SNAPSHOT_STORE_ENTRIES - 1 {
5456
std::fs::write(
5557
safe_child_path(&configs, &format!("legacy-{index:04}.toml")),
@@ -378,6 +380,61 @@ fn self_healing_state_persists_pending_and_failed_rollback() {
378380
assert!(restored_pending.last_rollback_failure.is_some());
379381
}
380382

383+
#[test]
384+
fn recovery_diagnostic_limits_preserve_previous_state() {
385+
let dir = TestDir::new("snapshot-recovery-diagnostic-limit");
386+
let store = SnapshotStore::new(dir.path());
387+
let snapshot = store.snapshot_config(&Config::default(), None).unwrap();
388+
let valid = SnapshotRuntimeState {
389+
runtime_snapshot: Some(snapshot.id.clone()),
390+
known_good_snapshot: Some(snapshot.id.clone()),
391+
pending_validation: None,
392+
};
393+
store.save_runtime_state(&valid).unwrap();
394+
let state_path = store.runtime_state_path();
395+
let before = std::fs::read(&state_path).unwrap();
396+
397+
for (impact, failure) in [
398+
("x".repeat(MAX_RUNTIME_DIAGNOSTIC_BYTES + 1), None),
399+
("invalid\nimpact".to_owned(), None),
400+
(
401+
"snapshot".to_owned(),
402+
Some("x".repeat(MAX_RUNTIME_DIAGNOSTIC_BYTES + 1)),
403+
),
404+
] {
405+
let invalid = SnapshotRuntimeState {
406+
runtime_snapshot: Some(snapshot.id.clone()),
407+
known_good_snapshot: Some(snapshot.id.clone()),
408+
pending_validation: Some(PendingValidation {
409+
target_snapshot: snapshot.id.clone(),
410+
previous_snapshot: Some(snapshot.id.clone()),
411+
impact,
412+
expires_unix_secs: 100,
413+
successful_checks: 0,
414+
failed_checks: 0,
415+
rollback_attempts: 0,
416+
last_rollback_failure: failure,
417+
}),
418+
};
419+
assert!(store.save_runtime_state(&invalid).is_err());
420+
assert_eq!(std::fs::read(&state_path).unwrap(), before);
421+
}
422+
}
423+
424+
#[test]
425+
fn recovery_reader_rejects_file_above_dedicated_limit() {
426+
let dir = TestDir::new("snapshot-recovery-file-limit");
427+
let store = SnapshotStore::new(dir.path());
428+
store.snapshot_config(&Config::default(), None).unwrap();
429+
write_atomically(
430+
&store.runtime_state_path(),
431+
&vec![b'x'; (MAX_RUNTIME_STATE_BYTES + 1) as usize],
432+
)
433+
.unwrap();
434+
435+
assert!(store.load_runtime_state().is_err());
436+
}
437+
381438
#[test]
382439
fn failed_create_new_atomic_write_removes_temporary_file() {
383440
let dir = TestDir::new("snapshot-temp-cleanup");
@@ -451,6 +508,7 @@ impl TestDir {
451508
fn new(name: &str) -> Self {
452509
let path = unique_temp_path(name);
453510
std::fs::create_dir(&path).unwrap();
511+
set_private_test_directory(&path);
454512
Self { path }
455513
}
456514

@@ -463,6 +521,16 @@ impl TestDir {
463521
}
464522
}
465523

524+
fn set_private_test_directory(path: &std::path::Path) {
525+
#[cfg(unix)]
526+
{
527+
use std::os::unix::fs::PermissionsExt as _;
528+
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
529+
}
530+
#[cfg(not(unix))]
531+
let _ = path;
532+
}
533+
466534
impl Drop for TestDir {
467535
fn drop(&mut self) {
468536
let _ = std::fs::remove_dir_all(&self.path);

crates/fluxheim-snapshot/src/recovery.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
use crate::metadata::validate_snapshot_id;
22
use crate::state::SnapshotRuntimeState;
33
use crate::store::{SnapshotError, SnapshotStore};
4-
use crate::store_fs::{read_optional_regular_file_to_string, write_atomically};
4+
use crate::store_fs::{read_optional_regular_file_to_string_with_limit, write_atomically};
55
use serde::{Deserialize, Serialize};
66

7+
pub(crate) const MAX_RUNTIME_STATE_BYTES: u64 = 64 * 1024;
8+
pub(crate) const MAX_RUNTIME_DIAGNOSTIC_BYTES: usize = 4096;
9+
710
#[derive(Debug, Deserialize, Serialize)]
811
#[serde(deny_unknown_fields)]
912
struct PersistedRuntimeState {
@@ -17,11 +20,16 @@ struct PersistedRuntimeState {
1720
impl SnapshotStore {
1821
pub fn load_runtime_state(&self) -> Result<Option<SnapshotRuntimeState>, SnapshotError> {
1922
self.validate_for_recovery()?;
20-
let Some(raw) = read_optional_regular_file_to_string(&self.runtime_state_path())? else {
23+
let Some(raw) = read_optional_regular_file_to_string_with_limit(
24+
&self.runtime_state_path(),
25+
MAX_RUNTIME_STATE_BYTES,
26+
)?
27+
else {
2128
return Ok(None);
2229
};
2330
let persisted: PersistedRuntimeState =
2431
toml::from_str(&raw).map_err(SnapshotError::Decode)?;
32+
require_runtime_state_size(&persisted.state_toml)?;
2533
match self.integrity.as_deref() {
2634
Some(key)
2735
if persisted.key_id.as_deref() == Some(key.key_id())
@@ -41,6 +49,7 @@ impl SnapshotStore {
4149
pub fn save_runtime_state(&self, state: &SnapshotRuntimeState) -> Result<(), SnapshotError> {
4250
validate_runtime_state(state)?;
4351
let state_toml = toml::to_string_pretty(state).map_err(SnapshotError::Encode)?;
52+
require_runtime_state_size(&state_toml)?;
4453
let persisted = match self.integrity.as_deref() {
4554
Some(key) => PersistedRuntimeState {
4655
hmac_sha256: Some(key.sign_recovery(state_toml.as_bytes())?),
@@ -54,6 +63,7 @@ impl SnapshotStore {
5463
},
5564
};
5665
let raw = toml::to_string_pretty(&persisted).map_err(SnapshotError::Encode)?;
66+
require_runtime_state_size(&raw)?;
5767
self.with_store_lock(|| write_atomically(&self.runtime_state_path(), raw.as_bytes()))
5868
}
5969

@@ -84,5 +94,37 @@ fn validate_runtime_state(state: &SnapshotRuntimeState) -> Result<(), SnapshotEr
8494
{
8595
validate_snapshot_id(id)?;
8696
}
97+
if let Some(pending) = state.pending_validation.as_ref() {
98+
validate_runtime_diagnostic(&pending.impact)?;
99+
if let Some(failure) = pending.last_rollback_failure.as_deref() {
100+
validate_runtime_diagnostic(failure)?;
101+
}
102+
}
103+
Ok(())
104+
}
105+
106+
fn validate_runtime_diagnostic(value: &str) -> Result<(), SnapshotError> {
107+
if value.len() > MAX_RUNTIME_DIAGNOSTIC_BYTES || value.chars().any(char::is_control) {
108+
return Err(SnapshotError::Io(std::io::Error::new(
109+
std::io::ErrorKind::InvalidInput,
110+
"snapshot recovery diagnostic is invalid or exceeds its configured limit",
111+
)));
112+
}
113+
Ok(())
114+
}
115+
116+
fn require_runtime_state_size(raw: &str) -> Result<(), SnapshotError> {
117+
let length = u64::try_from(raw.len()).map_err(|_| {
118+
SnapshotError::Io(std::io::Error::new(
119+
std::io::ErrorKind::InvalidInput,
120+
"serialized snapshot recovery state length overflowed",
121+
))
122+
})?;
123+
if length > MAX_RUNTIME_STATE_BYTES {
124+
return Err(SnapshotError::Io(std::io::Error::new(
125+
std::io::ErrorKind::InvalidInput,
126+
format!("serialized snapshot recovery state exceeds {MAX_RUNTIME_STATE_BYTES} bytes"),
127+
)));
128+
}
87129
Ok(())
88130
}

crates/fluxheim-snapshot/src/snapshot_hardening_tests.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ impl TestDir {
461461
fn new(name: &str) -> Self {
462462
let path = unique_temp_path(name);
463463
std::fs::create_dir(&path).unwrap();
464+
set_private_test_directory(&path);
464465
Self(path)
465466
}
466467

@@ -469,6 +470,16 @@ impl TestDir {
469470
}
470471
}
471472

473+
fn set_private_test_directory(path: &std::path::Path) {
474+
#[cfg(unix)]
475+
{
476+
use std::os::unix::fs::PermissionsExt as _;
477+
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
478+
}
479+
#[cfg(not(unix))]
480+
let _ = path;
481+
}
482+
472483
impl Drop for TestDir {
473484
fn drop(&mut self) {
474485
let _ = std::fs::remove_dir_all(&self.0);

crates/fluxheim-snapshot/src/snapshot_upgrade_tests.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ impl TestDir {
142142
fn new(name: &str) -> Self {
143143
let path = unique_temp_path(name);
144144
std::fs::create_dir(&path).unwrap();
145+
set_private_test_directory(&path);
145146
Self(path)
146147
}
147148

@@ -150,6 +151,16 @@ impl TestDir {
150151
}
151152
}
152153

154+
fn set_private_test_directory(path: &std::path::Path) {
155+
#[cfg(unix)]
156+
{
157+
use std::os::unix::fs::PermissionsExt as _;
158+
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
159+
}
160+
#[cfg(not(unix))]
161+
let _ = path;
162+
}
163+
153164
impl Drop for TestDir {
154165
fn drop(&mut self) {
155166
let _ = std::fs::remove_dir_all(&self.0);

0 commit comments

Comments
 (0)