Skip to content

Security: LelouchHe/webagent

Security

docs/security.md

Security

webagent is designed for single-operator self-hosting: one human, one or more devices, one ACP agent. The threat model is:

  • Network: the server may be reachable on a LAN, behind a reverse proxy, or directly on the open internet via Cloudflare Tunnel. Untrusted clients must not be able to issue prompts, read history, or run shell commands.
  • Browser: the only legitimate client is a browser the operator controls. Token theft via XSS, third-party scripts, or stale CDN content is treated as a real risk.
  • Process boundary: the agent subprocess (Copilot CLI, Claude, etc.) is trusted with anything the operator can do — there is no sandbox between the server and the agent.

This document covers the auth model, data layout, and operational practices that protect against the first two threats. The third is by design and not in scope.

Bootstrap

webagent refuses to serve unauthenticated traffic. Behavior on startup depends on whether data/auth.json exists.

First-run zero-config (default)

When data/auth.json does not exist AND stdin is a TTY (i.e. you launched webagent or webagent start from an interactive shell), the server:

  1. Mints a one-time admin token named first-run.

  2. Prints it as part of the startup-doctor stream:

    [check] node: v24.0.0                      ✓
    [check] data_dir: /path/to/data            ✓
    [check] acp agent: copilot --acp           ✓
    [check] auth: minted first-run admin token  ✓
    
    ┌─ first-run ──────────────────────────────────────
    │  Welcome. WebAgent has minted a one-time admin token
    │  for this device. Copy it and paste it into the
    │  login form:
    │
    │    1. open http://localhost:6800/ in your browser
    │    2. paste this token:
    │
    │       wat_<43-char-token>
    │
    │  Treat this token like a password — it appears in
    │  your terminal scrollback. Revoke from /tokens later
    │  if needed.
    └──────────────────────────────────────────────────
    
  3. Continues serving normally.

