Skip to content

feat(web): HTTP Basic Auth with session cookies + read-only guard for unauthenticated LAN access#53

Open
GCS-ZHN wants to merge 2 commits into
shekohex:mainfrom
GCS-ZHN:feat/web-basic-auth
Open

feat(web): HTTP Basic Auth with session cookies + read-only guard for unauthenticated LAN access#53
GCS-ZHN wants to merge 2 commits into
shekohex:mainfrom
GCS-ZHN:feat/web-basic-auth

Conversation

@GCS-ZHN

@GCS-ZHN GCS-ZHN commented Jul 25, 2026

Copy link
Copy Markdown

Summary

This PR adds HTTP Basic Authentication to the PTY web interface with a signed session cookie, and enforces origin-based read-only access for unauthenticated non-loopback clients — preventing unauthorized users on the same network from killing sessions or injecting input while still allowing them to observe live output.

Motivation

Before this change, the PTY web server was open to anyone who could reach its bind address. This is fine for localhost development, but poses real risks when the server is exposed on a local network (e.g., PTY_WEB_HOSTNAME=0.0.0.0):

  • A passer-by on the LAN can kill active dev servers, builds, or REPLs with a single DELETE request.
  • A hostile client can type arbitrary commands into a running interactive PTY, giving them a shell under the developer's user account.
  • There was no mechanism to distinguish legitimate local access from remote snooping.

What this PR changes

HTTP Basic Auth with HMAC-signed session cookie (commit 6c86304)

  • WebAuth class — validates Authorization: Basic … credentials against PTY_WEB_USERNAME / PTY_WEB_PASSWORD environment variables. When PTY_WEB_PASSWORD is unset, the API remains open (backward-compatible default).
  • Session cookie — on successful authentication, a signed HMAC cookie (PTY_SESSION) is set so the browser only re-prompts for credentials once per session. No Max-Age is set, so the cookie lasts until the browser tab is closed (session-scoped).
  • Safari compatibility — the SPA shell is routed through a single fetch handler rather than Bun's typed routes map, preventing Safari's HTTP auth cache from creating separate protection spaces for static files vs. the WebSocket upgrade.
  • /health is always unauthenticated — so the frontend can probe whether auth is enabled before the browser surfaces its native auth dialog.

Origin-based read-only guard (commit 9da2fee)

When PTY_WEB_PASSWORD is not set and a request originates from a non-loopback address:

Operation Blocked? Status code
POST /api/sessions (create PTY) ✓ blocked 403
POST /api/sessions/:id/input (write to PTY) ✓ blocked 403
DELETE /api/sessions/:id (kill PTY) ✓ blocked 403
DELETE /api/sessions (clear all) ✓ blocked 403
WebSocket spawn / input messages ✓ blocked error frame sent

Read-only operations remain availableGET /api/sessions, buffer reads, and the WebSocket subscription path (session_list, raw_data, session_update) all continue to work, so the frontend can still stream live output in a read-only terminal.

Frontend

  • Read-only xterm — when the WebSocket connection is non-writable, the xterm.js instance intercepts all keystrokes via attachCustomKeyEventHandler while preserving Ctrl+C/Cmd+V copy-paste and text selection. A yellow banner informs the user that input is disabled.
  • Origin awareness — the React UI detects non-loopback access (via location.hostname) and surfaces a prominent auth-warning banner suggesting the user set PTY_WEB_PASSWORD.

Non-breaking

  • PTY agent tools (pty_spawn, pty_write, etc.) are completely unaffected. The guard lives entirely in the HTTP/WebSocket layer of the web server (server.ts, handlers/websocket.ts). Agent sessions communicate with the PTY manager via the plugin tool interface, which does not pass through the web server at all.
  • Loopback access retains full privileges — when the HTTP Origin / Host header resolves to 127.0.0.1, localhost, or [::1], all operations are permitted without authentication.
  • When PTY_WEB_PASSWORD is set, the origin guard is bypassed entirely — authenticated requests are always writable regardless of origin.

Files changed

Area Files
Auth core src/web/server/auth.ts (new)
Server + origin guard src/web/server/server.ts
WS message handler src/web/server/handlers/websocket.ts
Removed orphaned WS upgrade handler src/web/server/handlers/upgrade.ts (deleted)
Frontend — read-only xterm src/web/client/components/terminal-renderer.tsx, app.tsx, index.html
Frontend — hooks src/web/client/hooks/use-session-manager.ts
Server type plumbing callback-manager.ts, health.ts
Tests test/web-auth.test.ts (new), test/web-server-auth.test.ts (new), test/web-ws-guard.test.ts (new)

GCS-ZHN added 2 commits July 22, 2026 22:39
When PTY_WEB_HOSTNAME is bound to anything other than a loopback
address, any browser on the LAN can otherwise read every running
session's output and type into interactive PTYs. This commit gates
the web UI on HTTP Basic Auth so the obvious threat model is closed
off by default.

- PTY_WEB_USERNAME / PTY_WEB_PASSWORD in the env turn the gate on;
  username defaults to the current OS user.
- src/web/server/auth.ts parses Authorization: Basic ..., compares
  credentials with timingSafeEqual, and serves a 401 +
  WWW-Authenticate challenge with Cache-Control: no-store.
- After successful Basic Auth, the server sets a stateless
  HMAC-signed session cookie (HttpOnly, SameSite=Lax, no Max-Age so
  the browser drops it on close). Safari does not propagate HTTP
  Basic Auth to fetch() and WebSocket upgrade requests, but it does
  propagate same-origin cookies — this is what stops the second
  prompt and the refresh re-prompt. The HMAC secret is regenerated
  per process so a server restart invalidates every cookie.
- /health is always unauthenticated so the frontend can probe the
  authEnabled flag.
- Without auth, destructive operations (kill / cleanup / clear-all)
  are blocked from non-loopback origins with a 403 that points at
  PTY_WEB_PASSWORD. Loopback access and authenticated access are
  unrestricted.
- All routing flows through a single fetch handler (matching
  opencode-mem's architecture) so every path returns the same
  WWW-Authenticate challenge and Safari keeps the protection-space
  cache warm.
- When the page is served from a non-loopback host without auth, the
  UI shows a small dismissable banner in the bottom-right corner
  hinting at PTY_WEB_PASSWORD.
- Tests cover credential check, challenge format, cookie round-trip,
  origin guard, and the regression where DELETE routes previously
  bypassed auth.
Backend blocks all write paths (spawn, input, kill, cleanup) when a
non-loopback client connects without auth. WS upgrade uses Host instead
of Origin (Bun WS clients don't send Origin). Frontend xterm uses
attachCustomKeyEventHandler to block keystrokes while preserving
select/copy/paste, with a read-only banner for feedback.

Fixes: ws-isWritable-vs-LAN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant