Skip to content

Security

gmux gives full interactive terminal access to your machine. This page documents the threat model, the safeguards in place, and the design decisions behind them.

gmux runs an HTTP server that serves a web UI, a REST API, and WebSocket connections that carry live terminal I/O. Anyone who can reach this server can:

  • List all your sessions
  • Read terminal output (including secrets printed to the screen)
  • Type into any terminal (execute arbitrary commands)
  • Launch new processes
  • Kill running sessions

This is equivalent to SSH access. The server must not be reachable by anyone who shouldn’t have it.

Default posture: localhost only, always authenticated

Section titled “Default posture: localhost only, always authenticated”

gmuxd uses two listeners:

  1. Unix socket (~/.local/state/gmux/gmuxd.sock) for local CLI-to-daemon IPC. No authentication needed; access is enforced by filesystem permissions (0600 socket, 0700 directory). Unlike a TCP port, it is never auto-forwarded by VS Code, Docker Desktop, or SSH port forwarding.

  2. TCP listener (127.0.0.1:8790 by default) for browser access. Every request must present a bearer token or session cookie — the only unauthenticated paths are the login page itself and the web-app manifest. There is no option to disable auth, and daemon shutdown is refused on the TCP listener entirely (it is a Unix-socket-only operation), even with valid credentials.

By default, the TCP listener binds to 127.0.0.1:

  • ✅ Accessible from the local machine only
  • ❌ Not reachable from LAN, even without a firewall
  • ❌ Not reachable from Tailscale or other VPNs
  • ❌ Not reachable from the internet

The bind address can be changed via GMUXD_LISTEN for container and VPN deployments (see Running in Docker) — it is validated at startup and only accepts loopback, private (RFC 1918), link-local, CGNAT, ULA, or all-interfaces addresses; binding directly to a public IP is refused (use Tailscale instead). The port can be changed in the config file.

The token is a 256-bit random value generated on first start and persisted at ~/.local/state/gmux/auth-token. It can be presented via an Authorization: Bearer <token> header or an HTTP-only session cookie.

The two credentials behave very differently in a browser, so they are constrained differently:

  • Bearer header — attached explicitly by the client. A web page on another origin cannot forge it, so bearer-authenticated requests carry no origin constraint. CLI, API, and hub↔spoke peering traffic all use bearer auth and are unaffected by everything below.
  • Session cookie — attached ambiently by the browser, regardless of which page initiated the request. Cookie-authenticated requests must therefore prove they came from the gmux UI itself.

The cookie is HttpOnly and SameSite=Strict (and Secure when served over HTTPS), which blocks classic cross-site attacks like DNS rebinding: a malicious page on evil.com that resolves to your loopback address gets neither the token nor the cookie. But SameSite reasons about sites, not origins — and ts.net is on the Public Suffix List, which makes <tailnet>.ts.net the registrable domain. Every other web service on your own tailnet is same-site with gmux, so SameSite=Strict does nothing between them. A compromised or XSS’d co-tenant tailnet service could otherwise ride the cookie into gmux’s control plane — which, via a WebSocket to a terminal, is code execution.

So for cookie-authenticated requests, gmuxd enforces same-origin:

  • WebSocket upgrades must originate from the gmux origin itself. This closes cross-origin WebSocket hijacking of terminal I/O, the highest-value path.
  • State-changing requests — any POST/PUT/PATCH/DELETE, on any path — must carry Sec-Fetch-Site: same-origin; browsers that don’t send fetch metadata fall back to an Origin header matching the request’s own host. Anything else gets 403.
  • Reads over GET are exempt: gmuxd never emits CORS headers, so a foreign page cannot read the response anyway.

The check is a single dynamic comparison against the request’s own Host (honoring X-Forwarded-Host behind proxies that rewrite it) — whatever hostname the browser used to load the UI is, by definition, the hostname it sends in both Origin and Host. Localhost, LAN IPs, tailnet FQDNs, and reverse-proxy vhosts (e.g. Traefik routing gmux.example.com) all work without configuration.

Why not a hostname allowlist? An earlier design validated the Host header against a configured list of trusted names (GMUXD_TRUSTED_HOSTS). It was rejected: an allowlist must enumerate every name the daemon can legitimately be reached by — localhost, the bind host, every tailnet FQDN, every reverse-proxy hostname — which is brittle and easy to misconfigure into a lockout, while adding nothing the token, the cookie attributes, and the same-origin check don’t already provide.

The UI also sends Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY on every response, so gmux can never be embedded in another page’s frame.

The full decision record is ADR 0020.

The login page itself is self-contained: no external resources are loaded before authentication (the brand font is embedded), so nothing leaks pre-auth and it works air-gapped.

  • Per-session runner sockets live in a per-user directory under the state dir (~/.local/state/gmux/run/sessions, mode 0700) — never in world-shared /tmp, where another local user could squat the directory.
  • Clipboard paste files are created with 0600 permissions so pasted secrets aren’t readable by other local users.
  • Session IDs and slugs are validated against a strict allowlist before any filesystem or URL use, so an attacker-influenced ID can’t carry .. or path separators into the state directory.

Remote access is available via a separate, optional Tailscale (tsnet) listener. When enabled, gmuxd joins your tailnet and serves on https://hostname.your-tailnet.ts.net. See Remote Access for setup.

