White Paper browser-securityelectronprivacymullvadtypescript

Bastion: Module Contract, Layered Proxying, and Fail-Closed Routing in a Personal Electron Browser

Architecture of Bastion, a security-focused Electron browser: the manifest-driven module system, network router, proxy layers, session handling, and stated limits.

October 13, 2026

Overview

Bastion is a personal desktop browser written in Electron and strict TypeScript. Tabs are WebContentsViews in a BaseWindow. The chrome UI is a separate view pinned on top, which grows to cover content when the omnibox dropdown or a panel needs the space. Renderers are sandboxed, contextIsolation is on, and node is off.

Pages browse in an isolated session, persist:bastion. The chrome UI lives on the default session and is unreachable from pages.

Source layout

src/main/      window shell, tabs, settings store, event bus,
               module loader, network router, proxy controller,
               bastion:// protocol
src/preload/   chrome.cts (UI bridge), tab.cts (fingerprint hooks)
src/renderer/  browser chrome, ES modules over bastion://chrome
src/pages/     newtab, error, https-warning
src/shared/    types.ts, the module contract and IPC payloads
modules/       one folder per feature plus manifest.json

Core holds only what modules cannot own. Everything else is a module.

Module contract

modules/manifest.json lists each module with an id, an entry, and the flags early, defaultEnabled, and locked. early modules load before app.whenReady(), which is where command-line switches go. The rest load after the window is up. Heavy work stays inside the module and runs deferred. adblock builds its engine asynchronously for this reason.

A module default-exports a BrowserModule: meta, an optional settingsSchema, init(ctx), enable(), and disable(). The loader checks that meta.id matches the manifest. disable() must reverse enable() completely, because the settings toggle invokes both at runtime.

The ModuleContext replaces global Electron state:

  • ctx.settings: typed get, set, onChange, onAction, persisted per module
  • ctx.bus: tab:created, tab:navigated, tab:closed, vpn:state, session:burned, tab:load-failed
  • ctx.state: cross-module flags such as private-mode and vpn:connected
  • ctx.network.onBeforeRequest(id, priority, fn)
  • ctx.proxy.setLayer(name, priority, rules | null)
  • ctx.tabs: listing, navigation guards, context menu items, forEachWebContents for current and future contents
  • ctx.onBrowserSession(cb) and ctx.browserSessions()
  • ctx.ui: toolbar indicators, toasts, full-screen overlays
  • ctx.registerIpc(name, fn), exposed to the chrome as mod:<id>:<name>

A handler on tab:load-failed can set handled = true to claim the event.

Network router

Electron permits one webRequest listener per event. Core registers them and dispatches to modules in priority order. https-only is 10 and adblock is 20. Modules never call session.webRequest. https-only upgrades http to https at the navigation layer and the network layer, which catches server redirects. It also drops insecure subresources and shows an interstitial with per-site exceptions. adblock uses the Ghostery engine with EasyList and EasyPrivacy, and adds CSP injection and cosmetic CSS.

Proxy controller

Proxy rules are layers. Kill-switch black-hole is 100, Mullvad SOCKS is 50, direct is the fallback. The highest populated layer applies across every session, and any layer change also closes live connections.

The vpn-mullvad module polls am.i.mullvad.net/json and drives a toolbar pill showing the server, EXPOSED, or OFFLINE. Optional SOCKS5 routing targets 10.64.0.1:1080 or a per-server address. That endpoint only exists inside the tunnel, so a tunnel drop breaks routing instead of leaking.

kill-switch requires vpn-mullvad. On a drop it applies the black-hole proxy, severs sockets, and shows a full-screen warning. It is off by default.

Session manager

The manager owns the default session and a fresh in-memory session for each throwaway or circuit tab. Each session receives the network router, bastion:// pages, the proxy configuration, and every module’s per-session setup through ctx.onBrowserSession(), covering permissions, UA, and downloads. Circuit tabs opt out of the global proxy and pin Direct, Mullvad SOCKS5, or Tor at 127.0.0.1:9050.

Media downloading

media-downloader detects videos, GIFs, and HLS or DASH streams passing through a page. Direct files save through the session. HLS is assembled natively: segment fetch, AES-128 decrypt, concat to .ts or .mp4. DASH is detected and needs ffmpeg to mux.

The optional ytdlp module, off by default and Windows-only for now, routes pasted page URLs through yt-dlp and ffmpeg. It downloads about 90 MB of binaries into the app data folder on first use, updates yt-dlp daily, goes through the active Mullvad SOCKS proxy, and refuses to run while the kill switch is engaged. It executes a fetched binary, which SECURITY.md addresses.

Verification and limits

BASTION_VERIFY=1 exercises file:// refusal and the download guard against the running app. SECURITY.md documents the sandbox, scheme gates, SSRF and path-traversal guards on downloads, device denial, and IPC sender checks.

The documented gaps: Bastion trails Chrome on engine patches, the custom code is unaudited, and Sec-CH-UA client hints still advertise Chromium. The standardized UA covers the classic string and navigator.userAgent. Bastion is a privacy tool, not a high-assurance security boundary.