A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper. Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users.
Go to file
dcosnet 7e94b0e690 Delete .github/workflows/ci.yml 2026-08-23 16:01:22 -04:00
config **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
docs **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
extension **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
helper **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
history **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
packaging **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
scripts **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
BLOG.md **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
DEPLOYMENT.md **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
LICENSE **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
QUICKSTART.md **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00
README.md **A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users. 2026-08-23 15:58:24 -04:00

README.md

Vestibule

A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper. Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users.

  • Author: Jeremy Anderson — dcos.net — info@dcos.net
  • License: MIT (see LICENSE)
  • Platforms: Linux is the primary target — provisioned, packaged, and installed with no signing gate. The Windows stack is complete and CI-validated but ships unsigned: a code-signing certificate costs $200–500/year, the project does not buy one, and SmartScreen warns accordingly. A donation earmarked for code signing (info@dcos.net) changes that decision.
  • Status: Production-ready and deployable. Real Argon2id unlock, real per-origin cookie preservation, real power event detection (Linux D-Bus + Windows WM_POWERBROADCAST), safe-by-default navigation (every domain blocked until the operator safelists it), dedicated unlock popup, systemd/scheduled-task supervision, CI matrix, and (Phase 2) one-command OS-level lockdown provisioning on both platforms plus Inno Setup / Flatpak packaging.

What Vestibule does

A public-facing browser that:

  1. Locks the perimeter at the OS level. AssignedAccess on Windows, cage compositor on Linux. The device is the kiosk, not an app pretending to be one.
  2. Sanitizes every session. Cookies, history, cache, formData, downloads, localStorage, indexedDB, pluginData, serviceWorkers, passwords, sessions — all wiped on idle timeout, wake-from-sleep, unlock, and browser shutdown. No partial resets.
  3. Detects power state changes. Subscribes to login1.Manager on Linux via D-Bus. Emits Wake events on resume. The extension resets the session on every wake.
  4. Hides its UI by default. A menu appears only when the mouse touches the top 3px of the viewport — the 2001 gesture, unchanged: invisible to patients, findable by operators.
  5. Gates configuration behind a wizard. Six-step flow, password- protected, covering kiosk identity, URL policy, session behavior, power management, and unlock method.
  6. Blocks the web by default. The URL policy ships in safelist mode: every domain is blocked — pages, frames, CDNs, everything — until the operator lists it. The home page's domain is validated against the safelist in the wizard and guaranteed navigable at runtime, so the kiosk can never lock itself out.

Architecture

┌─────────────────────────────────────────────────────────┐
│  OS-level lockdown (provisioned by Phase 2 scripts)      │
│  • Windows: AssignedAccess + auto-logon local account    │
│    (Shell Launcher step-down on Ent/Edu)                 │
│  • Linux: cage compositor + systemd unit on a VT         │
│  → scripts/provision-kiosk.ps1 / .sh — see DEPLOYMENT.md │
└────────────────────────┬────────────────────────────────┘
                         │ launches at boot
                         ▼
┌─────────────────────────────────────────────────────────┐
│  LibreWolf / Firefox ESR (kiosk mode)                    │
│  • --kiosk URL                                           │
│  • dedicated profile (vestibule-profile)                 │
│  • policies.json (strict lockdown, see config/)         │
│  • auto-restart via systemd / scheduled task             │
└────────────────────────┬────────────────────────────────┘
                         │ loads extension
                         ▼
┌─────────────────────────────────────────────────────────┐
│  Vestibule WebExtension                                  │
│  • URL policy engine — safelist default, domain-based   │
│    (webRequest; legacy open/blocklist/substring modes)  │
│  • hidden menu (3px-from-top gesture)                    │
│  • session reset on wake / idle / unlock                 │
│  • idle polling (browser.idle.queryState, 30s)           │
│  • admin wizard (Ctrl+Shift+V or gear icon)              │
└────────────────────────┬────────────────────────────────┘
                         │ Native Messaging (stdio JSON)
                         ▼
┌─────────────────────────────────────────────────────────┐
│  usher (Rust binary, ~1.2 MB)                            │
│  Threaded architecture:                                  │
│    • main    — stdin read loop, message dispatch         │
│    • power   — D-Bus PrepareForSleep (Linux)             │
│    • writer  — owns stdout lock                          │
└─────────────────────────────────────────────────────────┘

Why this stack

Alternative Rejection reason
Tauri 2 (WebView2) WebView2 is Edge/Chromium
Electron Ships full Chromium, ~150 MB binaries
Servo Web compat gaps on arbitrary kiosk content
Stock Firefox ESR Mozilla trademark policy complicates redistribution
Extension-only Cannot reach OS keyring or supervisor process
Per-platform native (WinUI 3 + GTK4) Two codebases, violates single-coder ethos

