Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/service-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ concurrency:

env:
CARGO_TERM_COLOR: always
# This smoke test installs the freshly-built `target/release/dig-node` from the runner's
# user-writable checkout. On Windows that registration is system-level (LocalSystem), which the
# §565 privileged-target gate refuses for a user-writable binary dir (a real LPE protection —
# see service.rs). This explicit, default-off opt-out permits the controlled test install of an
# unreleased build; end users install via the native OS package into a protected admin-owned dir
# (never this env). It is a harmless no-op on the Linux/macOS user-level installs.
DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET: "1"

jobs:
smoke:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project are documented here.
This project adheres to [Semantic Versioning](https://semver.org) and
[Conventional Commits](https://www.conventionalcommits.org).

## [0.37.1] - 2026-07-16

### Bug Fixes
- **service:** Refuse a system-level `install` whose binary is in a user-writable dir (#565 LPE gate)

## [0.37.0] - 2026-07-17

### Features
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.37.0"
version = "0.37.1"

# Release hardening, matching digstore: keep integer-overflow checks ON in release.
# The node parses untrusted serialized input and does offset/length arithmetic over
Expand Down
20 changes: 20 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1457,6 +1457,26 @@ service does not hit Windows `CreateService` error 1073 ("the specified service
service-manager-generated unit whose `Description` is the service id, matching `dig-dns`'s own
established precedent for the CLI-only path.

9.2c. **Privileged-target gate (`install`, #565 LPE).** A **system-level** registration (Windows
SCM, always LocalSystem; a root systemd/launchd daemon) records the currently-running binary as its
`ExecStart` / SCM `binPath` / launchd `ProgramArguments` (§9.2). If that binary sits in a
user-writable directory, a non-privileged local user could replace it and gain persistent
SYSTEM/root code execution on the next service start — a privilege-escalation vector. So before
registering a system-level service, `install` MUST verify the program's directory is
**privileged-owned** (Unix: `root`/uid 0 and no group/other write bit; Windows: an owner SID equal
to the well-known LocalSystem `S-1-5-18` or BUILTIN\Administrators `S-1-5-32-544`) and **refuse with
`PERMISSION_DENIED`** otherwise, before any side effect (no state-dir harden, no service create).
This is the SAME spawn-free owner gate the self-heal spawn root (§7 #565) and the TLS material root
(§4.1a #661) use — one shared check, fail-closed on an indeterminate owner. A **user-level** install
(the Linux/macOS default) runs as the very user who owns the binary, crosses no privilege boundary,
and is always allowed. The canonical install path (native OS package, §9.7) places the binary in a
protected admin-owned location (`%ProgramFiles%\DIG Network\dig-node\`, `/usr/…`), so it satisfies
the gate; a manual `dig-node install` from a user-writable download directory is what the gate
refuses. A single explicit, **default-off** opt-out — the `DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET`
env var (truthy `1`/`true`/`yes`) — bypasses the gate with a loud warning, intended ONLY for a
controlled test/dev install of an unreleased build from a build directory (e.g. the `service-smoke`
CI); it MUST NOT be set on an end-user machine.

9.3. **Entrypoint per platform.** The installed service runs `dig-node run-service` on Windows and
`dig-node run` on systemd/launchd (which exec the foreground process directly).

Expand Down
163 changes: 163 additions & 0 deletions crates/dig-node-service/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,83 @@ fn current_exe() -> io::Result<PathBuf> {
std::env::current_exe()
}

/// The opt-in escape hatch that bypasses the §565 privileged-target gate
/// ([`ensure_service_target_is_safe`]). Set to a truthy value ONLY for a controlled test/dev
/// install of an unreleased build from a build directory (e.g. the `service-smoke` CI job installs
/// `target/release/dig-node` from the runner's user-writable checkout). It is default-OFF and MUST
/// NOT be set on an end-user machine — the canonical install (native OS package, §9.7) always lands
/// the binary in a protected admin-owned directory and never needs it.
const ALLOW_INSECURE_SERVICE_TARGET_ENV: &str = "DIG_NODE_ALLOW_INSECURE_SERVICE_TARGET";

/// Whether [`ALLOW_INSECURE_SERVICE_TARGET_ENV`] is set to a truthy value (`1`/`true`/`yes`,
/// case-insensitive). Any other value — or an unset var — leaves the gate ENABLED (default-safe).
fn insecure_service_target_allowed() -> bool {
std::env::var(ALLOW_INSECURE_SERVICE_TARGET_ENV)
.map(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
})
.unwrap_or(false)
}

/// Refuse to register a PRIVILEGED (system-level) service whose program binary lives in a
/// user-writable directory — the §565 privilege-escalation class.
///
/// A system-level service runs as a privileged principal (Windows LocalSystem / a root daemon).
/// If its recorded `ExecStart` / SCM `binPath` / launchd `ProgramArguments` points at a binary a
/// non-privileged user can replace, that user gains PERSISTENT privileged code execution: swap the
/// file, wait for the next service start, and the swapped code runs as SYSTEM/root. So before
/// registering a system-level service, its program's directory MUST be privileged-owned
/// (root/SYSTEM, no group/world write) — verified through the SAME spawn-free owner gate the
/// self-heal spawn root (#565) and the TLS material root (#661) use ([`crate::security`]), so the
/// three never drift. Fails CLOSED: an indeterminate owner is refused.
///
/// A **user-level** install (Linux systemd / macOS launchd, the default there) runs as the very
/// user who owns the binary — there is no privilege boundary to cross — so it is always allowed.
/// `allow_insecure_override` is the explicit test/dev opt-out
/// ([`ALLOW_INSECURE_SERVICE_TARGET_ENV`]); it is default-`false` in production.
fn ensure_service_target_is_safe(
program: &std::path::Path,
user_level: bool,
allow_insecure_override: bool,
) -> io::Result<()> {
// A user-level service runs as the installing user: swapping a binary that user already owns
// grants that user nothing it lacked. No privilege boundary, no LPE — always allowed.
if user_level {
return Ok(());
}
// The program's directory is the surface an attacker would need write access to; a
// privileged-owned directory keeps a non-privileged user from replacing the binary in it.
let root = program.parent().unwrap_or(program);
if crate::security::dir_is_privileged(root) {
return Ok(());
}
// Explicit, default-off test/dev opt-out — a controlled install of an unreleased build from a
// build directory (see the env-var doc). Never set on an end-user machine.
if allow_insecure_override {
eprintln!(
"dig-node: WARN {ALLOW_INSECURE_SERVICE_TARGET_ENV} is set — registering a \
system-level service pointing at \"{}\", a user-writable directory. This is a \
privilege-escalation risk (#565) and is intended ONLY for test/dev installs of an \
unreleased build.",
program.display()
);
return Ok(());
}
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"dig-node: refusing to register a system-level (privileged) service pointing at \
\"{}\", whose directory is writable by a non-privileged user. Registering it would \
let any local user replace that binary and gain persistent SYSTEM/root code \
execution (a privilege-escalation vector, #565). Install dig-node into a protected, \
admin-owned location — via the DIG installer or a native OS package — and re-run \
`dig-node install` from there.",
program.display()
),
))
}

/// On Windows, is this process running elevated (Administrator)? Used to fail
/// `install`/`uninstall` early with a helpful message instead of a cryptic SCM
/// access-denied. Always `true` off Windows (those paths are user-level).
Expand Down Expand Up @@ -620,6 +697,15 @@ pub fn install(config: &Config) -> io::Result<Outcome> {

let backend = SystemServiceBackend::new()?;
let program = current_exe()?;
// §565 LPE gate: before touching anything, refuse a privileged (system-level) registration
// whose program binary sits in a user-writable directory — a swapped binary would run as
// SYSTEM/root on the next start. Checked FIRST so a refusal has no side effects (no state-dir
// harden, no service create). A user-level install runs as the invoking user and is allowed.
ensure_service_target_is_safe(
&program,
backend.user_level(),
insecure_service_target_allowed(),
)?;
let plan = build_plan(config, program.clone());

// HARDEN the machine-wide state dir NOW, as the INSTALLING (interactive) user, per the
Expand Down Expand Up @@ -1020,6 +1106,83 @@ SERVICE_NAME: net.dignetwork.dig-node
assert!(!is_2xx_status_line(""));
}

// -- §565 privileged-install LPE gate (ensure_service_target_is_safe) -------------------

#[test]
fn user_level_install_is_always_allowed_regardless_of_binary_owner() {
// A user-level service runs as the installing user, so a user-writable program dir crosses
// NO privilege boundary — the gate must not refuse it even from a plainly user-owned dir,
// and without needing the insecure override.
let dir = tempfile::tempdir().unwrap();
let program = dir.path().join("dig-node");
assert!(
ensure_service_target_is_safe(&program, /* user_level */ true, false).is_ok(),
"a user-level install from a user-owned dir must be allowed"
);
}

#[test]
fn system_level_install_from_a_user_writable_dir_is_refused() {
// The §565 LPE: a privileged (system-level) service pointing at a binary in a
// user-writable directory lets any local user swap it for persistent SYSTEM/root exec.
// A freshly-created tempdir is owned by the (non-privileged) test user — exactly that
// condition — so registration MUST fail closed with PERMISSION_DENIED.
let dir = tempfile::tempdir().unwrap();
let program = dir.path().join("dig-node");
let err = ensure_service_target_is_safe(&program, /* user_level */ false, false)
.expect_err("a system-level install from a user-writable dir must be refused");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
// The message must name the LPE class + the offending path so an operator can act.
let msg = err.to_string();
assert!(msg.contains("#565"), "message cites the LPE class: {msg}");
assert!(
msg.contains(&program.display().to_string()),
"message names the offending program path: {msg}"
);
}

#[test]
fn insecure_override_permits_a_system_level_install_from_a_user_writable_dir() {
// The explicit, default-off test/dev opt-out lets a controlled install of an unreleased
// build proceed from a user-writable build dir (e.g. the service-smoke CI job installing
// target/release/dig-node). It is default-off, so this branch only opens when set.
let dir = tempfile::tempdir().unwrap();
let program = dir.path().join("dig-node");
assert!(
ensure_service_target_is_safe(&program, /* user_level */ false, true).is_ok(),
"the explicit insecure override must permit the otherwise-refused system-level install"
);
}

#[test]
fn insecure_override_env_parses_only_truthy_values() {
// The env reader is default-safe: unset or any non-truthy value keeps the gate ENABLED.
// (Uses process env, so restore it to avoid leaking into sibling tests.)
let prev = std::env::var(ALLOW_INSECURE_SERVICE_TARGET_ENV).ok();
for (val, expected) in [
("1", true),
("true", true),
("YES", true),
("0", false),
("false", false),
("", false),
("nope", false),
] {
std::env::set_var(ALLOW_INSECURE_SERVICE_TARGET_ENV, val);
assert_eq!(
insecure_service_target_allowed(),
expected,
"{val:?} must parse to {expected}"
);
}
std::env::remove_var(ALLOW_INSECURE_SERVICE_TARGET_ENV);
assert!(!insecure_service_target_allowed(), "unset ⇒ gate enabled");
match prev {
Some(v) => std::env::set_var(ALLOW_INSECURE_SERVICE_TARGET_ENV, v),
None => std::env::remove_var(ALLOW_INSECURE_SERVICE_TARGET_ENV),
}
}

// -- the real OS-backed path (no state mutation): probe + status only -------------------

#[test]
Expand Down
Loading