Open Bug 1923901 Opened 2 years ago Updated 23 hours ago

[macOS] Make it possible for standard users to keep Firefox updated without elevation

Categories

(Toolkit :: Application Update, enhancement, P3)

enhancement

Tracking

()

People

(Reporter: spohl, Assigned: spohl)

References

(Depends on 1 open bug, Blocks 2 open bugs)

Details

(Whiteboard: [fidedi-ope])

Attachments

(6 external files)

We would like to explore ways to allow for standard users to keep Firefox updated without requiring elevation on each update. The initial idea is to modify the current LaunchDaemon (which is used temporarily during elevated updates) to become a permanent daemon after a first elevated update and download & apply updates as they become available.

Priority: -- → P3
Duplicate of this bug: 2004946
Depends on: 2054404

Each Firefox edition (Release, Developer Edition, Nightly, ...) now builds its own privileged helper, derived from the browser's bundle id: "<MOZ_MACBUNDLE_ID>.updater" (e.g., org.mozilla.firefox.updater, org.mozilla.firefoxdeveloperedition.updater, org.mozilla.nightly.updater). The previous shared "org.mozilla.updater" identifier prevented two editions from coexisting on disk under SMJobBless's static-identifier model: installing one would overwrite the other's privileged helper.

This was an accepted limitation of the previous design (because the helper was reinstalled on every elevation and removed on completion, the conflict window was small), but it becomes load-bearing as we move to a persistent helper across updates: each edition needs its own helper and its own SMJobBless trust relationship so they don't overwrite each other's installed binary.

Changes:

  • The helper's CFBundleIdentifier in updater.app's Info.plist and the Launchd.plist's Label and MachServices key are now substituted at build time from MOZ_MACBUNDLE_ID. Launchd.plist is renamed to Launchd.plist.in and preprocessed; moz.build's -sectcreate now references the preprocessed copy in $(DIST)/bin.
  • The browser bundle's SMPrivilegedExecutables key is similarly substituted, so SMJobBless's identity match holds.
  • The helper binary copied into Firefox.app/Contents/Library/LaunchServices/ is renamed to match the per-edition id, since SMJobBless reads the binary from a path whose basename is the bundle id.
  • The smprivilegedexec_requirements code-signing requirement now embeds "$(MOZ_MACBUNDLE_ID).updater" as the identifier clause.
  • MacLaunchHelper.mm reads the helper bundle id from the calling bundle's SMPrivilegedExecutables dictionary at runtime (single key) rather than hard-coding "org.mozilla.updater". This keeps the Info.plist as the single source of truth across editions.
  • UpdaterHelperProtocol.h drops the kUpdaterHelperMachServiceName macro in favor of GetUpdaterHelperBundleID(), implemented in launchchild_osx.mm by extracting the embedded __TEXT,__info_plist section from the running binary and reading CFBundleIdentifier. The same binary is used both as the unelevated updater (its main bundle is updater.app) and as the privileged helper (running from /Library/PrivilegedHelperTools/<id>); the embedded section is available in both cases.
  • The argv[0] check in updater.cpp that distinguishes the elevated helper invocation from the unelevated one now matches by the /Library/PrivilegedHelperTools/ prefix rather than the full hard-coded path.

The previously hard-coded binary file name "org.mozilla.updater" inside updater.app/Contents/MacOS/ is unchanged: that's the executable file inside the .app bundle (CFBundleExecutable), not the SMJobBless-managed identity. Only the SMJobBless-installed copy (at /Library/PrivilegedHelperTools/<helper-id> and at Firefox.app/Contents/Library/LaunchServices/<helper-id>) takes the per-edition file name.

The allowed-duplicates list in browser/installer/allowed-dupes.mn is also updated: the helper binary copied to Firefox.app/Contents/Library/LaunchServices/<helper-id> is a known duplicate of the one inside updater.app, and the path now embeds the per-edition MOZ_MACBUNDLE_ID rather than a hard-coded "org.mozilla.updater".

Stop tearing down the privileged helper at the end of each update so that subsequent elevated updates can reconnect to the same helper without re-prompting for admin credentials. This delivers the user-visible benefit of the project: macOS standard users authenticate with admin credentials only once (during the very first elevated update or during installation), then every subsequent update applies silently.

Helper-side: CleanupElevatedMacUpdate no longer removes /Library/PrivilegedHelperTools/<helper-id>, no longer removes /Library/LaunchDaemons/<helper-id>.plist, and no longer calls "launchctl remove". The launchd job is configured one-shot (RunAtLoad with no KeepAlive), so as the helper's main() returns launchd reaps the process; the binary and plist stay on disk, and the mach service remains registered. The next inbound message respawns the daemon from the same on-disk binary on demand.

