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
Open
feat(web): HTTP Basic Auth with session cookies + read-only guard for unauthenticated LAN access#53GCS-ZHN wants to merge 2 commits into
GCS-ZHN wants to merge 2 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
localhostdevelopment, but poses real risks when the server is exposed on a local network (e.g.,PTY_WEB_HOSTNAME=0.0.0.0):DELETErequest.What this PR changes
HTTP Basic Auth with HMAC-signed session cookie (commit
6c86304)WebAuthclass — validatesAuthorization: Basic …credentials againstPTY_WEB_USERNAME/PTY_WEB_PASSWORDenvironment variables. WhenPTY_WEB_PASSWORDis unset, the API remains open (backward-compatible default).PTY_SESSION) is set so the browser only re-prompts for credentials once per session. NoMax-Ageis set, so the cookie lasts until the browser tab is closed (session-scoped).fetchhandler rather than Bun's typedroutesmap, preventing Safari's HTTP auth cache from creating separate protection spaces for static files vs. the WebSocket upgrade./healthis 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_PASSWORDis not set and a request originates from a non-loopback address:POST /api/sessions(create PTY)POST /api/sessions/:id/input(write to PTY)DELETE /api/sessions/:id(kill PTY)DELETE /api/sessions(clear all)spawn/inputmessagesRead-only operations remain available —
GET /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
xterm.jsinstance intercepts all keystrokes viaattachCustomKeyEventHandlerwhile preservingCtrl+C/Cmd+Vcopy-paste and text selection. A yellow banner informs the user that input is disabled.location.hostname) and surfaces a prominent auth-warning banner suggesting the user setPTY_WEB_PASSWORD.Non-breaking
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.Origin/Hostheader resolves to127.0.0.1,localhost, or[::1], all operations are permitted without authentication.PTY_WEB_PASSWORDis set, the origin guard is bypassed entirely — authenticated requests are always writable regardless of origin.Files changed
src/web/server/auth.ts(new)src/web/server/server.tssrc/web/server/handlers/websocket.tssrc/web/server/handlers/upgrade.ts(deleted)src/web/client/components/terminal-renderer.tsx,app.tsx,index.htmlsrc/web/client/hooks/use-session-manager.tscallback-manager.ts,health.tstest/web-auth.test.ts(new),test/web-server-auth.test.ts(new),test/web-ws-guard.test.ts(new)