fix(security): disable fail-open auto-updater until Phase 4

Gate the nunciod 24h auto-update-check loop behind NUNCIO_AUTO_UPDATE_ENABLED
(defaults off), so the daemon never autonomously checks/installs updates. Make
update.apply RPC return an explicit 'disabled pending Phase 4' error instead of
performing the fail-open install; update.check (read-only) is preserved.
UpdateEngine (update.rs) untouched for the Phase 4 checksum fix.

Refs #140
This commit is contained in:
James Maes committed 2026-07-26 20:36:30 -05:00
1 parent 9c8303280e
commit 3931342f36
2 files changed
+104 -36

No files matched your search

+58
View File
@@ -4,3 +4,61 @@
pub mod orchestrator;
pub use orchestrator::SelfHealingSyncOrchestrator;
/// Environment variable that opts `nunciod` in to its autonomous
/// background auto-update-check loop.
///
/// # Security (GH #140 / backlog story 0.B.2)
///
/// `nuncio_core::UpdateEngine`'s checksum verification is currently
/// **fail-open**: if the `SHA256SUMS.txt` release asset is missing, an
/// update install proceeds without checksum verification. That will be
/// fixed properly in Phase 4. Until then, `nunciod` MUST NOT
/// autonomously check for or install updates, so the background loop
/// defaults to disabled and is only spawned when an operator explicitly
/// opts in via this environment variable.
pub const AUTO_UPDATE_ENV_VAR: &str = "NUNCIO_AUTO_UPDATE_ENABLED";
/// Returns `true` only if the operator has explicitly opted in to the
/// autonomous background auto-update-check loop by setting
/// [`AUTO_UPDATE_ENV_VAR`] to `"1"` or `"true"` (case-insensitive).
/// Defaults to `false` (disabled).
///
/// See the module-level documentation on [`AUTO_UPDATE_ENV_VAR`] for why
/// this defaults to disabled.
pub fn auto_update_task_enabled() -> bool {
match std::env::var(AUTO_UPDATE_ENV_VAR) {
Ok(val) => val.eq_ignore_ascii_case("1") || val.eq_ignore_ascii_case("true"),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
// Serialize access to the shared process environment variable so this
// test cannot interleave with any other test that might touch it.
static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn auto_update_task_disabled_by_default() {
let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var(AUTO_UPDATE_ENV_VAR);
assert!(
!auto_update_task_enabled(),
"autonomous auto-update loop must default to disabled (GH #140)"
);
}
#[test]
fn auto_update_task_enabled_requires_explicit_opt_in() {
let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var(AUTO_UPDATE_ENV_VAR, "true");
assert!(auto_update_task_enabled());
std::env::set_var(AUTO_UPDATE_ENV_VAR, "0");
assert!(!auto_update_task_enabled());
std::env::remove_var(AUTO_UPDATE_ENV_VAR);
assert!(!auto_update_task_enabled());
}
}
+46 -36
View File
@@ -52,27 +52,43 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
}
});
// Background Auto-Update Check Listener Loop (24h interval)
let event_bus_update = event_bus.clone();
let _update_task = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(86400));
interval.tick().await;
loop {
// Background Auto-Update Check Listener Loop (24h interval).
//
// SECURITY (GH #140 / backlog story 0.B.2): `UpdateEngine`'s checksum
// verification is currently fail-open (installs proceed unverified if
// `SHA256SUMS.txt` is missing from the release). That will be fixed
// properly in Phase 4. Until then, nunciod must never autonomously
// check for or install updates, so this loop only runs when an
// operator explicitly opts in via `NUNCIO_AUTO_UPDATE_ENABLED=1`.
let _update_task = if nunciod::auto_update_task_enabled() {
let event_bus_update = event_bus.clone();
Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(86400));
interval.tick().await;
if let Ok(updater) = nuncio_core::UpdateEngine::new() {
if let Ok(result) = updater.check_for_updates().await {
if result.update_available {
if let Some(info) = result.release_info {
event_bus_update.publish_event(CoreEvent::UpdateAvailable {
version: info.version,
release_notes: info.release_notes,
});
loop {
interval.tick().await;
if let Ok(updater) = nuncio_core::UpdateEngine::new() {
if let Ok(result) = updater.check_for_updates().await {
if result.update_available {
if let Some(info) = result.release_info {
event_bus_update.publish_event(CoreEvent::UpdateAvailable {
version: info.version,
release_notes: info.release_notes,
});
}
}
}
}
}
}
});
}))
} else {
tracing::info!(
"Autonomous auto-update check loop disabled (set {} to enable); \
pending Phase 4 checksum fail-open fix, see GH #140",
nunciod::AUTO_UPDATE_ENV_VAR
);
None
};
// Custom RPC Handler for filter.* and update.* methods
@@ -243,26 +259,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
},
Err(e) => Some(Err(e.to_string())),
},
"update.apply" => match nuncio_core::UpdateEngine::new() {
Ok(updater) => match updater.check_for_updates().await {
Ok(res) => {
if let Some(info) = res.release_info {
match updater.apply_update(&info).await {
Ok(msg) => {
Some(Ok(json!({ "status": "updated", "message": msg })))
}
Err(e) => Some(Err(e.to_string())),
}
} else {
Some(Ok(
json!({ "status": "already_up_to_date", "message": "Already up to date" }),
))
}
}
Err(e) => Some(Err(e.to_string())),
},
Err(e) => Some(Err(e.to_string())),
},
"update.apply" => {
// SECURITY (GH #140 / backlog story 0.B.2): `UpdateEngine`'s
// checksum verification is currently fail-open (an update
// installs unverified if `SHA256SUMS.txt` is missing from
// the release). Until that is fixed in Phase 4, nunciod
// must never perform an on-demand install either. Use
// `update.check` for a read-only version check, and
// install updates manually in the meantime.
Some(Err(
"auto-update is disabled pending Phase 4 (fail-open checksum fix, \
see GH #140); install updates manually for now"
.to_string(),
))
}
_ => None,
}
})