To make this safe, the cleanup path now flushes the applyUpdate XPC reply before letting main() exit. Previously the synchronous "launchctl remove" served as an implicit barrier (the kernel waited for launchctl to finish before terminating the helper, which gave the reply enough time to reach the wire); without it, the unelevated client can see a connection invalidation instead of the success reply and incorrectly treat a successful update as failed. The listener delegate now stashes the active connection in a global, and CleanupElevatedMacUpdate calls [connection scheduleSendBarrierBlock:] with a 5-second-bounded semaphore wait so the reply is guaranteed to flush before the helper exits.

Client-side: LaunchElevatedUpdate (and the analogous DMG-launched first-install path in MacRunFromDmgUtils) now checks whether /Library/PrivilegedHelperTools/<helper-id> already exists before calling SMJobBless. If it does, we skip InstallPrivilegedHelper, and therefore skip its admin-authentication dialog, entirely. launchd will spawn the existing helper from disk on demand when the unelevated child connects to its mach service.

Notes on staleness: with this commit alone, the on-disk helper binary is whatever was installed by the last SMJobBless run; subsequent updates use that binary to apply the next .mar even though it may predate the new updater being installed. This is acceptable because applyUpdate's protocol is stable, but it is not future-proof. The next commit in this series adds proactive self-update of the helper before applyUpdate so the helper is always refreshed to match the update being applied.

Because the persistent helper is now reachable on demand rather than only during a single SMJobBless-authorized session, this commit also gates the elevated apply on the same trust checks SMJobBless makes at install time, so persistence does not widen what the root helper will act on:

  • Before applying, the helper validates that the target install directory is a genuine copy of this Firefox edition, signed by the same authority as the helper, checked against MOZ_SMAUTHORIZEDCLIENTS_REQUIREMENTS on the elevated path.
  • The MAR downgrade check floors to the installed application's version, read from its application.ini, rather than this binary's build-time MOZ_APP_VERSION (which for a persistent helper can lag the install). When running elevated and that version cannot be read from an already-validated target, the update is refused rather than falling back to the stale floor.
  • Every update file operation resolves its path and must remain within the update directory before the helper acts on it, and group ownership and permissions are not applied to a target that is itself a symlink.
  • macOS never elevates staging (it writes only to the user-writable update directory and runs in the background), so the elevated helper refuses a staging invocation and only ever applies directly to the validated install or swaps in an already-staged update.

With the helper persisting across updates (Bug 1923901 PR 3), the on-disk binary at /Library/PrivilegedHelperTools/<id> can lag behind the staged updater being applied to /Applications. Add a self-update hop so the helper is always refreshed to match the staged updater before applyUpdate runs, without going back through SMJobBless and its admin-authentication prompt.

Protocol additions:

  • getBuildIDWithReply: replies with the helper's MOZ_BUILDID. The build id changes on every build, so any actual code change to the updater triggers a self-update. The standalone updater cannot include the top-objdir buildid.h directly, so the value is sourced through a generated UpdaterBuildId.h (preprocessed from buildid.h, mirroring how application.ini picks up the build id).
  • selfUpdateWithBinaryPath:reply: replaces the on-disk binary at /Library/PrivilegedHelperTools/<id> with the file at the supplied path, then exits so launchd respawns the daemon from the new binary on the next inbound message.

Helper-side selfUpdate is engineered to be safe under partial failures and against a malicious caller:

  1. Copy the caller-supplied binary to /Library/PrivilegedHelperTools/<id>.new (idempotently removing any leftover .new from a prior failed attempt first). The candidate is staged into the root-only PrivilegedHelperTools directory before it is validated. The staged item is then required to be a real regular file: copyItemAtPath: preserves symlinks, and a staged symlink would let the caller redirect the subsequent chown/chmod, code-signing validation, and rename to content outside this root-only directory (a check-then-swap back into TOCTOU, and an arbitrary chown/chmod-as-root primitive).
  2. Set ownership root:wheel and mode 0555 to match SMJobBless's installed binary.
  3. Validate the staged copy's code-signature against MOZ_SMPRIVILEGEDEXECUTABLES_REQUIREMENTS, the same identity check SMJobBless makes at install time. Validating the staged copy rather than the caller-supplied path closes a time-of-check/time-of-use hole: the unelevated caller controls the source path and could otherwise swap the file for an unsigned binary between the check and the copy. On failure the staged copy is removed and the existing helper is left untouched, so a malicious or corrupted candidate never reaches the canonical helper path.
  4. POSIX rename(<id>.new, <id>): atomic on the same filesystem; this is the moment the canonical helper flips from old to new.
  5. Reply success.
  6. Flush the reply onto the wire via scheduleSendBarrierBlock so the unelevated client doesn't see a connection invalidation instead of the success reply.
  7. exit(0). launchd reaps; the next message respawns from the new on-disk binary.