The chosen stack delivers: one Rust codebase for the helper, one JS codebase for the extension, Gecko as the webview (no Chromium), 1.2 MB helper binary, ~6 KB extension.

Note the Firefox row above concerns basing the shipped product on Firefox (trademark limits on redistribution). Deploying against an existing Firefox install is different and fully supported since Phase 2.1: the extension, usher, and policies are engine-standard, and the provisioning wizards detect Firefox (native, Flatpak, or snap) with LibreWolf still the preferred default. See DEPLOYMENT.md.

Quick start

See QUICKSTART.md for the fast path. Summary:

cd helper && cargo build --release && cd ..
python3 scripts/test-native-messaging.py
./scripts/install-native-host.sh    # Linux
# or: powershell -File scripts/install-native-host.ps1   # Windows

Then load extension/manifest.json in LibreWolf via about:debugging.

For production kiosks, skip the manual steps entirely: DEPLOYMENT.md provisions the whole machine (OS lockdown included) with one command per platform, and the CI-built installers are ready to ship.

Configuration

Open the admin wizard via the gear icon in the hidden menu, or Ctrl+Shift+V. The wizard enforces a setup password on first run and walks through six steps:

Step Scope
0 Set or enter admin password
1 Kiosk identity (name, home URL, attract URL)
2 URL policy (safelist default; open / blocklist / allowlist advanced)
3 Session behavior (idle timeout, onReset, persistence allowlist)
4 Power management (aware / always-on, on_wake behavior)
5 Unlock method + kiosk unlock password
6 Review and save

Two distinct passwords protect two distinct surfaces:

Password Protects Storage Algorithm
Admin Wizard access, config browser.storage.local PBKDF2-SHA-256, 100k iters
Kiosk unlock Session release file storage via usher (0600) Argon2id

Recovery: OS-level reset only. Delete the vestibule-profile/storage directory and re-run the wizard. A backup recovery code is a Phase 1 task.

Security model

Data sanitization

Three enforcement layers, defense in depth:

  1. Block all save paths — policies.json disables form history, password manager, master password creation, Pocket, Screenshots, Firefox Accounts, search suggestions, Firefox Home widgets, extension installs, popup blocking override, protocol handler registration, and encrypted media extensions. SanitizeOnShutdown wipes Cache, Cookies, Downloads, FormData, History, Sessions, SiteSettings, and OfflineApps on every browser exit.

  2. Reset on trigger events — resetSession() in background.js invokes browser.browsingData.remove() with all 11 data types on idle timeout, wake-from-sleep, unlock, and manual reset. Resets are debounced (5s window) to coalesce simultaneous triggers.

  3. OS-level defense — AssignedAccess (Windows) and cage (Linux) ensure the browser cannot be closed or escaped. The dedicated vestibule-profile directory isolates the kiosk from any user profile data.

URL policy

The default mode is safelist: every request whose hostname is not on the operator-maintained safelist is blocked. Matching is hostname-based — the entry example.org grants example.org and any subdomain, and nothing else. A URL that merely contains example.org as a substring (a query string, a path segment, a userinfo prefix) does not match; that smuggle class is exactly what domain matching closes.

Two guarantees keep the default safe without bricking the kiosk:

  • Internal schemes are always permitted (about:, moz-extension:, chrome:, resource:) — the admin wizard, the unlock popup, and about:blank keep working with an empty list.
  • The home origin is always navigable. Session reset, wake, and idle timeout all navigate home; the engine exempts the home URL's hostname so an operator error cannot strand the kiosk on its own block page. The wizard additionally refuses to save a safelist configuration whose home (or attract) domain is not listed.

Blocked top-level navigations land on an in-extension block page that names the blocked domain; blocked subresources are cancelled outright. An empty safelist therefore degrades to a kiosk that shows about:blank and nothing else — safe by default, open by explicit operator action.

Provisioned kiosks bridge the two worlds automatically: the provisioner launches the browser with the kiosk home URL on the command line, and while the policy is still the default, the extension adopts that startup page as the home URL and adds its domain as the first safelist entry. Only pages the browser was launched with qualify — a URL typed after boot is never adopted — so the default-deny posture holds against walk-up users on an unconfigured kiosk.

The legacy modes remain for deployments that need them: open (no filtering), blocklist (substring deny), and allowlist (substring allow — an empty allowlist allows everything; prefer safelist). A corrupted or unrecognized mode string fails closed to safelist semantics.

Power state awareness

Vestibule detects wake-from-sleep and resets the session. It does not block sleep — the OS power profile decides whether the device sleeps, and that is a deployment-time decision.