The operator copies the token from the terminal and pastes it into the /login form. We deliberately do not print a clickable URL with the token embedded (e.g. http://host/#t=...): although URL fragments don't reach the server in HTTP requests, they do leak via browser history sync, "history-permission" extensions, and the "looks clickable → click it" muscle memory. Plain-token + manual paste matches the existing --create-token flow's mental model.

Threat model:

  • ✅ The token does not appear in network logs (it's never sent in a URL).
  • ⚠️ The token does appear in the operator's terminal scrollback — same exposure as webagent --create-token. Treat it like a password until you've used it once.
  • The minted token is a regular admin token; revoke from /tokens once you're done with it (e.g. after adding a per-device api token), or leave it in place — it's no different from any other token after redemption.

Disable with [auth] first_run_bootstrap = false in your config — useful when a supervisor / CI / Ansible playbook provisions auth.json out of band.

Daemon

webagent start runs the same startup gate (preflight + auth bootstrap) in the parent process before forking the supervisor — i.e. in your operator terminal. A first-run mint banner therefore lands on your TTY exactly as it does for foreground webagent, not in webagent.log. The supervisor and child server inherit WEBAGENT_STARTUP_CHECKED=1 and skip re-running the gate.

If you launch webagent start from an init system / cron / systemd unit where stdin is not a TTY, the gate falls through to the fallback below.

Headless / opted-out / config-anomaly fallback

When any of these is true:

  • data/auth.json does not exist AND stdin is not a TTY (init-system / cron / piped launch), OR
  • data/auth.json does not exist AND [auth] first_run_bootstrap = false, OR
  • data/auth.json exists but the token list is empty (manual edit / parse failure / permission error — config anomaly, not a fresh install)

…the server logs a recovery hint and exits with code 78 (sysexits.h: configuration error). The daemon supervisor recognizes 78 and stops the restart loop instead of churning forever.

Recover with:

webagent --create-token <name>
# prints the raw token ONCE — paste into /login on first browser visit

The CLI prints the raw token to stdout exactly once. If you lose it, revoke and recreate. Tokens are stored hashed; they cannot be recovered from auth.json.

Token Storage

data/auth.json (mode 0600) holds:

{
  "tokens": [
    {
      "name": "macbook",
      "scope": "admin",
      "hash": "ec5cd2f9c3a66c309c12e306791fa67e067fc7f92c0bea71aafa4b134c7e4ee9",
      "createdAt": 1777097538082,
      "lastUsedAt": 1777098000123
    }
  ]
}
  • hash: SHA-256 of the raw token. The raw value never persists server-side.
  • name: free-form label, unique per file. Used by the CLI and /tokens API to address a token.
  • scope: admin (full access, including token CRUD) or api (everything except /tokens/**). webagent --create-token always creates admin. POST /api/v1/tokens always creates api.
  • lastUsedAt: updated on every successful auth, but buffered in memory and flushed every 60s (and on SIGTERM) to avoid hot-write churn. Reads from disk return the last-flushed value.
  • File locking: proper-lockfile guards every read-modify-write so the CLI (which mutates auth.json while the daemon is running) and the daemon's flush timer cannot interleave and lose updates.

Token format: wat_ + 32 random URL-safe bytes (base64url). Constant-time comparison via SHA-256 hash equality.

Auth Flow

Every HTTP request flows through src/auth-middleware.ts:

  1. If the path matches a whitelist entry → pass through unauthenticated.
  2. Otherwise extract Authorization: Bearer <token> from the request header.
  3. Hash the token, look up in AuthStore. On match, attach principal: TokenRecord to the request and continue. On any failure, return 401 Unauthorized.

Whitelisted paths (no auth required):

Path Why
GET /api/v1/version Public probe; no PII
GET /api/beta/push/vapid-key Public probe; subscribing requires auth
GET / HTML shell — app.ts redirects to /login if no token in localStorage
GET /login, /login.html Login page
GET /manifest.json, /sw.js, /favicon.ico, /theme-init.js PWA + early-paint helpers needed before login
GET /js/*.js, /styles*.css, /icons/* Static bundles (content-hashed)
GET /api/v1/events/stream SSE — auth via short-lived ticket in query string instead (see below)
GET /api/v1/attachments/* Auth via HMAC signature in query string instead (see below)
GET /api/v1/files/content Auth via a path-bound HMAC signature; file info/list remain Bearer-only

Anything else (chat history, prompt submission, model selection, bash, push subscriptions) requires a Bearer token.

Frontend wiring

  • Token persists in localStorage under wa_token. Key is exported as TOKEN_STORAGE_KEY from public/js/login-core.ts so the login page and the API wrapper agree.
  • public/js/api.ts exposes request() which auto-attaches Authorization: Bearer ... to every API call.
  • app.ts does an early pre-bootstrap check (line 5-10): if no token in localStorage, immediately location.replace('/login') before any other module loads.
  • On a 401 from a Bearer-authenticated API, the wrapper clears wa_token and bounces to /login. Capability-authenticated byte routes (attachments and /api/v1/files/content) are excluded: their 401 means the URL signature expired or was tampered with, not that the operator token is invalid.

Login page

/login is its own HTML entrypoint (separate from /) for clarity and easier testing:

  • <input type="password"> with autocomplete="current-password", autocorrect="off", autocapitalize="none", spellcheck="false". type=password masks natively cross-browser; spellcheck=false prevents Chrome's Enhanced Spellcheck from uploading the token to a cloud spellchecker.
  • Form submits on Enter or via the Sign in button (mobile: Sign in button is essential since iOS soft keyboards may not fire form-submit on virtual Enter).
  • On 200, store token + location.replace('/'). On 401, show error in #error and stay on /login.

SSE Ticket

EventSource cannot set custom headers, so SSE auth uses a short-lived ticket:

  1. Client POST /api/v1/sse-ticket with Authorization: Bearer <token> → server generates a 32-byte random ticket, stores {ticket, principal, exp: now+60s} in memory, returns {ticket}.
  2. Client opens EventSource('/api/v1/events/stream?ticket=' + ticket). Server consumes the ticket (single-use, deleted on first match) and binds the SSE connection to the resolved principal.
  3. The 15s heartbeat doubles as a recheck: if the underlying token is revoked (and auth.json reloaded via SIGHUP), the next heartbeat write fails and the SSE stream closes.

Tickets are in-memory only; restarting the server invalidates all of them. Clients reconnect automatically and re-issue.

Signed Content URLs

Uploaded images and ordinary file-viewer bytes use short-lived capability URLs because <img> and download links cannot attach the Bearer header. Their GET routes are whitelisted only at the outer auth middleware; each route verifies its own URL before reading disk:

  • The server holds a per-restart HMAC secret (random 32 bytes, never persisted).
  • The signature is HMAC-SHA256(secret, "<path>:<exp>"), where exp = now + 1h.
  • Changing either the URL path/query-bound file path or the expiry invalidates the signature.
  • Every server restart rotates the secret. Clients re-fetch the attachment event or GET /api/v1/files/info to obtain a fresh signed URL.

For the file viewer, info and list remain Bearer-only and are the only way to mint a signed content URL. GET /api/v1/files/content requires a valid signature even if the caller also supplies a Bearer header; if the signing secret is missing it fails closed with 503. On use, the route verifies that the signed canonical path still resolves to itself, then uses one nonblocking/no-follow descriptor for fstat, MIME sniffing, disposition choice, and streaming. This prevents a capability from being retargeted through a replacement symlink and prevents check/read descriptor races.

Mutable file content is served with Cache-Control: no-store, nosniff, and a restrictive CSP. Previewable text is bounded at 1 MiB; larger or unknown content uses the same capability URL with attachment disposition and streams under backpressure without whole-file buffering. FIFOs, sockets, and device nodes are never streamed. The public /s/:token share viewer cannot call info or list and therefore cannot mint arbitrary file URLs.

A stolen signed URL is usable only for its single bound path and at most one hour (or until the next server restart).

Content Security Policy

Every HTML entrypoint response carries a strict CSP:

default-src 'self';
img-src 'self' data: blob:;
script-src 'self' 'wasm-unsafe-eval';
style-src 'self';
connect-src 'self';
object-src 'none';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'

No unsafe-inline for scripts. No unsafe-eval. The script-src includes the narrow CSP3 token 'wasm-unsafe-eval' (permits WebAssembly.instantiate() only; does NOT re-enable eval()/Function()) so wasm-backed features can run. No third-party origins. Notable consequences:

  • The early-paint theme bootstrap is in a separate file (/theme-init.js) and loaded with <script src="..."> instead of being inline.
  • marked and dompurify are bundled into app.[hash].js. highlight.js common (36 languages) is dynamically imported into a separate chunk.[hash].js, preloaded via <link rel="modulepreload"> — still all served from 'self', no cdn.jsdelivr.net at runtime.
  • frame-ancestors 'none' prevents any other site from embedding webagent in an <iframe>.

The HTML entrypoint set is the single source of truth in src/routes.ts:

export const HTML_ENTRYPOINTS = [
  { urlPath: "/", file: "index.html" },
  { urlPath: "/login", file: "login.html" },
] as const;

Three invariant tests guard CSP correctness so future changes don't regress silently:

Test Asserts
test/csp.test.ts Every HTML entrypoint response includes Content-Security-Policy matching the documented policy
test/html-entrypoints.test.ts Every HTML file under public/ is registered in HTML_ENTRYPOINTS (no orphan HTML pages)
test/inline-assets.test.ts No <script>...</script> or <style>...</style> blocks with inline content in any HTML entrypoint

Operations

Multi-device

Create one token per device:

webagent --create-token macbook
webagent --create-token phone
webagent --create-token cli-laptop

Or after the first admin token, use the /token slash command (or POST /api/v1/tokens) from an authenticated task — those default to api scope.

Revocation

/token slash command lists tokens (with last-used timestamps) and supports revoke. Or DELETE /api/v1/tokens/:name directly. Both routes require admin scope.

The CLI does not currently expose --list-tokens or --revoke-token; use the running daemon's API. (If the daemon is offline you can edit auth.json directly — it's just JSON — and SIGHUP the process when it comes back.)

Hot reload

SIGHUP reloads auth.json from disk. Use this after editing the file by hand, or after CLI mutations on a system where another process has the file:

kill -HUP $(pgrep -f "webagent.*--config")

Existing SSE connections are NOT dropped. Tokens that were revoked are kicked at the next 15s heartbeat (when the in-memory cache rechecks).

Rotation

There is no built-in expiry. Tokens live until revoked. To rotate:

  1. Create a new token.
  2. Update the device's localStorage (or paste into /login again — the new token replaces the old).
  3. Revoke the old token.

Audit

lastUsedAt (visible in /token and GET /api/v1/tokens) is the only built-in usage signal. There is no per-request audit log. Rely on reverse-proxy access logs if you need request-level tracking.

Data Directory

data/
├── auth.json          # tokens (0600, JSON, hashed) — see "Token Storage"
├── webagent.db        # tasks, events, messages — SQLite
├── webagent.db-shm    # SQLite shared-memory (transient)
├── webagent.db-wal    # SQLite write-ahead log (transient)
├── vapid.json         # VAPID keypair for Web Push (regenerated if deleted)
└── tasks/
    └── <taskId>/attachments/<file>   # uploaded files (images, docs, etc.)
  • All paths are inside data_dir (defaults to ./data). The dev config uses ./data-dev. The E2E suite uses ./test/e2e-data/<runId>/data.
  • auth.json is the only file with mode 0600. The SQLite files inherit umask defaults — that is intentional: SQLite holds task content, not credentials, and tightening the mode breaks litecli-style introspection. If your OS user is shared, restrict the parent directory instead.
  • Backup: auth.json + webagent.db are sufficient. The WAL/SHM are transient. Attachment files under tasks/ are referenced by events but the app degrades gracefully (renders broken-image placeholder) if they're missing.
  • Reset: rm -rf data/ and start over. The server will refuse to serve until you --create-token again.

E2E Test Setup

test/e2e/seed.ts runs at Playwright config-load time, inside the run directory minted by test/e2e/run-context.ts. It:

  1. Creates that run's data directory (test/e2e-data/<runId>/), without touching any other run in the same worktree.
  2. Generates one admin-scope token via AuthStore.addToken("e2e", "admin").
  3. Writes auth.json (server-side, in the run's data dir), storage-state.json (Playwright loads into every page's localStorage for origin 127.0.0.1:<run port>), and .token (raw value for specs that need it).

The seed is idempotent — it skips seeding if that run's .token exists. The run port and directory come from E2E_PORT/E2E_RUN_DIR; see development.md.

playwright.config.ts sets extraHTTPHeaders: { Authorization: 'Bearer <token>' } so direct page.request.* API calls are pre-authenticated. Specs that test rejection paths (e.g. login-bad-token.spec.ts) opt out via test.use({ extraHTTPHeaders: {} }).

Production Bundle Invariant

No literal token must ever be shipped in dist/. Verify with:

grep -E 'wat_[A-Za-z0-9_-]{30,}' dist/    # must produce no matches

The string wat_ alone is fine — it appears as the placeholder text in login.html (placeholder="wat_..."). Only actual token-shaped strings (30+ chars after the prefix) are forbidden.

What's NOT Protected

These are explicit non-goals — single-user self-hosting trades them away for simplicity:

  • No per-request audit log. No "who ran this prompt at what time" trail.
  • No agent sandbox. Anything you can do in a terminal, the agent can do via permission requests. autopilot mode auto-approves everything; only enable it when you trust the prompt source.
  • No CSRF token on state-changing requests. Authorization: Bearer is set explicitly by the frontend; cross-origin requests cannot read or send it back. With strict CSP frame-ancestors 'none' we don't allow embedding either.
  • No rate limiting. A leaked token grants the same throughput as a legitimate one. Rotate immediately if you suspect leakage.
  • No multi-user RBAC. admin vs api is the entire authorization surface; both can read all tasks and run all commands.

If you need any of the above, terminate webagent at a reverse proxy that adds them. The Bearer model is compatible with proxy-injected Authorization headers.

There aren't any published security advisories