Any failure in steps 1-4 leaves the existing helper untouched and returns success=NO with a human-readable error message. The unelevated client logs the error and proceeds against the existing helper rather than failing outright; applyUpdate's protocol is stable, so a stale helper can still service the update even if self-update couldn't refresh it this round.

Client-side: ServeElevatedUpdate now factors connection creation into MakeUpdaterHelperConnection (so we can reconnect after the helper exits post-self-update), and gates applyUpdate on a build-id probe + optional self-update:

  • ProbeHelperBuildID with a 5-second timeout. nil on RPC error / timeout (an old helper without getBuildID, or a non-responsive helper); treat as "skip self-update, applyUpdate best-effort".
  • If the helper's build id doesn't match @MOZ_STRINGIFY(MOZ_BUILDID), call RequestHelperSelfUpdate with our own argv[0] (the unelevated client and the staged helper are the same binary, both extracted from the same .mar). 30-second timeout to accommodate slow disks. On success, invalidate the connection and create a new one so subsequent RPCs land on the respawned daemon.

The fast path (build ids match) is a single round-trip overhead on top of applyUpdate. The self-update path adds two more round-trips plus the rename, but is amortized: once the helper's build id catches up, subsequent updates skip self-update entirely.

selfUpdate also refuses to move the helper backwards: the updater's Info.plist is stamped with its build id (MozUpdaterBuildID), and before replacing the on-disk binary the helper rejects any candidate whose build id is older than the running helper's.

With the helper persisting on disk across updates, it can outlive its associated Firefox installation (e.g., the user drags Firefox to the Trash without going through any update). Without a cleanup mechanism the privileged helper would sit installed indefinitely. Add a daily launchd-scheduled wake during which the helper checks whether its paired Firefox is still present and self-uninstalls if not.

Launchd.plist.in adds StartInterval=86400, scheduling the daemon to run once per day. Combined with the existing MachServices entry, launchd will spawn the helper either when an XPC client connects (normal update path) or when the daily timer fires (validation path), whichever comes first.

ObtainUpdaterArguments now bounds its semaphore wait to 10 seconds and loops. On each timeout it treats the wake as a periodic-validation fire only when no client connection is active (checked under the connection lock); if a client is connected it keeps waiting, because a client's pre-applyUpdate handshake (build-id probe plus self-update) can run longer than one window, and the connection's invalidation handler will wake the main thread if the client goes away. This guarantees the validation/self-uninstall path can never run while a client is mid-update.

When the wake is a genuine periodic fire with no client:

  • GetAssociatedBrowserBundleID strips ".updater" from the helper's own bundle id (e.g., "org.mozilla.firefox.updater" -> "org.mozilla.firefox").
  • ValidateAssociatedFirefoxInstallation queries NSWorkspace URLForApplicationWithBundleIdentifier: for that id and, if found, validates the bundle's code-signature against MOZ_SMAUTHORIZEDCLIENTS_REQUIREMENTS, the same trust check SMJobBless makes at install time, which guarantees we're looking at a Firefox signed by the same authority as the helper.
  • On a successful validation the consecutive-failure counter is cleared. On a failure the counter is incremented, and only once it reaches a small threshold does UninstallOrphanedHelper remove /Library/PrivilegedHelperTools/<id>, /Library/LaunchDaemons/<id>.plist, the counter file, and run "launchctl remove". The lookup is unreliable from a GUI-less root daemon (no logged-in user / session 0), so requiring several consecutive daily failures (and failing safe: any counter I/O error keeps the helper) ensures a transient miss can never uninstall a still-valid helper.

Users who deploy Firefox via the enterprise .pkg installer authenticate once at install time (the .pkg installer always elevates), so we can piggy-back on that elevation to pre-install the privileged updater helper. With this change, .pkg-installed Firefox never prompts the user for admin credentials again on macOS: the first update and every subsequent update both go through the persistent helper that the installer placed at /Library/PrivilegedHelperTools/ during the .pkg install.

Users who install via the .dmg drag flow are unaffected; they still follow the existing path of one SMJobBless prompt on first elevated update, then silent updates after that via the persistent helper.

