Skip to content

Security hardening: Phase 1 socket + install robustness (#28) + review fixes - #38

Merged
ProducerGuy merged 5 commits into
mainfrom
security-hardening-phase1
Aug 18, 2026
Merged

Security hardening: Phase 1 socket + install robustness (#28) + review fixes#38
ProducerGuy merged 5 commits into
mainfrom
security-hardening-phase1

Conversation

@ProducerGuy

Copy link
Copy Markdown
Owner

Milestone A — security hardening (Phases 0–1) + install robustness

Branch security-hardening-phase1. Do not merge — under review (Fable).

⚠️ PR base note

Base is origin/main at 340cf2e ("Bump version to 0.1.10"), which does not contain Phase 0. b92bf07 (Phase 0 — server read timeout) was committed to local main but never pushed, so it is included here as a 5th commit and appears in this diff — contrary to the original review write-up, which assumed Phase 0 was already on main. We deliberately did not push Phase 0 to trunk just to make the PR match the write-up. Phase 0 is therefore in scope for this review and has not been reviewed by anyone before now.

Commits (base 340cf2e)

  • b92bf07 Phase 0 — bound server-side client read with SO_RCVTIMEO/SNDTIMEO (5s). Included via the base gap above.
  • 30c6cbf Phase 1 — root-owned /var/run socket, 0600 owned by installing user, --owner-uid plumbed through the launchd plist.
  • 2d1adda install: copy only a version-matching app bundle, never a stale Homebrew keg.
  • 5b475b2 Fix Bug: thermalforge install self-deletes and fails; custom profiles hidden in menu bar #28: install self-delete + path-independent upgrade re-sync (temp-file + atomic rename; self-referential keg fallback; keg version read from Cellar path, never executed).
  • 83d59d4 Fable review fixes (items 1–5) — see below.

Review fixes (83d59d4)

  1. Install already-current branch — dropped try? on the ownership/perms re-assert; now throws ValidationError. launchd execs the binary as root at every boot; a silent failure on the most common path could leave a user-owned/writable root binary.
  2. DaemonServer.init — guard chown/chmod (NSLog+errno, close(fd), throw). Diagnosability, not a boundary: umask(0o077) is the unconditional boundary; both failures fail closed or are socket-equivalent.
  3. Install relaunch — relaunch only when the app was running AND a fresh version-matching bundle was installed this run; otherwise print the socket-moved guidance instead of reopening the stale bundle.
  4. /tmp socket unlink — documented why the root unlink() in a world-writable dir is safe (symlink not followed; hardlink leaves victim intact; dir → EISDIR no-op).
  5. Relaunch verification — confirm a genuinely new ThermalForgeApp pid vs the pre-install pid, rather than trusting open's exit code.

What Phase 1 bought (don't overread)

Bar moved from "any local process can drive the fans" → "any process running as uid 501 can drive the fans." Closed: cross-user access + path squatting. Not closed: same-user access — peer identity is Phase 5, deferred.

Verified

  • Build -c release clean; swift test 29/29.
  • Cross-user denial: nobody raw connect()EACCES [Errno 13] (socket present, daemon reachable, owner connects).
  • Socket rebuild: rm + launchctl kickstart -k → recreated srw------- fatty wheel, job clean, owner connects.
  • Boot ordering: daemon binary and /var/run on the same APFS Data volume (/dev/disk3s5), so /var/run exists whenever launchd can exec the daemon (settled by inspection, not reboot).
  • App on new socket path: read + write + clean quit.

Not verified without a privileged install (owner's to run)

  • Review items 1, 3, 5 execute only inside sudo thermalforge install — compiled + reasoned, not runtime-exercised here.
  • Real reboot; FileVault-locked boot; second real login account (tested via nobody).

handleClient's blocking read() on the serial accept loop had no timeout, so a connect-and-hang client wedged the daemon for every other client indefinitely. Set a 5s SO_RCVTIMEO/SO_SNDTIMEO on the client fd (mirrors DaemonClient.sendRaw, v0.1.7); on timeout read()/write() return -1/EAGAIN and the existing guard n>0 closes the fd. No path/plist/protocol change (Phase 0).
…ase 1)

Move the control socket from world-writable /tmp to root-owned /var/run (squatting structurally impossible); bind under a 0077 umask so it's 0700 from birth (no bind->chmod race); chown to the installing user + chmod 0600 — only that user and root connect. Daemon takes --owner-uid from the plist (Install.run reads SUDO_UID; absent/0 -> ValidationError, never a root-only socket that bricks the app) and refuses to start on uid 0. Migration: install unlinks legacy /tmp; uninstall removes both paths. Install also restarts the controlling user's app after an upgrade (captured non-destructively at install start) so it reloads the new /var/run path instead of the misleading daemon-down banner — non-fatal, never fails the install.

Rollback (install-touching): git checkout main && git branch -D security-hardening-phase1, then ./setup.sh from main (rebuilds b92bf07, the /tmp-socket daemon) to restore. sudo thermalforge auto works via direct SMC regardless if fans stick. Daemon self-binds either way — no launchd/binary listener disagreement.
…rew keg

Install.run() took the first EXISTING candidate bundle regardless of version, so a leftover Homebrew 0.1.x keg (opt symlink still points at it) got copied over /Applications on a direct 'sudo thermalforge install'. Now it accepts a candidate only if its CFBundleShortVersionString matches ThermalForgeVersion.current, else leaves /Applications untouched (from-source build-app assembles the correct bundle right after; a correct existing bundle is preserved). Else branch now prints each candidate's version so a miss is diagnosable.
Install.run() removed installPath before copying the daemon binary. When the
binary being run IS the one at installPath (the natural sudo invocation once on
PATH), that deleted both the running binary and the copy's own source, aborting
mid-install: nothing on PATH, emergency reset gone, launchd pointing at a
deleted inode.

Split the fix into two concerns:

Safety (unconditional): stage into installPath.new (same dir -> same fs ->
atomic rename, no EXDEV) and rename() over installPath. installPath is never
removed, so a same-path or failed install can't strand the machine, the
emergency-reset binary always exists, and a dangling symlink is replaced.

Source selection (re-sync, path-independent): install argv[0] when it's a
distinct file from installPath (from-source builds and keg-via-/opt/homebrew,
any version). When argv[0] IS installPath -- sudo's secure_path resolving to a
stale /usr/local/bin copy after brew upgrade -- copying onto itself is a
permanent no-op, so fall back to the Homebrew keg and install it iff strictly
newer. The keg version is read from its Cellar path, never by executing the
binary; a present-but-unparseable keg logs loudly instead of going silent.
1. Install already-current branch: drop try? on the ownership/perms re-assert
   and throw ValidationError. launchd execs installPath as root at every boot;
   a silent failure on the most common path could leave a user-owned/writable
   root binary. Matches installBinary()'s fail-loud treatment.

2. DaemonServer.init: guard chown/chmod return values (NSLog+errno, close(fd),
   throw writeFailed). Diagnosability, not a security boundary — umask(0o077) is
   the unconditional boundary; both failures fail closed or are socket-equivalent.
   A loud crash-loop beats a silent lockout presenting as daemon-down.

3. Install relaunch: track whether a version-matching bundle was actually
   installed this run; relaunch only when the app was running AND a fresh bundle
   landed. Otherwise print the socket-moved guidance instead of reopening the
   stale bundle (old /tmp socket compiled in).

4. /tmp socket unlink: document why the root unlink() in a world-writable dir is
   safe (symlink not followed; hardlink leaves victim intact; dir → EISDIR no-op).

5. Relaunch verification: confirm a genuinely new ThermalForgeApp pid (different
   from the pre-install pid) rather than trusting open's exit code, which can be
   0 without launching into the GUI session.
@ProducerGuy
ProducerGuy merged commit d7a456a into main Aug 18, 2026
1 check passed
@ProducerGuy
ProducerGuy deleted the security-hardening-phase1 branch August 18, 2026 04:48
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