Files
paseo/SECURITY.md
T
89727f43bc fix(server): require the daemon password on relay connections (#7668)
* fix(server): require the daemon password on relay connections

A password-protected daemon still admitted relay clients that sent no
credential in their encrypted hello, so a leaked pairing link alone gave
full control. Remove the COMPAT(relayPasswordOptional) shim: relay clients
now need the password like direct ones.

Fixes #4087

* test(server): cover relay password admission end to end

Run a password-protected daemon behind a local relay and connect with
the real host client over relay://: no password and a wrong password are
refused, the right password is admitted and serves RPCs.

* test(server): run relay password regression in CI

* test(ci): include relay password regression in command expectation

---------

Co-authored-by: Nicholas Maglowsch Salgueiro <21068323+nickmaglowsch@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-23 13:15:14 +07:00

11 KiB

Security

Paseo follows a client-server architecture, similar to Docker. The daemon runs on your machine and manages your coding agents. Clients (the mobile app, CLI, or web interface) connect to the daemon to monitor and control those agents.

Your code never leaves your machine. Paseo is a local-first tool that connects directly to your development environment.

Architecture

The Paseo daemon can run anywhere you want to execute agents: your laptop, a Mac Mini, a VPS, or a Docker container. The daemon listens for connections and manages agent lifecycles.

Clients connect to the daemon over WebSocket. There are two ways to establish this connection:

  • Relay connection — The daemon connects outbound to our relay server, and clients meet it there. No open ports required.
  • Direct connection — The daemon listens on a network address and clients connect directly.

Relay threat model

The relay is designed to be untrusted. All traffic between your phone and daemon is end-to-end encrypted. The relay server cannot read your messages, see your code, or modify traffic without detection. Even if the relay is compromised, your data remains protected.

How it works

  1. The daemon generates a persistent Curve25519 keypair on first run and stores it at $PASEO_HOME/daemon-keypair.json with mode 0600
  2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (https://app.paseo.sh/#offer=...). Fragments are not sent to the web server, so app.paseo.sh never sees the key.
  3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an e2ee_hello message. The daemon will not process any application messages until this handshake completes.
  4. Updated peers run a Noise NK handshake (Noise_NK_25519_ChaChaPoly_SHA256) keyed to the daemon's static key: the e2ee_hello carries the first Noise message and the daemon's e2ee_ready carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl box) and a random [24-byte nonce][ciphertext] bundle. Both modes negotiate binaryCiphertext: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. docs/relay-endpoint-security.md has the exact mapping.

The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations.

The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext e2ee_hello / e2ee_ready handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.

Why the relay can't attack you

The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:

  • Impersonate the daemon to your phone — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
  • Send commands as you — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
  • Read your traffic — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode
  • Forge messages — Both constructions are authenticated encryption; a tampered message is rejected in either mode
  • Replay old messages — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set daemon.relay.requireProtectedSecurity: true to refuse legacy clients once every client you use has updated.

Trust model

The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.

When a daemon password is configured, relay clients send it in the encrypted hello message, so the relay never sees it. The daemon rejects a relay client that sends a wrong password or none, so a leaked pairing link alone does not grant access to a password-protected daemon.

Local daemon trust boundary

By default, the daemon binds to 127.0.0.1. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.

The daemon supports an optional shared-secret password (set via auth.password in config.json or the PASEO_PASSWORD env var; stored bcrypt-hashed). WebSocket clients send the password in hello; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (GET /api/health) and CORS preflight (OPTIONS) are exempt; /api/files/download and /mcp/agents use their own capability tokens.

The daemon writes a new $PASEO_HOME/local-credential on every run with mode 0600 and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock listen matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect $PASEO_HOME accordingly. Relay traffic remains end-to-end encrypted independently of password admission.

Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.

When Paseo checks out a change request from a different repository, it does not run that workspace's paseo.json setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.

If you expose the daemon beyond loopback, such as by binding to 0.0.0.0, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.

In Docker, the official image runs the daemon and agents as the non-root paseo user by default. Mounted workspaces and credentials are still fully available to anything the agents run inside the container.

For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.

Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.

DNS rebinding protection

CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).

Paseo validates the Host header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only localhost, *.localhost, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via hostnames in config.json or the PASEO_HOSTNAMES env var (comma-separated; entries beginning with . match a domain and its subdomains; the value true disables the allowlist entirely). Requests with unrecognized hosts are rejected with 403 Host not allowed.

HTML file preview

Previewing an .html file in the file pane renders it as a page, so markup an agent wrote — or markup that arrived with a repo you cloned — executes when you open it. The preview is built to contain that, not to trust it.

The document loads with an opaque origin and a policy that permits inline script and style and refuses everything else: no remote script, font, image, or media; no fetch, XHR, WebSocket, or beacon; no form posts; no plugins; no nested frames. It has no access to Paseo's DOM, and storage and cookie APIs throw inside it rather than returning anything. It cannot navigate the top window, and it cannot open popups. It cannot read any file but itself.

One gap remains on web and desktop: a sandboxed document may navigate itself, and no CSP directive in current browsers prevents that. navigate-to was dropped from CSP Level 3 and is not enforced, and <meta http-equiv="refresh"> needs no script at all. A hostile page can therefore reach a server by navigating away, carrying data available inside the preview, such as its own contents, browser and device properties, user input inside the page, and your IP address. It cannot read Paseo, another file, storage, or cookies.

Native builds narrow this gap rather than closing it outright. The WebView refuses every navigation after the initial document, but that decision is made in the app's JavaScript, and on Android the WebView falls back to allowing a navigation when the decision doesn't come back in time. Treat it as a strong mitigation, not a guarantee: if the JS thread is stalled at the moment a page navigates, the same leak is possible there too.

If you don't trust a page, read it in Source, which executes nothing. Source is available as an editable view on supported web hosts and a read-only view everywhere else.

Agent authentication

Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.

Forge host trust

Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.

Reporting vulnerabilities

If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.