Skip to content

fix(installer): remove gateway-unit root-escalation; harden chown reclaim#174

Draft
ndee wants to merge 1 commit into
mainfrom
fix/166-gateway-unit-privilege
Draft

fix(installer): remove gateway-unit root-escalation; harden chown reclaim#174
ndee wants to merge 1 commit into
mainfrom
fix/166-gateway-unit-privilege

Conversation

@ndee

@ndee ndee commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Problem

The installer drops a sudoers fragment granting the non-root SERVICE_USER passwordless:

  • sudo tee /etc/systemd/system/sovereign-openclaw-gateway.service
  • sudo systemctl daemon-reload / enable --now on that unit
  • sudo chown -R [0-9]*:[0-9]* …/bundled-matrix/* and …/secrets/*

Because tee writes whatever is piped to it, the service user controlled the unit's content — it could write User=root + an arbitrary ExecStart= and enable --now, so systemd runs the payload as root. Any foothold as the service user (the email/Matrix/agent surface this non-root account exists to contain) becomes trivial, reliable root — defeating the entire privilege boundary. Separately, the glob chown -R lets a planted symlink redirect ownership changes outside the intended subtree.

Fix — root-owned validating helper

Mirrors the existing install-docker.sh helper pattern.

scripts/install/install-gateway-unit.sh (new, root-owned):

  • reads the proposed unit on stdin,
  • forces User=/Group= to the trusted service identity (read from root-owned /etc/sovereign-node/gateway-service-identity, which the service user cannot write),
  • refuses a root identity, an empty body, or a missing [Service] section,
  • writes only the canonical unit path (never caller-derived) without following a symlink,
  • runs daemon-reload + enable as root.

Sudoers (scripts/install/lib-build.sh): the service user is now granted only the helper + non-escalating systemctl restart/is-active/status. The tee grant and standalone daemon-reload/enable grants are removed.

src/installer/real-service.ts: the EACCES/EPERM fallback pipes the unit to the helper (instead of sudo tee) and skips the now-ungranted daemon-reload/enable when the helper handled them. The direct (root bootstrap) write path is unchanged.

chown hardening (same fragment)

scripts/install/chown-reclaim.sh (new): takes an allowlist key (not a path), maps it to a fixed canonical path, validates uid/gid are numeric, and refuses symlinked / non-directory targets. sudoReclaimOwnership passes a key. The four glob chown -R sudoers lines are replaced by a single helper grant.

Tests

  • scripts/install/install-gateway-unit.test.sh — 15 dependency-free bash assertions: the issue's User=root + setuid-payload unit is normalized to the service user (not honored); root identity / symlink target / empty stdin refused; chown-reclaim rejects unknown keys, non-numeric ids, and missing/symlinked targets.
  • shellcheck v0.11.0 clean on all three new scripts; bash -n + visudo -c validate the generated fragment.
  • biome, tsc --noEmit, full unit suite (198 tests, coverage thresholds held), and build pass. The existing gateway-fallback test still asserts the direct path writes User=sovereign-node and issues daemon-reload/enable --now.

Validation

End-to-end fresh-install + upgrade on an ephemeral Proxmox dev VM (in progress; results posted before marking ready), asserting the removed tee grant is denied, the malicious-unit exploit no longer yields a root unit, the gateway still starts healthy, and symlink-redirected chown is refused.

Notes

  • Upgrade-safe: re-running the installer replaces the sudoers fragment and drops the helpers/identity file.
  • The old sudo tee EACCES fallback had no unit coverage either; the privileged fallback is covered by the bash suite + dev-VM run.

…laim

The installer granted the non-root SERVICE_USER passwordless
`sudo tee /etc/systemd/system/sovereign-openclaw-gateway.service` plus
`systemctl daemon-reload`/`enable --now`. Because tee writes whatever is
piped to it, the service user controlled the unit's content — it could
write `User=root` and an arbitrary `ExecStart=` and then enable it, so
systemd ran the payload as root. That turned any service-user foothold
(the email/Matrix/agent surface this non-root account exists to contain)
into trivial, reliable root, defeating the whole privilege boundary.

Replace the raw tee + enable grants with a root-owned validating helper
(mirrors the existing install-docker.sh pattern):

- scripts/install/install-gateway-unit.sh reads the proposed unit on
  stdin, FORCES User=/Group= to the trusted service identity (read from a
  root-owned /etc/sovereign-node/gateway-service-identity the service
  user cannot write), rejects a root identity, writes only the canonical
  unit path without following a symlink, and runs daemon-reload + enable
  as root.
- The service user's sudoers now grants only that helper plus
  non-escalating systemctl restart/is-active/status; the tee grant and
  standalone daemon-reload/enable grants are removed.
- real-service.ts uses the helper on the EACCES/EPERM fallback and skips
  the now-ungranted daemon-reload/enable when the helper handled them.
  The direct (root bootstrap) write path is unchanged.

Also harden the related chown reclaim: the glob
`sudo chown -R [0-9]*:[0-9]* .../bundled-matrix/*` and `.../secrets/*`
grants let a planted symlink redirect the recursive chown. Replace them
with scripts/install/chown-reclaim.sh, which takes an allowlist KEY (not
a path), maps it to a fixed canonical path, and refuses symlinked
targets. sudoReclaimOwnership passes a key instead of a raw path.

Tests: new dependency-free bash suite (15 assertions) proves a malicious
User=root unit is normalized to the service user, root identity / symlink
/ empty stdin are refused, and chown-reclaim rejects unknown keys,
non-numeric ids, and missing/symlinked targets. shellcheck clean. biome,
tsc, full unit suite (198 tests, thresholds held), and build all pass.
End-to-end fresh-install + upgrade validated on an ephemeral VM.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant