Skip to content

Commit 33a0302

Browse files
fix(cert): testable euid-root branch + orphan enterprise_roots warning
1 parent 08062d4 commit 33a0302

2 files changed

Lines changed: 130 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cert_installer.rs

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,30 +77,18 @@ pub fn reconcile_sudo_environment() {
7777

7878
#[cfg(unix)]
7979
mod unix {
80-
use super::sudo_parse_passwd_home;
80+
use super::{should_reconcile_for, sudo_parse_passwd_home};
8181
use std::path::Path;
8282
use std::process::Command;
8383

8484
pub(super) fn reconcile_sudo_home() {
85-
// EUID gate: only act when we are *actually* running with root
86-
// privileges. A process running as a normal user might have
87-
// SUDO_USER exported (inherited from a shell init, set in
88-
// user env, or via `sudo -E`) — without the EUID check we'd
89-
// happily rewrite HOME to another user's dir and redirect
90-
// every subsequent data_dir / cert path there. `geteuid()` is
91-
// the cheap, reliable discriminator.
92-
//
9385
// SAFETY: geteuid() is async-signal-safe and cannot fail.
9486
let euid = unsafe { libc::geteuid() };
95-
if euid != 0 {
96-
return;
97-
}
98-
let Ok(sudo_user) = std::env::var("SUDO_USER") else {
87+
let sudo_user_raw = std::env::var("SUDO_USER").ok();
88+
let Some(sudo_user) = should_reconcile_for(euid, sudo_user_raw.as_deref()) else {
9989
return;
10090
};
101-
if sudo_user.is_empty() || sudo_user == "root" {
102-
return;
103-
}
91+
let sudo_user = sudo_user.to_string();
10492
match resolve_home(&sudo_user) {
10593
Some(home) => {
10694
tracing::info!(
@@ -155,6 +143,34 @@ mod unix {
155143
}
156144
}
157145

146+
/// Decide whether to re-root HOME for a sudo-style invocation, given a
147+
/// process's effective UID and the value of the `SUDO_USER` env var.
148+
/// Returns `Some(user)` if and only if we should re-root HOME to that
149+
/// user's dir; `None` in every other case (normal user, real root
150+
/// login without sudo, SUDO_USER missing / empty / literally "root").
151+
///
152+
/// Extracted as a pure function so every branch — including the
153+
/// load-bearing `euid == 0 && SUDO_USER unset` path that must leave
154+
/// HOME as root's own /root — can be asserted with unit tests.
155+
/// Always compiled so the tests run on every host.
156+
fn should_reconcile_for<'a>(euid: u32, sudo_user: Option<&'a str>) -> Option<&'a str> {
157+
// EUID gate: if we're not actually root, `SUDO_USER` could be
158+
// anything (inherited from a shell init, explicitly exported,
159+
// set via `sudo -E`) and rewriting HOME based on it would let a
160+
// normal-user process redirect cert paths to someone else's files.
161+
if euid != 0 {
162+
return None;
163+
}
164+
// Real root login (no sudo) — SUDO_USER is simply unset. Do NOT
165+
// re-root: root's own /root is the correct HOME for that process.
166+
let user = sudo_user?;
167+
// Empty string or literal "root" also mean "nothing to reconcile".
168+
if user.is_empty() || user == "root" {
169+
return None;
170+
}
171+
Some(user)
172+
}
173+
158174
/// Pure parser for a single-line `getent passwd` entry.
159175
/// Always compiled so unit tests can run on every host.
160176
fn sudo_parse_passwd_home(content: &str) -> Option<String> {
@@ -1035,6 +1051,21 @@ fn contains_our_block(existing: &str) -> bool {
10351051
false
10361052
}
10371053

1054+
/// True iff `existing` has our exact pref line but NOT inside our
1055+
/// marker+pref block — i.e. an orphan `security.enterprise_roots.enabled
1056+
/// = true` whose provenance we can't prove. Used by
1057+
/// `disable_firefox_enterprise_roots` to surface a one-line hint on
1058+
/// uninstall so users upgrading from pre-v1.2.13 installs know their
1059+
/// Firefox user.js still has a cosmetic orphan pref from the old app
1060+
/// (not broken, just left in place because we can't distinguish it
1061+
/// from a user-authored line).
1062+
fn has_bare_enterprise_roots(existing: &str) -> bool {
1063+
if contains_our_block(existing) {
1064+
return false;
1065+
}
1066+
existing.lines().any(|l| l.trim() == FX_PREF)
1067+
}
1068+
10381069
fn has_nss_certutil() -> bool {
10391070
// We want NSS's `certutil` (from libnss3-tools), not Windows's
10401071
// built-in `certutil.exe` which shares the binary name but has
@@ -1310,6 +1341,24 @@ fn disable_firefox_enterprise_roots() {
13101341
};
13111342
if let Some(new) = strip_enterprise_roots_block(&existing) {
13121343
let _ = std::fs::write(&user_js, new);
1344+
continue;
1345+
}
1346+
// No marker block to strip, but an orphan pref is present.
1347+
// Surface it so the user isn't left wondering why user.js
1348+
// still has an enterprise_roots line after --remove-cert.
1349+
// The orphan is harmless (Firefox falls back to its built-in
1350+
// root store once the CA leaves the OS store), but silent
1351+
// leftovers feel like half-done removals.
1352+
if has_bare_enterprise_roots(&existing) {
1353+
tracing::info!(
1354+
"Firefox profile {}: `security.enterprise_roots.enabled` pref \
1355+
present without our marker — left in place. If it was written \
1356+
by a pre-v1.2.13 install it's a cosmetic orphan (harmless, \
1357+
Firefox falls back to its built-in root store); remove it \
1358+
manually from user.js if it bothers you. If you set it \
1359+
yourself, leave it.",
1360+
profile.display()
1361+
);
13131362
}
13141363
}
13151364
}
@@ -1560,6 +1609,70 @@ ID_LIKE=debian
15601609
assert_eq!(stripped, "");
15611610
}
15621611

1612+
// ── has_bare_enterprise_roots ──
1613+
1614+
#[test]
1615+
fn bare_enterprise_roots_detected_when_no_marker_present() {
1616+
let content = "user_pref(\"security.enterprise_roots.enabled\", true);\n";
1617+
assert!(has_bare_enterprise_roots(content));
1618+
}
1619+
1620+
#[test]
1621+
fn bare_enterprise_roots_not_detected_when_marker_block_present() {
1622+
// Our marker+pref block — strip handles this; has_bare_ must
1623+
// return false so we don't double-warn about a line we own.
1624+
let content = format!("{}\n{}\n", FX_MARKER, FX_PREF);
1625+
assert!(!has_bare_enterprise_roots(&content));
1626+
}
1627+
1628+
#[test]
1629+
fn bare_enterprise_roots_not_detected_when_pref_absent() {
1630+
let content = "user_pref(\"other\", 1);\n";
1631+
assert!(!has_bare_enterprise_roots(content));
1632+
}
1633+
1634+
#[test]
1635+
fn bare_enterprise_roots_ignores_false_variant() {
1636+
// User explicitly set enterprise_roots = false — not our line
1637+
// and not the pre-marker legacy write (which only ever wrote
1638+
// true). No orphan to warn about.
1639+
let content = "user_pref(\"security.enterprise_roots.enabled\", false);\n";
1640+
assert!(!has_bare_enterprise_roots(content));
1641+
}
1642+
1643+
// ── should_reconcile_for ──
1644+
1645+
#[test]
1646+
fn reconcile_skipped_for_normal_user() {
1647+
// euid != 0 — even with SUDO_USER set we must NOT re-root HOME.
1648+
// A non-root process that happened to inherit SUDO_USER (or
1649+
// used `sudo -E`) shouldn't get to redirect cert paths.
1650+
assert_eq!(should_reconcile_for(1000, Some("alice")), None);
1651+
assert_eq!(should_reconcile_for(1000, None), None);
1652+
}
1653+
1654+
#[test]
1655+
fn reconcile_skipped_for_real_root_login_without_sudo() {
1656+
// Load-bearing case the maintainer asked to pin: euid == 0
1657+
// AND no SUDO_USER means the process is a real root login,
1658+
// not a sudo elevation. HOME should stay as /root; we must
1659+
// not try to resolve some other user's home.
1660+
assert_eq!(should_reconcile_for(0, None), None);
1661+
}
1662+
1663+
#[test]
1664+
fn reconcile_skipped_when_sudo_user_is_empty_or_root() {
1665+
assert_eq!(should_reconcile_for(0, Some("")), None);
1666+
assert_eq!(should_reconcile_for(0, Some("root")), None);
1667+
}
1668+
1669+
#[test]
1670+
fn reconcile_triggers_for_real_sudo_invocation() {
1671+
// euid == 0 AND SUDO_USER points to a non-root user — this is
1672+
// the sudo case we do want to reconcile.
1673+
assert_eq!(should_reconcile_for(0, Some("alice")), Some("alice"));
1674+
}
1675+
15631676
// ── sudo_parse_passwd_home ──
15641677

15651678
#[test]

0 commit comments

Comments
 (0)