This listener combines four protections:

  • Network isolation. The tsnet listener only accepts connections through the Tailscale network. It is not reachable from the public internet or local networks.
  • Encrypted transport. HTTPS only, using certificates issued automatically by Let’s Encrypt through Tailscale. No HTTP fallback, no TLS downgrade.
  • Identity verification (outer gate). Every request is authenticated by calling Tailscale’s local WhoIs API, which returns the connecting peer’s cryptographic identity. The peer’s login name (e.g. user@github) is checked against the configured allow list. This identity is derived from Tailscale’s WireGuard key exchange and cannot be forged without possessing the peer’s private key.
  • Token authorization (inner gate). Passing the allow list lets you reach the host; the request must also carry the host’s bearer token, exactly like the localhost listener. Identity is necessary but not sufficient (ADR 0008).

The two-gate design is deliberate. A tailnet routinely mixes trust levels — a laptop, a phone, a throwaway container running an untrusted agent — yet they may all authenticate as the same owner. If identity alone granted the full API, a single compromised node (e.g. a container escape) could launch shells and inject keystrokes on every other gmux machine on the tailnet. Requiring the host’s token means a node can only drive hosts whose token it holds: your machine holds a container’s token, never the reverse, so the compromise can’t pivot back. For the same reason, tailscale autodiscovery was removed — auto-connecting peers without a token is exactly the hole the token closes.

The Tailscale account that owns the node is automatically added to the allow list at startup. Additional login names in the allow list are checked strictly; there is no “allow all” default. Denied connections are logged with the peer’s identity for auditing.

The allow list matches login names and device tags, not device names. Tailscale device names are unique within a tailnet, but they can be renamed by the user. A renamed device would silently fall off the allow list. Login names are stable identities tied to the authentication provider (GitHub, Google, etc.). For per-device access control, use Tailscale ACLs.

Tagged devices carry no user identity — WhoIs reports the pseudo-login tagged-devices — so they cannot be allowed by login name. Instead, add the device’s ACL tag (e.g. tag:gmux) to the allow list. Tags are assigned via your tailnet’s ACL policy and can only be changed by tailnet admins, making them a stable, admin-controlled identity.

The allow list is for when you have multiple accounts on the same tailnet (e.g. a personal and a work account) or when you’ve shared the gmux device to another tailnet you own. It is not designed for giving other people access. See the not a collaboration tool caveat.

Why there’s no “disable auth” option

Section titled “Why there’s no “disable auth” option”

gmux faces the same security problem as Jupyter: both serve arbitrary code execution over HTTP in a browser. Jupyter’s approach is to allow disabling auth (NotebookApp.token = ''), which they document as “NOT RECOMMENDED.” In practice, people do it constantly, leading to exposed notebooks running as root on public clouds. gmux takes the stricter position: auth cannot be disabled, period. ssh doesn’t have a “disable auth” flag, and gmux gives you the same power as SSH.

Without auth, a misconfiguration is remote code execution. If you set GMUXD_LISTEN=0.0.0.0 to access gmux from another device and forget to change it back, anyone on the same network can type commands into your terminal. Take that laptop to a coffee shop, and every person on the Wi-Fi has full shell access to your machine. The token ensures that even if the listener is accidentally exposed, access still requires a secret that only you have.

You don’t lose anything. The token is a plain file on disk at ~/.local/state/gmux/auth-token. Any integration that needs programmatic access can read it and set the Authorization: Bearer <token> header. A reverse proxy like Traefik can inject the token into forwarded requests via a headers middleware, so you never interact with it directly.

For container deployments, the GMUXD_TOKEN environment variable can seed the token file on first start. User-provided tokens must be at least 64 hex characters (256 bits) — the same strength as auto-generated ones. If a token file already exists, the env var must match or gmuxd refuses to start. The variable is unset from the process after consumption so child shells don’t inherit it. See Environment variables for details.

The file remains the primary storage. Environment variables are inherited by child processes, visible in /proc/*/environ, and tend to appear in CI logs and Docker inspect output. CLI flags show up in ps and shell history. A file with 0600 permissions is the smallest attack surface for a long-lived secret. The env var is a provisioning convenience for containers, not a replacement for the file.

The examples/ directory has ready-to-run Docker Compose setups showing how to handle auth in common deployment scenarios: Tailscale, WireGuard, and Traefik with OIDC.

For easy access from other devices, use Tailscale remote access. It gives you HTTPS with automatic certificates and cryptographic identity verification; access still requires the host’s token (run gmux auth to get its connect URL).

The config file is strictly validated at startup. Silent fallback to defaults is dangerous for security settings: a typo like alow instead of allow would silently result in an empty allow list. Keys removed in 2.0 (tailscale.hostname, [[peers]], discovery.tailscale) produce a warning instead of an error, so an old config won’t brick the daemon. See host.toml reference for the full list of rules.

  1. Don’t change the port and assume you’re safe. Security comes from the bind address and auth, not from obscurity.
  2. Audit your allow list. Everyone on it gets full terminal access. Treat it like your SSH authorized_keys.
  3. Use Tailscale ACLs if you want to restrict which devices (not just which users) can reach gmux.
  4. Check the logs. Denied connection attempts are logged with identity details.