Skip to content

Security: thejdubb02/clique

Security

SECURITY.md

Security model

CLIque serves a terminal running as root over a browser. That is not a side effect; it is the product. So the security model is not about hardening a web app — it is about controlling exactly who reaches that terminal, and assuming that anyone who does has the box.

This document exists because the honest version of that sentence should be written down rather than discovered.

The perimeter

Layer What it does
Bind address 127.0.0.1:3200. Nothing reaches the process except through the tunnel.
Tunnel Tailscale Serve, Caddy, or nginx terminates TLS. CLIque itself never binds the public internet.
Host allowlist Rejects any Host we do not answer to, before auth or any handler.
Password Mandatory. Stored as an scrypt hash; the process cannot recover the plaintext.
Session cookie HMAC-signed, HttpOnly, SameSite=Lax, Secure whenever the request arrived over HTTPS (incl. behind a TLS tunnel), 30-day TTL.
API tokens Separate credential for agents, scoped read, read/write, or attention-only, revocable individually.
Origin checks On every state-changing request and on the WebSocket upgrade.

The four attacks this is actually built against

1. DNS rebinding. An attacker points their domain at 127.0.0.1, gets you to open their page, and the browser then treats requests to their domain as same-origin with their script — bypassing every same-origin protection at once. The only fix is to refuse on the Host header before a handler runs, which is what host_allowed() does. Allowed: loopback, IP literals, .ts.net, the usual tunnel providers, and anything named in CLIQUE_ALLOWED_HOSTS.

2. Cross-Site WebSocket Hijacking. A WebSocket handshake is not subject to CORS, and SameSite=Lax does not cover it — so a hostile page can open a socket to this origin and the browser will attach the session cookie. Without an Origin check on the upgrade, that page has a live root terminal. This is the single most dangerous hole a browser terminal can have, and it is checked before the handshake completes.

3. CSRF. A cookie is attached by the browser to any request to this origin, including one a hostile page triggered, so a cookie alone does not prove intent. State-changing requests need a matching Origin. A missing Origin is allowed, so curl and agents keep working; an opaque null origin is rejected, because that is a sandboxed frame rather than our page. Token requests skip the check entirely — browsers never attach an Authorization header on their own, which is what makes a token proof of authorship.

4. Password guessing. Failures are counted per address with a decay window. Crucially, a correct password always gets through, even while throttled. Behind a tunnel every request arrives from the same loopback address, so a per-IP lockout would lock out the only legitimate user along with the attacker — a denial of service dressed as a protection.

Credentials

All three live under $CLIQUE_HOME (set this to ~/.clique unless you have a reason not to). Mode 0600.

  • Login passwordpassword, an scrypt hash. The server only ever verifies, so keeping the plaintext in this file buys nothing. Set it with python3 -m clique password. Keep the plaintext in a password manager, not next to the hash.
  • Cookie signing secretsecret, 32 random bytes, persisted so a restart does not log everyone out. Deleting it invalidates every session, which is the "log out everywhere" lever.
  • API tokenstokens.json, SHA-256 hashes only. A leaked file is a list of names and dates, not working keys. Minted with python3 -m clique token create, never through the API: an endpoint that mints credentials turns any other hole into permanent access.
  • State-hook token — a per-session, attention-scoped token handed to each launched session in its own environment ($CLIQUE_TOKEN) so a state hook can report to /api/sessions/<id>/attention. The scopes are enforced on both sides: every GET /api/* and the /ws attach require a read scope, every write requires write. So this token reaches only that one status nudge — an agent that reads its own $CLIQUE_TOKEN, or a prompt-injected one, cannot spawn a shell, drive a session, or read a terminal, transcript, or host file with it. And because each session's token is bound to its session id, a token exfiltrated from one pane can nudge only its own status — not another session's. It is minted fresh when the pane is created (re-minted on resume) and revoked when the session is deleted, so it is individually revocable and dies with the session that held it. Tokens on panes launched before this model existed are unbound and still accepted for any session, until those sessions are recreated.
  • BYOK model keys — optional, and off unless you add one. A provider's key is encrypted at rest with AES-256-GCM before it is written; the data key is a separate 0600 secret.key under $CLIQUE_HOME, so lifting state.json (from a backup or a sync) yields ciphertext, not a key. The crypto comes from the opt-in extra (pip install 'clique-panel[llm]'); with it absent, a key is refused rather than stored in the clear. The plaintext key is never returned by any endpoint (only whether one is set), never logged, and never rides /api/state. Outbound calls to a provider refuse a base_url that resolves to a cloud-metadata / link-local address or, by default, an internal-network host — so a mistyped or hostile URL cannot turn the panel into an SSRF proxy; loopback stays open for local models. What we do not yet do: pin the connection to the resolved IP (DNS-rebinding on an operator-set URL is the residual), and a model's own output is treated as data — the copilot's "run this" is always a draft an operator approves, never auto-run.

Where we are stronger than the tool we replaced

  • No dependency surface. Zero third-party packages against ~790 MB of node_modules. Most real-world compromise of self-hosted tools arrives through a transitive dependency, and we do not have any.
  • No build step, so nothing is generated between what is reviewed and what runs.
  • Least privilege by construction. kill() refuses any tmux session that is not ours unless explicitly forced. Sessions live on our own tmux socket, so a bug here cannot reach another tool's work.
  • Nothing goes through a shell. Every tmux call is an argv list, so a session name or path containing a quote is data, not a command.
  • Read-only API tokens, so an agent that only needs to look cannot act.

Where we are still behind, honestly

  • Sessions are not individually revocable. The cookie is stateless HMAC, so the only revocation is rotating the secret, which logs out every device. Codeman keeps server-side opaque tokens and can drop one. Worth adopting.
  • No schema validation library. Input is clamped and filtered by hand. It is careful, but Zod-style declarative validation is harder to get wrong.
  • File previews are fenced, and never serve a credential. GET …/file previews a path an agent printed. Directory containment is realpath after resolution — a symlink cannot smuggle a path out of the session's working directory — and can be opted out on a trusted-local box with CLIQUE_FENCE_READS=0. The credential blocklist is not part of that opt-out: .env and its whole family (.env.local, …), private-key material (.pem/.key/.p12), token dotfiles (.npmrc, .pypirc, .bw-session), and whole credential directories (.ssh, .aws, .gnupg, …) are refused whether or not the fence is on. Paste and artifact routes contain to the working tree unconditionally and serve with nosniff. Arbitrary upload/download is still not offered. Editing (POST …/file, write scope) saves through the same resolve gate — so it cannot escape the directory or overwrite a credential — and only ever overwrites an existing regular file, atomically, mode preserved, capped at 2 MB; it never creates a file, and the attention-only hook token in every pane cannot reach it.
  • No audit log. Who logged in, from where, and when is not recorded.
  • The review diff shows untracked files. GET …/diff needs only read scope and renders untracked, non-gitignored files in full — so an .env the agent left sitting in the working tree is visible to a read-only viewer. Gitignored files are never shown. Reasonable for a single-user tool; worth knowing before sharing a read-only link.
  • No multi-user model, deliberately — this is one person's tool.

Reporting

Open a GitHub security advisory if it is exploitable. Anything else can be an issue. Do not file a public issue for a hole that reaches a terminal.

There aren't any published security advisories