The .pkg flat-package format already had Payload + Bom + PackageInfo; this commit adds a Scripts archive (same pbzx-wrapped LZMA-compressed cpio format as Payload) containing a postinstall script. The script runs as root via the macOS Installer with $2 set to /Applications and:

  1. Reads the privileged helper bundle id baked into the postinstall at .pkg-build time from the source .app's SMPrivilegedExecutables (the single key in that dict).
  2. Copies <App>.app/Contents/Library/LaunchServices/<helper-id> to /Library/PrivilegedHelperTools/<helper-id>, sets root:wheel and mode 0555 to match what SMJobBless installs.
  3. Writes a launchd plist at /Library/LaunchDaemons/<helper-id>.plist that mirrors the embedded Launchd.plist (Label, MachServices, RunAtLoad, StartInterval=86400 for the periodic-validation wake from PR 5) plus a Program key naming the installed binary. The Program key is required here because, unlike the SMJobBless path (where the plist is embedded in the binary and launchd derives the executable from that association), this is a free-standing plist loaded via launchctl bootstrap and launchd has no other way to locate the executable.
  4. Bootstraps the daemon via launchctl bootstrap system. If a stale registration exists from a prior install, it is bootout-ed first so launchd's bookkeeping ends up associated with the freshly-installed binary.

The privileged-helper install is best-effort. The .app payload is already in place by the time the postinstall runs, so the script never fails the overall Firefox installation: each helper-install step is guarded, failures are logged, and the script always exits 0. A failed helper install simply falls back to the per-update SMJobBless authentication prompt.

Build-system changes:

  • pkg.py grows create_scripts_dir, which materializes a Scripts directory with the substituted postinstall, and create_pkg now pbzx-packs that directory into the .pkg's Scripts archive alongside Payload and Bom.
  • save_text_file gains an optional mode parameter so the postinstall can be made executable in the same call that writes it (existing tests patch save_text_file as a single unit; this keeps the patch surface unchanged).
  • Distribution.template flips require-scripts to "true".
  • PackageInfo.template declares <scripts><postinstall .../></scripts>.
  • postinstall.template is the new shell-script template; the ${app_name} and ${helper_id} tokens are filled at .pkg-build time and the $${VAR} literals are emitted as ${VAR} shell variable refs.
  • test_pkg.py gains coverage for create_scripts_dir (the rendered postinstall substitutes the build-time tokens, keeps literal shell refs like $2/${VAR}, carries the Program key, and is executable), for get_helper_bundle_id (single / missing / multi-key), and for save_text_file's mode parameter.

If the .app being repackaged doesn't have a SMPrivilegedExecutables dict (e.g., a build configured without elevated updates), the postinstall is still generated but exits early at runtime when it fails to find the helper binary at the expected path: the .pkg install completes normally and the user sees no error.

Blocks: 2061897
Whiteboard: [fidedi-ope]
Severity: -- → N/A

The privileged helper checked the install directory's signature and applied the update by path, so a rename of the bundle or one of its ancestors could change what it checked or updated. It now runs the signature checks and the channel lookup on a clone of the pinned directory in a root-only directory, and applies the update through the directory's /.vol path. An install that can't be cloned there, for example one on another volume, is refused.

Attachment #9677613 - Attachment description: Bug 1923901: Check and update the pinned install directory so that renaming it can't change what the helper works on. r?#application-update-reviewers,yjuglaret! → Bug 1923901: Make the privileged helper check and update a root-only clone of the install, then swap it into place. r?#application-update-reviewers,yjuglaret!
Attachment #9607022 - Attachment description: Bug 1923901: Make the privileged updater helper bundle id per-edition. r=#application-update-reviewers → Bug 1923901: Make the privileged updater helper bundle id per-edition. r?#application-update-reviewers,yjuglaret!
Attachment #9607023 - Attachment description: Bug 1923901: Persist the privileged updater helper across updates. r=#application-update-reviewers → Bug 1923901: Persist the privileged updater helper across updates. r?#application-update-reviewers,yjuglaret!
Attachment #9607024 - Attachment description: Bug 1923901: Self-update the privileged helper before applyUpdate. r=#application-update-reviewers → Bug 1923901: Self-update the privileged helper before applyUpdate. r?#application-update-reviewers,yjuglaret!
Attachment #9607025 - Attachment description: Bug 1923901: Periodically validate the privileged helper's associated Firefox install and self-clean if absent. r=#application-update-reviewers → Bug 1923901: Periodically validate the privileged helper's associated Firefox install and self-clean if absent. r?#application-update-reviewers,yjuglaret!
Attachment #9607026 - Attachment description: Bug 1923901: Install the privileged updater helper from the .pkg postinstall. r=#application-update-reviewers,#build → Bug 1923901: Install the privileged updater helper from the .pkg postinstall. r?#application-update-reviewers,yjuglaret!,#build
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: