Skip to content

fix(security): stop hive-discord.service executing code planted under /tmp (#5435) - #5481

Merged
kubestellar-prow[bot] merged 2 commits into
v4from
fix/5435-discord-service-path
Sep 1, 2026
Merged

fix(security): stop hive-discord.service executing code planted under /tmp (#5435)#5481
kubestellar-prow[bot] merged 2 commits into
v4from
fix/5435-discord-service-path

Conversation

@clubanderson

Copy link
Copy Markdown
Contributor

Security Fix

Files claimed: systemd/hive-discord.service, bin/hive-checkout-guard.sh (new), bin/hive-deploy.sh, src/deploy/test_hive_discord_unit_contract.sh (new), .github/workflows/v2-ci.yml, CHANGELOG.md.

hive-discord.service runs node bot.js with WorkingDirectory=/tmp/hive/discord, so the executed file resolves out of a world-writable parent that is cleared on every reboot. /tmp's sticky bit stops a user deleting or renaming entries owned by someone else — it does not stop them creating /tmp/hive/discord/bot.js in the window after a reboot wipes /tmp and before hive-deploy repopulates the checkout. The winner of that race gets code execution as dev with the Discord bot token from /etc/hive/discord.env. Requires=hive.service orders startup and validates nothing about who owns the code.

The fix

ExecStartPre=/usr/local/bin/hive-checkout-guard.sh /tmp/hive/discord bot.js — refuses to start unless:

  • every directory from / down to the checkout is owned by the service user or root and not writable by anyone else. A sticky ancestor such as /tmp is accepted; the leaf is not, because the leaf is where a new bot.js would appear and sticky only protects entries that already exist.
  • bot.js exists, is a regular file (not a symlink), and is not group- or world-writable.

Plus NoNewPrivileges, ProtectSystem=full, ProtectHome=read-only, PrivateDevices, RestrictSUIDSGID.

Why a script and not an inline directive

The obvious one-liner is a no-op guard:

ExecStartPre=/usr/bin/find <dir> -maxdepth 0 -user dev ! -perm /go=w -print -quit

find exits 0 after a successful traversal whether or not anything matched, so the unsafe case exits 0 too and systemd starts the unit anyway. It would grep green. That is the same shape of dead assertion #5398 found in another contract test, so the guard's exit status is the assertion and the test executes it rather than grepping for it.

Why the path did not move

The issue's first recommendation was relocating the checkout. I did not, and the checkout stays at /tmp/hive. HIVE_REPO_DIR defaults there, but the literal /tmp/hive is also hardcoded in bin/hive.sh:27, throughout bin/kick-agents.sh (including the loop that reinstalls unit files from /tmp/hive/systemd/*), and in the ExecStart of hive-snapshot.service. Changing the default alone would leave those pointing at a path nothing pulls into. That is a deploy-layout decision across all of them — which is why the issue was filed as issue-only — not a fix to this unit. The guard closes the code-execution path without moving anything.

Upgrade behaviour: existing hosts keep their checkout exactly where it is, so nothing migrates and nothing breaks. The one real upgrade hazard is that hive-deploy.sh's two sync loops both skip files that are not already installed ([ -f "$dst" ] || continue), so a new helper is never bootstrapped — the same problem that needed an explicit block for hive-baseline-check.sh. Without handling it, an upgraded host would pull a unit whose ExecStartPre points at a script it does not have and the bot would fail to start. hive-deploy.sh therefore installs the guard explicitly, before kick-agents.sh reinstalls the units.

Two directives deliberately NOT set

PrivateTmp=true would give the unit a private /tmp namespace, hiding the very checkout WorkingDirectory points at — the service would fail to start. ProtectSystem=strict would make /tmp read-only for the same tree. Both are the intuitive "more hardening" choice here and both break the bot, so the contract test pins them as absent to stop a later hardening pass introducing them.

Testing

src/deploy/test_hive_discord_unit_contract.sh builds real directory trees shaped like the deployment (sticky world-writable ancestor standing in for /tmp) and runs the guard against each attack shape, asserting exit status: healthy checkout (must allow), post-reboot window with bot.js absent, world-writable leaf, sticky world-writable leaf, world- and group-writable ancestors, world- and group-writable bot.js, symlinked bot.js, symlinked checkout directory, missing directory.

  • Against the unfixed unit: 3 failures (no ExecStartPre, no NoNewPrivileges, no ProtectSystem).
  • Against the fixed unit: 21 passed, 0 failed.

Wired into v2-ci.yml next to the other deploy contract tests — src/deploy/test_*.sh are registered individually there, so an unregistered test would never run.

One portability bug was caught while writing this and is worth noting for anyone reusing the guard: BSD/macOS stat -f '%Lp' prints only the low three permission bits and drops the sticky bit, while GNU stat -c '%a' includes it. Inferring sticky from the digit count therefore reports every sticky directory as non-sticky and would reject /tmp itself. The guard reads sticky via test -k instead.

Overlap with #5436

#5436 (for #5434) touches the sibling unit systemd/ttyd-hive.serviceno file overlap. It set no hardening-directive precedent to follow: it changed only a bind address (-i 127.0.0.1) and added no Protect*/NoNewPrivileges lines, and no unit in systemd/ currently carries any hardening directives. I followed its documentation posture instead — an inline comment explaining why the safe default is the default and what to do to change it — and confined the new directives to this unit rather than sweeping all eight, keeping the change reviewable and disjoint. A consistent hardening pass across the remaining units is worth a follow-up but is deliberately not in this PR.

Not verified

  • Not exercised on a live host: no reboot-race reproduction against a real systemd, and the ExecStartPre path was not observed refusing a real start. The guard's logic is verified directly, but its integration with systemd is verified only by reading the unit.
  • The guard's stat fallbacks were exercised on BSD stat locally; the GNU branch is exercised by CI on Linux, which is the platform that actually matters for these units.
  • ProtectHome=read-only is safe as far as the bot's source shows (no writes to $HOME anywhere in discord/), but a runtime that writes an npm/cache path under home would notice it.

Fixes #5435

… /tmp

hive-discord.service runs `node bot.js` with
WorkingDirectory=/tmp/hive/discord, so the executed file resolves out of a
world-writable parent that is cleared on every reboot. /tmp's sticky bit
prevents deleting or renaming entries owned by someone else, but not creating
new ones, so a local unprivileged user who wins the post-reboot race —
creating /tmp/hive/discord/bot.js before hive-deploy repopulates the checkout
— gets code execution as the service user along with the Discord bot token
from /etc/hive/discord.env. Requires=hive.service orders startup and validates
nothing about who owns the code.

Add bin/hive-checkout-guard.sh as an ExecStartPre. It refuses to start unless
every directory from / down to the checkout is owned by the service user or
root and is not writable by anyone else (a sticky ancestor such as /tmp is
accepted; the leaf is not, since the leaf is where a new bot.js would appear),
and unless bot.js itself exists, is a regular file rather than a symlink, and
is not group- or world-writable.

The guard is a script rather than an inline directive on purpose. The obvious
one-liner

    ExecStartPre=/usr/bin/find <dir> -maxdepth 0 -user dev ! -perm /go=w -print -quit

is a no-op: find exits 0 after a successful traversal whether or not anything
matched, so the unsafe case starts the unit anyway.

The checkout stays at /tmp/hive. HIVE_REPO_DIR defaults there, but the literal
path is also hardcoded in bin/hive.sh, bin/kick-agents.sh and the ExecStart of
hive-snapshot.service; relocating it is a deploy-layout change across all of
those rather than a fix to this unit, and existing hosts keep their checkout
where it is. hive-deploy.sh gains an explicit install block for the guard
because both of its sync loops skip files that are not already installed, so a
new helper is otherwise never bootstrapped and an upgraded host would pull a
unit referencing a script it does not have.

PrivateTmp is deliberately not set and ProtectSystem is deliberately not
strict: either would hide or freeze the very checkout the unit runs from and
stop the bot starting. The contract test pins both as absent so a later
hardening pass cannot introduce them. NoNewPrivileges, ProtectSystem=full,
ProtectHome=read-only, PrivateDevices and RestrictSUIDSGID are added.

src/deploy/test_hive_discord_unit_contract.sh executes the guard against real
directory trees and asserts on its exit status, once per attack shape,
including the pre-fix arrangement. Wired into v2-ci.yml alongside the other
deploy contract tests. Verified failing against the unfixed unit (3 failures)
and passing against the fixed one (21 assertions).

Fixes #5435

Signed-off-by: Andrew Anderson <andy@clubanderson.com>
@kubestellar-prow kubestellar-prow Bot added dco-signoff: yes Indicates the PR's author has signed the DCO. needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Sep 1, 2026
Signed-off-by: Andrew Anderson <andy@clubanderson.com>
@kubestellar-prow kubestellar-prow Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Sep 1, 2026
@clubanderson

Copy link
Copy Markdown
Contributor Author

While scoping this I checked the other seven units in systemd/ for the same pattern. hive-snapshot.service is the only other one whose ExecStart resolves out of /tmp/hive (ExecStart=/tmp/hive/dashboard/publish-snapshot.sh, also User=dev), so it carries the same class of exposure.

I left it out of this PR deliberately to keep the change disjoint and reviewable, and filed it as #5483 instead. It is lower severity — Type=oneshot on a timer rather than Restart=always, and no EnvironmentFile, so the payoff is dev execution without an accompanying token. bin/hive-checkout-guard.sh as added here generalizes to it with no modification.

@clubanderson clubanderson added lgtm Indicates that a PR is ready to be merged. approved Indicates a PR has been approved by an approver from all required OWNERS files. labels Sep 1, 2026
@kubestellar-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

Approval requirements bypassed by manually added approval.

This pull-request has been approved by:

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubestellar-prow
kubestellar-prow Bot merged commit 46070d8 into v4 Sep 1, 2026
50 checks passed
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Thank you for your contribution! Your PR has been merged.

Check out what's new:

Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey

@kubestellar-prow
kubestellar-prow Bot deleted the fix/5435-discord-service-path branch September 1, 2026 14:26
pull Bot pushed a commit to joshyorko/hive that referenced this pull request Sep 1, 2026
…ut (kubestellar#5483)

hive-snapshot.service executed /tmp/hive/dashboard/publish-snapshot.sh as
User=dev with no ownership guard. /tmp is world-writable and cleared on
reboot; the sticky bit does not prevent CREATING /tmp/hive/dashboard/
publish-snapshot.sh in the window before hive-deploy repopulates the
checkout, so a local unprivileged user who won that race got code
execution as dev on the next timer fire — and the real script reads the
GitHub App token from /var/run/hive-metrics/gh-app-token.cache.

Same class and same fix as kubestellar#5435 / kubestellar#5481:

- ExecStartPre=/usr/local/bin/hive-checkout-guard.sh /tmp/hive/dashboard
  publish-snapshot.sh — the existing guard generalizes unmodified; its
  exit status is the assertion, so a violation stops ExecStart.
- Hardening pass mirroring hive-discord.service: NoNewPrivileges,
  ProtectSystem=full, ProtectHome=read-only, PrivateDevices,
  RestrictSUIDSGID. PrivateTmp and ProtectSystem=strict deliberately
  absent (both would break execution out of the /tmp checkout) and
  pinned absent by the contract test.
- src/deploy/test_hive_snapshot_unit_contract.sh executes the guard with
  this unit's argument shape (the entrypoint is the 755 script itself,
  so the writable-file check is the live one) and pins the unit's
  directives.

The v2-ci.yml step wiring this test in (next to the kubestellar#5435 step) is NOT
in this PR: the App push token lacks workflows permission. A maintainer
should add it; exact step text is in the PR body.

Refs kubestellar#5483

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
clubanderson added a commit that referenced this pull request Sep 1, 2026
…r /tmp (#5483)

hive-snapshot.service ran ExecStart=/tmp/hive/dashboard/publish-snapshot.sh,
resolving the executed script out of a world-writable parent that is cleared on
every reboot. /tmp's sticky bit prevents replacing entries owned by others but
not creating new ones, so a local user who created the path before hive-deploy
repopulated the checkout got code execution as the service user.

Lower severity than #5435 (Type=oneshot on a timer, not Restart=always; no
EnvironmentFile) but not zero: publish-snapshot.sh reads a GitHub App token from
/var/run/hive-metrics/gh-app-token.cache and pushes to kubestellar/docs.

bin/hive-checkout-guard.sh from #5481 generalizes to this with no changes: it
resolves the service user from the running EUID and takes the directory and
entrypoints as arguments, so it is wired in as-is via ExecStartPre.

ProtectHome is deliberately NOT read-only here, unlike hive-discord.service:
publish-snapshot.sh drives git and gh, which write under $HOME. PrivateTmp and
ProtectSystem=strict remain absent for the same reason as #5481, plus the
DOCS_REPO_DIR clone also lives under /tmp.

The new contract test executes the guard against real directory trees and
asserts on exit status rather than grepping for ExecStartPre, and is registered
individually in v2-ci.yml because an unregistered deploy test never runs.

Fixes #5483

Signed-off-by: Andy Anderson <andy@clubanderson.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. dco-signoff: yes Indicates the PR's author has signed the DCO. lgtm Indicates that a PR is ready to be merged. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[sec-check] hive-discord.service executes node bot.js from world-writable-parent /tmp/hive/discord — local code-execution as service user

1 participant