Platform API Event
Linux D-Bus org.freedesktop.login1.Manager.PrepareForSleep(false) ~2s after wake
Windows RegisterPowerSettingNotification + WM_POWERBROADCAST (Phase 1) PBT_APMRESUMEAUTOMATIC

Crash recovery: if LibreWolf crashes during sleep, the supervisor (systemd / scheduled task) restarts LibreWolf, usher cold-starts, and usher's first action on hello emits a Wake event to the extension (Phase 1 implementation; spike skips this to avoid wiping data on every reconnect during testing).

Phase roadmap

Phase Scope Status
0 Architecture spike: extension + usher + Native Messaging + URL filter + wake detection + session reset + admin wizard done
1 Argon2id unlock via file storage (0600); dedicated unlock popup; Windows power events; per-origin cookie preservation; librewolf.overrides.cfg; systemd + scheduled-task supervision; CI matrix done
2 OS-level lockdown provisioning (AssignedAccess wizard on Win, cage systemd unit on Linux); packaging (Inno Setup on Windows, Flatpak on Linux); full deprovision done
3 Crash auto-restart integration testing; telemetry hooks; remote management API stub; always-on power-inhibit mode planned
4 Store distribution (Flathub submission); additional package formats (MSI, AppImage, .deb) only if deployments demand them planned
5 Remote management API; retro 2001 theme; accessibility pass; contributor docs planned
5+ Webcam presence detection (see TODO) deferred

Webcam presence detection

Deferred to Phase 5+. When a webcam is detected at startup, usher will use it to:

  1. Detect "user walked away" — no face visible for N seconds triggers immediate session reset (faster than the idle timeout).
  2. Detect "different user approached" — face embedding mismatch triggers reset.

Privacy guarantees (non-negotiable):

  • Frames processed in-memory only, never written to disk
  • Face embeddings never leave the local process
  • No telemetry, no cloud API calls
  • Webcam LED respected; no capture while camera is in use by another application

Deferred because: privacy review must precede any biometric code; OpenCV/dlib adds ~50 MB to the usher binary; the idle timeout and wake detection in the spike cover the common cases. See config/vestibule.toml.example [presence] block for the proposed schema.

Honest limitations

  1. LibreWolf release cadence tracks Firefox ESR (~4 weeks security, ~12 months major). CI matrix tests current ESR; operators should test before upgrading.
  2. Windows binaries ship unsigned — a standing decision, not an oversight. A code-signing certificate costs $200–500/year and the project does not buy one; SmartScreen warns on the usher binary and the installer. That is why Linux is the primary target: it has no equivalent gate. A donation earmarked for code signing (info@dcos.net) changes the decision; until then, Windows operators verify the SHA-256 from the release notes.
  3. --kiosk is not complete lockdown. It removes UI chrome but does not block all escape vectors. OS-level AssignedAccess / cage setup is mandatory for production deployments — and since Phase 2 it is one command: DEPLOYMENT.md covers interactive and unattended provisioning plus one-command deprovision on both platforms.
  4. usher force-exits on stdin close. The power thread is blocked on an OS-level receive call (D-Bus / GetMessage) and cannot be interrupted without an async runtime. This is the correct shutdown strategy for this architecture — the power thread has no cleanup.
  5. localStorage is not selectively preserved. When dataPersistenceAllowlist is non-empty, cookies for allowlisted domains are preserved via cookies.getAll() + selective cookies.remove(). localStorage is wiped unconditionally — the WebExtension API cannot enumerate origins for selective removal. localStorage typically holds UI state, not auth tokens; the risk is low and documented in code.
  6. Safelist mode blocks third-party resources too. A page on a safelisted domain that pulls scripts, fonts, or images from a CDN will render broken until the CDN's domain is also safelisted. This is by design — data can leave through subresource requests, so they are gated like navigations — but it means the operator's list must cover every domain the kiosk content depends on. The wizard's step-2 help text says so, and the browser console logs each block with the exact URL.
  7. Windows power events require testing on real hardware. The RegisterSuspendResumeNotification + WM_POWERBROADCAST implementation is complete and cfg-gated, but has not been tested on a physical Windows machine. The Linux D-Bus path is fully tested.

Documentation

  • DEPLOYMENT.md — operator runbook: kiosk provisioning, unattended rollout, exit codes, deprovision
  • QUICKSTART.md — fast-path install and verify
  • BLOG.md — narrative: 2001 VB6 to 2026 Rust
  • history/ — the scrubbed 2001 VB6 source, kept as a non-shipping historic artifact (nothing in the build depends on it)
  • docs/QA-PASS.md — production readiness review (Mixture-of-Experts panel)
  • LICENSE — MIT

Credits

Original concept: Jeremy Anderson, 2001 — coded on co-op while in school, VB6. Modern implementation: Jeremy Anderson, 2026.