Security model

August 20, 2026 ยท View on GitHub

DSH Remote assumes the computer owner is the only DSH owner. Multiple named phones may be paired, but this is not a multi-tenant service.

Trust layers

  1. Cloudflare Access or another identity-aware proxy authenticates the owner at the public edge.
  2. The tunnel publishes only /m; all other paths end at 404.
  3. The plugin requires the exact configured public Host and same Origin on mutations.
  4. A short-lived, single-use fragment ticket creates a separate device credential. Only its SHA-256 digest is stored.
  5. Phone RPC is a strict allowlist with strict payload schemas. It is not a gateway to DSH /api.

Email OTP is therefore not the only control, and the pairing boundary should not be disabled. It is also not accurate to market OTP plus pairing as human multi-factor authentication: pairing is a locally authorized device bootstrap, not a second identity factor.

Optional private-network mode

Tailscale is not required for the default Cloudflare deployment. Cloudflare Access limits who reaches the hostname, while the independent pairing credential limits which approved phone may use DSH Remote. Requiring both Cloudflare and Tailscale for every installation would add another account, client, and failure mode without replacing either plugin authorization or RPC confinement.

For an owner whose threat model requires no public DNS/edge entry point, a tailnet-only HTTPS reverse proxy is a reasonable alternative to Cloudflare Access. It should still publish only /m, keep DSH on loopback, preserve the external Host, and keep device pairing and revocation enabled. This mode is a manual deployment option in v0.1; the Cloudflare wizard does not install or configure Tailscale. Operators should not run both public and tailnet entry points accidentally.

Phone authority

The phone can list/create sessions with Host defaults, read bounded history, send text, queue or steer, stop the current turn, answer structured questions, and allow one approval once or reject it. It cannot choose cwd, run slash commands, change permission mode, install plugins, browse files, fetch model URLs, or call arbitrary Host methods.

Approval and question replies must match a still-pending Host rpcId and exact session/interaction IDs. Revocation and expiry abort existing SSE streams.

Browser boundary

The device cookie is Secure, HttpOnly, SameSite=Strict, and scoped to /m. Dynamic responses use Cache-Control: no-store. The Service Worker caches only an explicit static shell and bypasses API, events, pair, and unpair requests. The CSP forbids inline/eval script, framing, external images, and external connections. Model text is rendered with textContent, not Markdown HTML.

Local control boundary

The /remote UI and /api/dsh-remote/** control routes require both a loopback TCP peer and a loopback Host. Mutations additionally require an exact same Origin. A tunnel must preserve the public Host, so it cannot satisfy this local fence.

Secrets and supply chain

Cloudflare API Tokens are request-local. Tunnel credentials and device digests are stored mode 0600 under a mode 0700 state directory. Setup responses and journals never contain the API Token. Runtime does not download binaries, invoke package managers, self-update, or automatically open a tunnel.

Security reports should use GitHub private vulnerability reporting as described in SECURITY.md.