Threat model, audit checklist, and tooling for the installer and updater.
The installer runs a local HTTP server on 127.0.0.1 for the duration of an install and opens a tab
in a browser. The primary threat is a malicious web page open in any browser while the installer
is running: the page can reach http://127.0.0.1:<port>/... and try to drive the installer API or
read its responses. The updater (a privileged script inside the browser) downloads packages from the
update server, so the secondary threat is a compromised/malicious update source delivering a
tampered archive. The local-server security model is recorded as ADR
0010.
Mitigations in place:
- Session token. Every state-changing API route requires
?t=<32-hex>matching the per-run token embedded in the installer tab's URL. The token is generated from a CSPRNG (BCryptGenRandomon Windows,getentropyon POSIX); startup fails closed if secure randomness is unavailable. - No CORS. Responses carry no
Access-Control-Allow-Originheader, so cross-origin pages cannot read installer responses (the UI is same-origin). - Host/Origin validation. A request whose
Hostis notlocalhost:<port>/127.0.0.1:<port>for the port the server bound — or whoseOrigin, when present, is not that same local origin — is refused with 403 before routing. This is what stops DNS rebinding: a rebound name is same-origin from the browser's point of view, so "no CORS header" alone would not keep the response away from it. An absentHost(HTTP/1.0) is tolerated; no browser page can omit it. - Hash-verified packages. The updater verifies each zip against the published manifest hash
before extraction and copies only manifest-listed files. The installer verifies uploads against
the manifest it ingested — candidate bytes before they are stored, stored zips when the manifest
arrives (
installer_verify_upload/installer_verify_stored_zips). Exactly when that applies — and when it does not — is spelled out under "Stated invariants" below. - Zip-slip guard.
extractZipFlatten/copyFileListreject entry names that could escape the destination directory (absolute paths, backslashes, drive letters,./..). - Elevated-copy helper. When an update lands in an admin-protected install dir, the updater tab downloads a standalone helper binary and runs it outside the browser sandbox (see "Helper threat model" below).
The helper (installer/src/helper/helper_*.{c}, shipped as helper_win.exe etc.) exists to copy
config files into an admin-protected browser install dir when the plain, unprivileged copy fails. It
self-elevates exactly once (asInvoker manifest + a single runas relaunch on Windows,
pkexec/sudo on Linux, osascript … with administrator privileges on macOS); there is no
persistent elevated service and no elevation bypass — the user always sees the OS prompt.
Trust chain: the helper's argv comes only from our own updater tab (updater.js, running as
privileged chrome script), so the caller is already inside the browser's trust boundary. Before the
tab spawns the helper it verifies the downloaded bytes against the published <helper>.sha256
sidecar (issue #33) and checks the executable magic (PE/ELF/Mach-O, #275) — a tampered or corrupted
binary is refused before execution. Helper-side hardening (issue #274) treats the command line
itself as only semi-trusted: argument-shape violations (argc parity), .. path components, and
command-line/probe-filename overflow attempts are rejected with EXIT_BAD_ARGS rather than
executed. Defense here is defense in depth — none of these checks is what stands between a web page
and the helper; that distance is made of the updater's own sandbox and the hash-verified download
chain.
Four boundaries the threat model must state explicitly rather than leave to code reading:
- Upload verification is conditional.
installer_verify_upload()checks a candidate upload against the ingested manifest before the zip is stored;installer_verify_stored_zips()re-checks the stored zips when a manifest is ingested. But with no manifest reachable there is nothing to verify against (the documented fallback — the zip is its own reference),updater-ui.zipuploads are excluded (the!is_uigate inmain.c), and the EXTRACT states read the stored buffers without re-verifying. The checks gate storage, not extraction. - Privileged fetch URLs derive only from config. The installer performs no network I/O; every
URL the tab fetches (zips, manifest, self-update payload, release lists) arrives via
/api/package-urls, whichmain.cbuilds from theINSTALLER_*macros generated fromconfig/installer.conf. The one hardcoded URL in the tab (installer/web/script/20-banners.js) is awindow.open()navigation to the human-facinglatestrelease page — no bytes from it enter the install chain. - The manifest is an unsigned trust root.
hashes.jsonis served over HTTPS from gh-pages but is not signed: the hash chain detects tampering in transit or at rest, not a compromised publisher. Whoever can publish to the repository / pages controls the reference hashes; HTTPS plus the GitHub publish path is the trust anchor. --admin-copyis an unrestricted elevated copy.admin_copy_mode()copies arbitrarysrc/dstargv pairs with no path validation beyond creating the destination's parent directories (entry guard:argc >= 4inmain.c). It is safe only because the relaunch originates from our own installer/updater behind a single OS elevation prompt — treat the argument surface as privileged and validate paths in any new caller.
Run through every item; each maps to code that already exists.
- API auth — every state-changing route requires the session token. The authoritative route
classification lives in
test/e2e/installer/apiRoutes.mjs(gated:status,install,self-update,manifest,upload,waterfox,hg-tags,close-browser,open-folder,rescan,restart; plusshutdown, which is gated but answers200 ignoredto a stale token; open:ping,build-info,browsers,package-urls; token-reflecting:claim).test/unit/e2e/apiRouteContract.test.mjsdiffs that list against the routes actually registered and gated ininstaller/src/*.con everypnpm test, and the CI smoke test probes a running installer against the same sets — so this list cannot drift from code without failing CI. - No CORS headers — grep for
Access-Control-Allow-Originacrossinstaller/src/; only comments may mention it. - Token entropy —
generate_session_token()must come from the OS CSPRNG and fail closed (never degrade to a time/pid seed). - Zip safety —
extractZipFlatten/copyFileListincore/chrome/utils/updater/scriptsUpdater.sys.mjsreject unsafe entry names;ensureUpdaterUiverifies the manifest hash before extracting. - Shell usage —
system()/popen()calls in the C installer interpolate detected paths (browser binaries, profiles); paths come from user-writable files (profiles.ini, registry) so a path containing"or$()could break quoting. Reviewed, but new shell calls must use argv-array APIs (CreateProcess*,execvp) instead.tools/publish/remote-ui/updater.jsalready usesSubprocess.callwith argv arrays. The admin-copy fallback no longer shells out at all: it is an in-process buffered copy (2026-09-15 audit P2 fix); the remaining interpolated calls are the path-verifiedpkillsweep and hash/dialog helpers. - No eval / dynamic regex on untrusted input — eslint (
security/detect-eval-with-expression,security/detect-unsafe-regex) flags these. - No secrets in logs — the session token is logged at startup
(
[startup] ... session_token=...) deliberately (local, single-user diagnostic); do not log it in CI or remote contexts.
pnpm lint— ESLint (incl.eslint-plugin-security) + clang-format check +make -C installer analyze.make analyze(ininstaller/) —gcc -fanalyzerover the installer C sources, filtered bytools/analyze-c.mjsto memory-safety/UB classes (use-after-free, leaks, NULL deref, overflow, uninit). Exits nonzero on any finding. Vendoredminizis excluded.pnpm test/installer/test/test_hash.mjs— verifies the C hash algorithm matches the JS reference, so a tampered package is detected consistently.test/e2e/installer/smoke-security.mjs— CI security smoke test: boots the installer headless (--smoke-test) and asserts every state-changing/apiroute rejects a missing/wrong session token, valid tokens pass the gate, and no response carriesAccess-Control-Allow-Origin. Runs in the CI workflow on Windows afterpnpm snapshot:dev; run it locally the same way.- External reviews (e.g. Greptile on PRs) act as a second pair of eyes; treat findings as hypotheses to verify against this checklist, not as ground truth.