From 6c863044f46c619e81fb9bb71a1eb07b5294111c Mon Sep 17 00:00:00 2001 From: "Zhang.H.N" Date: Wed, 22 Jul 2026 22:39:35 +0800 Subject: [PATCH 1/2] feat(web): HTTP Basic Auth with HMAC-signed session cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 28 +++ src/plugin.ts | 28 ++- src/web/client/components/app.tsx | 102 +++++++- src/web/client/hooks/use-session-manager.ts | 16 +- src/web/client/index.html | 58 +++++ src/web/server/auth.ts | 231 ++++++++++++++++++ src/web/server/handlers/health.ts | 5 +- src/web/server/handlers/static.ts | 35 ++- src/web/server/server.ts | 230 +++++++++++++++--- src/web/shared/types.ts | 7 + test/web-auth.test.ts | 201 ++++++++++++++++ test/web-server-auth.test.ts | 249 ++++++++++++++++++++ test/web-server.test.ts | 7 +- 13 files changed, 1145 insertions(+), 52 deletions(-) create mode 100644 src/web/server/auth.ts create mode 100644 test/web-auth.test.ts create mode 100644 test/web-server-auth.test.ts diff --git a/README.md b/README.md index f23ac251..c40b0f0f 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,34 @@ This eliminates the need for polling—perfect for long-running processes like b | `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session | | `PTY_WEB_HOSTNAME` | `::1` | Hostname for the web server to bind to (IPv6 loopback by default) | | `PTY_WEB_PORT` | `0` (random) | Port for the web server (0 = random port) | +| `PTY_WEB_USERNAME` | OS user | Username accepted by the HTTP Basic Auth gate. Defaults to the OS account that launched the plugin | +| `PTY_WEB_PASSWORD` | unset | When set, every API request (including the WebSocket upgrade and the SPA shell) must carry `Authorization: Basic …` credentials. When unset, the API is open to anyone who can reach the bind address | + +### Web UI Authentication + +The web server is fully open by default. When you bind it to anything other than a +loopback address (e.g. `PTY_WEB_HOSTNAME=0.0.0.0` so a phone on the same network can +use it), set `PTY_WEB_PASSWORD` so the browser surfaces its native HTTP Basic Auth +prompt and unauthorized clients can no longer read your sessions: + +```bash +PTY_WEB_PASSWORD="choose-something-strong" opencode +# Optionally pin the username (defaults to the OS user running opencode): +PTY_WEB_USERNAME="me" PTY_WEB_PASSWORD="choose-something-strong" opencode +``` + +Behavior: + +- `/health` is **always** unauthenticated — the frontend uses it to figure out + whether auth is on. +- When `PTY_WEB_PASSWORD` is set, every other route (`/api/*`, `/ws`, the SPA + shell, the 302 redirect) returns `401` with `WWW-Authenticate: Basic …` until + the correct `Authorization: Basic base64(user:pass)` header is presented. +- When `PTY_WEB_PASSWORD` is **not** set and the page is loaded from a non-loopback + host, the UI shows a banner reminding you to enable Basic Auth, and the + `Kill Session` / `Ctrl+C` kill path is server-blocked (`403`) for non-loopback + origins so a passer-by can't take down your sessions. Loopback access always + keeps the previous, fully-open behavior. ### Permissions diff --git a/src/plugin.ts b/src/plugin.ts index 9f9f35c5..43adc76c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,15 +7,32 @@ import { ptyRead } from './plugin/pty/tools/read.ts' import { ptyList } from './plugin/pty/tools/list.ts' import { ptyKill } from './plugin/pty/tools/kill.ts' import { PTYServer } from './web/server/server.ts' +import { WebAuth } from './web/server/auth.ts' import open from 'open' const ptyOpenClientCommand = 'pty-open-background-spy' const ptyShowServerUrlCommand = 'pty-show-server-url' +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1') { + return true + } + if (normalized.startsWith('::ffff:')) { + const tail = normalized.slice('::ffff:'.length) + return tail === '127.0.0.1' + } + return false +} + export const PTYPlugin = async ({ client, directory }: PluginContext): Promise => { initPermissions(client, directory) initManager(client) let ptyServer: PTYServer | undefined + const webAuth = new WebAuth({ + username: process.env.PTY_WEB_USERNAME, + password: process.env.PTY_WEB_PASSWORD, + }) return { 'command.execute.before': async (input) => { @@ -23,12 +40,19 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise

{ + let cancelled = false + void (async () => { + try { + const response = await fetch('/health', { credentials: 'same-origin' }) + if (!response.ok) return + const data = (await response.json()) as { + authEnabled?: boolean + authUsername?: string + } + if (cancelled) return + setAuthEnabled(data.authEnabled === true) + setAuthUsername(typeof data.authUsername === 'string' ? data.authUsername : '') + } catch { + // Network failure is fine; leave defaults so the UI stays usable. + } + })() + return () => { + cancelled = true + } + }, []) + + return { authEnabled, authUsername } +} + +function useNonLoopback() { + return useState(() => !isLoopbackHostname(window.location.hostname))[0] +} + +function useDismissedAuthWarning() { + const [dismissed, setDismissed] = useState(() => { + try { + return localStorage.getItem(AUTH_WARNING_DISMISSED_KEY) === '1' + } catch { + return false + } + }) + const dismiss = useCallback(() => { + setDismissed(true) + try { + localStorage.setItem(AUTH_WARNING_DISMISSED_KEY, '1') + } catch { + // Ignore quota / privacy-mode failures; the in-memory flag still hides it. + } + }, []) + return { dismissed, dismiss } +} + export function App() { const [sessions, setSessions] = useState([]) const [activeSession, setActiveSession] = useState(null) @@ -17,6 +82,12 @@ export function App() { const [wsMessageCount, setWsMessageCount] = useState(0) const [sessionUpdateCount, setSessionUpdateCount] = useState(0) + const { authEnabled } = useAuthStatus() + const nonLoopback = useNonLoopback() + const { dismissed: authWarningDismissed, dismiss: dismissAuthWarning } = useDismissedAuthWarning() + const showAuthWarning = nonLoopback && !authEnabled && !authWarningDismissed + const killBlocked = nonLoopback && !authEnabled + const { connected: wsConnected, subscribeWithRetry, @@ -89,6 +160,7 @@ export function App() { subscribeWithRetry, sendInput, wsConnected, + killBlocked, onRawOutputUpdate: useCallback((rawOutput: string) => { setRawOutput(rawOutput) }, []), @@ -96,6 +168,24 @@ export function App() { return (

+ {showAuthWarning && ( + + + + Auth disabled. Set PTY_WEB_PASSWORD to secure kill/cleanup. + + + + )}
{activeSession.description ?? activeSession.title}
-
diff --git a/src/web/client/hooks/use-session-manager.ts b/src/web/client/hooks/use-session-manager.ts index 9411f9e7..b61137b3 100644 --- a/src/web/client/hooks/use-session-manager.ts +++ b/src/web/client/hooks/use-session-manager.ts @@ -9,6 +9,12 @@ interface UseSessionManagerOptions { subscribeWithRetry: (sessionId: string) => void sendInput?: (sessionId: string, data: string) => void wsConnected?: boolean + /** + * When true, kill operations are refused client-side. The server enforces + * the same rule on the API (returning 403), so disabling the handler here + * avoids a round-trip + an unfriendly error popup. + */ + killBlocked?: boolean onRawOutputUpdate?: (rawOutput: string) => void } @@ -18,6 +24,7 @@ export function useSessionManager({ subscribeWithRetry, sendInput, wsConnected, + killBlocked = false, onRawOutputUpdate, }: UseSessionManagerOptions) { const handleSessionClick = useCallback( @@ -81,6 +88,13 @@ export function useSessionManager({ return } + if (killBlocked) { + alert( + 'Killing sessions is disabled. Configure PTY_WEB_PASSWORD on the server (and reload the page) to enable kill when accessing the UI from a non-loopback host.' + ) + return + } + if ( !confirm( `Are you sure you want to kill session "${activeSession.description ?? activeSession.title}"?` @@ -94,7 +108,7 @@ export function useSessionManager({ // eslint-disable-next-line no-empty } catch {} - }, [activeSession]) + }, [activeSession, killBlocked]) return { handleSessionClick, diff --git a/src/web/client/index.html b/src/web/client/index.html index bf9bfb1c..e166c372 100644 --- a/src/web/client/index.html +++ b/src/web/client/index.html @@ -194,6 +194,64 @@ .connection-status.disconnected { color: #da3633; } + .kill-btn:disabled, + .kill-btn[disabled] { + background: #484f58; + color: #8b949e; + cursor: not-allowed; + } + .kill-btn:disabled:hover, + .kill-btn[disabled]:hover { + background: #484f58; + } + .auth-warning { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 1000; + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 380px; + border: 1px solid #d29922; + background: rgba(36, 28, 14, 0.92); + color: #e3b341; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + line-height: 1.4; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + } + .auth-warning-icon { + flex-shrink: 0; + font-size: 14px; + } + .auth-warning-text { + color: #e3b341; + } + .auth-warning code { + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + background: rgba(255, 255, 255, 0.08); + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; + color: #f0c674; + } + .auth-warning-dismiss { + background: transparent; + border: none; + color: #e3b341; + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 0 2px; + margin-left: 4px; + opacity: 0.7; + } + .auth-warning-dismiss:hover { + opacity: 1; + } diff --git a/src/web/server/auth.ts b/src/web/server/auth.ts new file mode 100644 index 00000000..de899899 --- /dev/null +++ b/src/web/server/auth.ts @@ -0,0 +1,231 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import { userInfo } from 'node:os' + +export const WEB_AUTH_REALM = 'OpenCode PTY Web Interface' + +export const SESSION_COOKIE_NAME = 'pty-session' + +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']) + +// IPv4-mapped IPv6 loopback in the URL canonicalized form. Both forms are +// commonly seen depending on whether the originator wrote the address in +// dotted-quad or IPv6 hex. +const IPV6_MAPPED_IPV4_LOOPBACK = new Set(['::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:7f00:0:1']) + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (LOOPBACK_HOSTNAMES.has(normalized)) return true + if (IPV6_MAPPED_IPV4_LOOPBACK.has(normalized)) return true + // Defense-in-depth: when the URL parser didn't normalize to hex, recurse. + if (normalized.startsWith('::ffff:')) { + return isLoopbackHostname(normalized.slice('::ffff:'.length)) + } + return false +} + +/** + * Returns true when the request's Origin header resolves to a loopback host + * (or is absent — which is the case for many non-browser clients). + * + * The check is intentionally permissive for missing Origin: only browser-driven + * cross-network calls expose an Origin we can verify, and the absence of one is + * the same outcome as a loopback one for our purposes (we still allow it). + */ +export function isLoopbackOriginRequest(req: Request): boolean { + const origin = req.headers.get('Origin') + if (!origin) return true + try { + const url = new URL(origin) + return isLoopbackHostname(url.hostname) + } catch { + return false + } +} + +export interface WebAuthOptions { + /** Plain-text password used for HTTP Basic Auth. Empty/undefined disables auth. */ + password?: string + /** + * Plain-text username. When empty the OS user that launched the process is + * used (via `os.userInfo().username`, then $USER / $USERNAME fallbacks). + */ + username?: string +} + +export interface WebAuthConfig { + enabled: boolean + username: string +} + +export interface AuthCheckResult { + ok: boolean + /** + * Populated on a successful auth via session cookie OR HTTP Basic Auth so + * callers can attach `Set-Cookie` to the response. When auth is disabled + * or the request failed, this is `undefined`. + */ + sessionToken?: string + response?: Response +} + +export class WebAuth { + private readonly enabled: boolean + private readonly username: string + private readonly expectedUsername: Buffer + private readonly expectedPassword: Buffer + /** + * Stateless session token: `HMAC(secret, username)`. The secret is + * regenerated per process, so server restarts invalidate every cookie + * (forcing a re-prompt) and there's no in-memory map to leak or sweep. + */ + private readonly sessionToken: string + private readonly secret: Buffer + + constructor(options: WebAuthOptions = {}) { + const password = (options.password ?? '').trim() + this.enabled = password.length > 0 + const explicitUsername = (options.username ?? '').trim() + this.username = explicitUsername.length > 0 ? explicitUsername : safeOsUsername() + this.expectedUsername = Buffer.from(this.username, 'utf8') + this.expectedPassword = Buffer.from(password, 'utf8') + this.secret = randomBytes(32) + this.sessionToken = createHmac('sha256', this.secret).update(this.username).digest('base64url') + } + + getConfig(): WebAuthConfig { + return { enabled: this.enabled, username: this.username } + } + + isEnabled(): boolean { + return this.enabled + } + + /** + * Build the `Set-Cookie` header value. No `Max-Age` / `Expires` is set so + * the cookie behaves as a session cookie — the browser drops it on close, + * which is exactly the lifecycle the user expects (re-prompt on browser + * restart, transparent reuse inside the same session). + */ + cookieHeader(sessionToken: string): string { + return `${SESSION_COOKIE_NAME}=${sessionToken}; HttpOnly; SameSite=Lax; Path=/` + } + + /** + * Validate the incoming request. + * + * Auth precedence: session cookie first (so Safari's reluctance to + * re-send HTTP Basic Auth on `fetch()` and WebSocket upgrade requests + * doesn't re-prompt after the first successful login), then HTTP Basic + * Auth. + */ + check(req: Request, path: string): AuthCheckResult { + if (!this.enabled) return { ok: true } + + if (path === '/health') return { ok: true } + + // 1. Session cookie — same-origin fetch() and WebSocket upgrade requests + // both include cookies, so this is what stops Safari re-prompting. + const cookieHeader = req.headers.get('Cookie') + if (cookieHeader) { + const token = parseSessionCookie(cookieHeader) + if (token && constantTimeEqualsString(token, this.sessionToken)) { + return { ok: true, sessionToken: this.sessionToken } + } + } + + // 2. HTTP Basic Auth. On success the same HMAC token is returned so the + // caller can attach it as `Set-Cookie`. + const header = req.headers.get('Authorization') + if (header) { + const decoded = decodeBasicAuth(header) + if ( + decoded && + constantTimeEquals(decoded.username, this.expectedUsername) && + constantTimeEquals(decoded.password, this.expectedPassword) + ) { + return { ok: true, sessionToken: this.sessionToken } + } + } + + return { ok: false, response: this.challenge() } + } + + challenge(): Response { + return new Response('Authentication required', { + status: 401, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'WWW-Authenticate': `Basic realm="${WEB_AUTH_REALM}", charset="UTF-8"`, + 'Cache-Control': 'no-store', + }, + }) + } +} + +function safeOsUsername(): string { + try { + const info = userInfo() + if (info.username && info.username.length > 0) return info.username + } catch { + // userInfo() can throw inside restricted sandboxes; fall through. + } + if (process.env.USER && process.env.USER.length > 0) return process.env.USER + if (process.env.USERNAME && process.env.USERNAME.length > 0) return process.env.USERNAME + return 'user' +} + +function constantTimeEquals(provided: string, expected: Buffer): boolean { + const providedBuf = Buffer.from(provided ?? '', 'utf8') + if (providedBuf.length !== expected.length) { + // Run a dummy compare so the call duration stays independent of length, + // partially defending against remote timing oracle for the credential. + timingSafeEqual(expected, expected) + return false + } + return timingSafeEqual(providedBuf, expected) +} + +function constantTimeEqualsString(provided: string, expected: string): boolean { + const a = Buffer.from(provided ?? '', 'utf8') + const b = Buffer.from(expected, 'utf8') + if (a.length !== b.length) { + timingSafeEqual(b, b) + return false + } + return timingSafeEqual(a, b) +} + +function decodeBasicAuth(header: string): { username: string; password: string } | null { + if (!header.toLowerCase().startsWith('basic ')) return null + const encoded = header.slice(6).trim() + if (!encoded) return null + let decoded: string + try { + decoded = Buffer.from(encoded, 'base64').toString('utf8') + } catch { + return null + } + const colon = decoded.indexOf(':') + if (colon === -1) return null + return { + username: decoded.slice(0, colon), + password: decoded.slice(colon + 1), + } +} + +/** + * Pull the `pty-session` value out of a `Cookie:` header without pulling in + * a full parser. Only the value of the named cookie is returned, so other + * unrelated cookies on the request are ignored. + */ +function parseSessionCookie(cookieHeader: string): string | null { + for (const part of cookieHeader.split(';')) { + const eq = part.indexOf('=') + if (eq === -1) continue + const name = part.slice(0, eq).trim() + if (name !== SESSION_COOKIE_NAME) continue + const value = part.slice(eq + 1).trim() + if (value.length > 0) return value + } + return null +} \ No newline at end of file diff --git a/src/web/server/handlers/health.ts b/src/web/server/handlers/health.ts index 5646d370..1cde7f64 100644 --- a/src/web/server/handlers/health.ts +++ b/src/web/server/handlers/health.ts @@ -1,8 +1,9 @@ import { manager } from '../../../plugin/pty/manager.ts' +import type { WebAuth } from '../auth.ts' import { JsonResponse } from './responses.ts' import type { HealthResponse } from '../../shared/types.ts' -export function handleHealth(server: Bun.Server) { +export function handleHealth(server: Bun.Server, auth: WebAuth | null = null) { const sessions = manager.list() const activeSessions = sessions.filter((s) => s.status === 'running').length const totalSessions = sessions.length @@ -28,6 +29,8 @@ export function handleHealth(server: Bun.Server) { heapTotal: process.memoryUsage().heapTotal, } : undefined, + authEnabled: auth?.isEnabled() ?? false, + authUsername: auth?.getConfig().username ?? '', } // Add response time diff --git a/src/web/server/handlers/static.ts b/src/web/server/handlers/static.ts index 1c354c7d..e9d68795 100644 --- a/src/web/server/handlers/static.ts +++ b/src/web/server/handlers/static.ts @@ -16,26 +16,47 @@ const SECURITY_HEADERS = { } as const const STATIC_DIR = join(PROJECT_ROOT, 'dist/web') -export async function buildStaticRoutes(): Promise> { +export interface StaticAssets { + /** Map of asset path → Response (e.g. `/assets/index-XYZ.js`). The SPA shell is excluded. */ + routes: Record + /** The built `index.html` response, ready to be served at `/` and `/index.html`. */ + indexHtml: Response +} + +export async function buildStaticRoutes(): Promise { const routes: Record = {} + let indexHtml: Response | null = null const files = readdirSync(STATIC_DIR, { recursive: true }) for (const file of files) { if (typeof file === 'string' && !statSync(join(STATIC_DIR, file)).isDirectory()) { const ext = extname(file) - const routeKey = `/${file.replace(/\\/g, '/')}` // e.g., /assets/js/bundle.js + const routeKey = `/${file.replace(/\\/g, '/')}` const fullPath = join(STATIC_DIR, file) const fileObj = Bun.file(fullPath) const contentType = fileObj.type || ASSET_CONTENT_TYPES[ext] || 'application/octet-stream' - - // Buffer all files in memory - routes[routeKey] = new Response(await fileObj.bytes(), { + const body = await fileObj.bytes() + const response = new Response(body, { headers: { 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', ...SECURITY_HEADERS, + // The SPA shell must never be cached long-term so deploys / config + // changes are picked up on the next load. Assets are content-hashed + // and immutable. + 'Cache-Control': + routeKey === '/index.html' + ? 'no-cache, must-revalidate' + : 'public, max-age=31536000, immutable', }, }) + if (routeKey === '/index.html') { + indexHtml = response + } else { + routes[routeKey] = response + } } } - return routes + if (!indexHtml) { + throw new Error(`buildStaticRoutes: ${join(STATIC_DIR, 'index.html')} not found`) + } + return { routes, indexHtml } } diff --git a/src/web/server/server.ts b/src/web/server/server.ts index c8e94ed7..80ecfde7 100644 --- a/src/web/server/server.ts +++ b/src/web/server/server.ts @@ -1,5 +1,6 @@ import type { Server } from 'bun' import { routes } from '../shared/routes.ts' +import { isLoopbackOriginRequest, WebAuth } from './auth.ts' import { CallbackManager } from './callback-manager.ts' import { handleHealth } from './handlers/health.ts' import { @@ -13,17 +14,27 @@ import { killSession, sendInput, } from './handlers/sessions.ts' -import { buildStaticRoutes } from './handlers/static.ts' -import { handleUpgrade } from './handlers/upgrade.ts' +import { buildStaticRoutes, type StaticAssets } from './handlers/static.ts' +import { ErrorResponse } from './handlers/responses.ts' import { handleWebSocketMessage } from './handlers/websocket.ts' +export interface PTYServerOptions { + /** + * Authentication configuration. When omitted, the server reads + * `PTY_WEB_USERNAME` / `PTY_WEB_PASSWORD` from the environment. + */ + auth?: WebAuth +} + export class PTYServer implements Disposable { public readonly server: Server - private readonly staticRoutes: Record + private readonly staticAssets: StaticAssets private readonly stack = new DisposableStack() + private readonly auth: WebAuth - private constructor(staticRoutes: Record) { - this.staticRoutes = staticRoutes + private constructor(staticAssets: StaticAssets, auth: WebAuth) { + this.staticAssets = staticAssets + this.auth = auth this.server = this.startWebServer() this.stack.use(this.server) this.stack.use(new CallbackManager(this.server)) @@ -33,44 +44,24 @@ export class PTYServer implements Disposable { this.stack.dispose() } - public static async createServer(): Promise { - const staticRoutes = await buildStaticRoutes() - - return new PTYServer(staticRoutes) + public static async createServer(options: PTYServerOptions = {}): Promise { + const staticAssets = await buildStaticRoutes() + const auth = options.auth ?? buildWebAuthFromEnv() + return new PTYServer(staticAssets, auth) } private startWebServer(): Server { + // Single `fetch` handler — matches opencode-mem's architecture. Using + // Bun's typed `routes` map routes static assets around the auth gate + // (their 200 response carries no `WWW-Authenticate`), which Safari's + // HTTP auth cache treats as a different protection space than the SPA + // shell, leading to re-prompts on the WebSocket upgrade and on refresh. + // Funneling every request through one handler guarantees a consistent + // auth challenge for the (host, port, realm) tuple the browser caches. return Bun.serve({ port: process.env.PTY_WEB_PORT ? parseInt(process.env.PTY_WEB_PORT, 10) : 0, hostname: process.env.PTY_WEB_HOSTNAME ?? '::1', - - routes: { - ...this.staticRoutes, - [routes.websocket.path]: (req: Request) => handleUpgrade(this.server, req), - [routes.health.path]: () => handleHealth(this.server), - [routes.sessions.path]: { - GET: getSessions, - POST: createSession, - DELETE: clearSessions, - }, - [routes.session.path]: { - GET: getSession, - DELETE: killSession, - }, - [routes.session.cleanup.path]: { - DELETE: cleanupSession, - }, - [routes.session.input.path]: { - POST: sendInput, - }, - [routes.session.buffer.raw.path]: { - GET: getRawBuffer, - }, - [routes.session.buffer.plain.path]: { - GET: getPlainBuffer, - }, - }, - + fetch: (req) => this.handleRequest(req), websocket: { data: undefined as undefined, perMessageDeflate: true, @@ -82,12 +73,173 @@ export class PTYServer implements Disposable { }) }, }, - - fetch: () => new Response(null, { status: 302, headers: { Location: '/index.html' } }), }) } + private async handleRequest(req: Request): Promise { + const url = new URL(req.url) + const path = url.pathname + const method = req.method + + if (method === 'OPTIONS') { + return new Response(null, { status: 204 }) + } + + // Auth gate runs for every request (including static assets and the SPA + // shell). /health is exempt so the frontend can probe auth state. + const check = this.auth.check(req, path) + if (!check.ok && check.response) { + return check.response + } + // Wrap dispatch so the response is augmented with Set-Cookie whenever the + // auth check minted (or refreshed) a session. Safari won't re-send HTTP + // Basic Auth on `fetch()` or WebSocket upgrade requests, but it WILL + // send a session cookie — this is what lets the user keep using the + // UI after a single browser prompt. + return await this.withSessionCookie(req, path, method, check.sessionToken) + } + + private async withSessionCookie( + req: Request, + path: string, + method: string, + sessionToken: string | undefined + ): Promise { + const response = await this.dispatch(req, path, method) + if (response === undefined || !sessionToken) return response + // Clone before mutating headers — Response bodies are single-use. + const augmented = new Response(response.body, response) + augmented.headers.append('Set-Cookie', this.auth.cookieHeader(sessionToken)) + return augmented + } + + private async dispatch( + req: Request, + path: string, + method: string + ): Promise { + // Health probe — unauthenticated by design. + if (path === routes.health.path && method === 'GET') { + return handleHealth(this.server, this.auth) + } + + // SPA shell. Served fresh on every request (Bun reads Response bodies + // exactly once) so cached credentials, concurrent tabs, and hot reloads + // all see the same content. + if (path === '/' || path === routes.session.path.replace(':id', 'index.html')) { + return this.staticAssets.indexHtml.clone() + } + + // Static assets — auth-gated so Safari's protection space cache stays + // warm for the whole (host, port, realm) tuple. + const asset = this.staticAssets.routes[path] + if (asset) { + return asset.clone() + } + + // WebSocket upgrade. + if (path === routes.websocket.path) { + if (req.headers.get('upgrade') !== 'websocket') { + return new Response('WebSocket endpoint - use WebSocket upgrade', { status: 426 }) + } + const success = this.server.upgrade(req) + if (success) return undefined + return new Response('WebSocket upgrade failed', { status: 400 }) + } + + // Destructive operations get an extra origin-based guard when auth is + // disabled, so an unauthenticated LAN visitor can't take down sessions. + const checkKillGuard = (): Response | null => { + if (this.auth.isEnabled() || isLoopbackOriginRequest(req)) return null + return new ErrorResponse( + 'Killing sessions from a non-loopback origin requires HTTP Basic Auth to be configured. ' + + 'Set the PTY_WEB_PASSWORD environment variable (and reload the web UI) to enable kill.', + 403 + ) + } + + if (path === routes.sessions.path) { + if (method === 'GET') return getSessions() + if (method === 'POST') return createSession(req) + if (method === 'DELETE') { + return checkKillGuard() ?? clearSessions() + } + } + + // Match `/api/sessions/:id` and the four sub-routes that hang off it. + const sessionIdPattern = /^\/api\/sessions\/([^/]+)$/ + const cleanupPattern = /^\/api\/sessions\/([^/]+)\/cleanup$/ + const inputPattern = /^\/api\/sessions\/([^/]+)\/input$/ + const rawBufferPattern = /^\/api\/sessions\/([^/]+)\/buffer\/raw$/ + const plainBufferPattern = /^\/api\/sessions\/([^/]+)\/buffer\/plain$/ + + const sessionMatch = path.match(sessionIdPattern) + if (sessionMatch) { + const bunReq = withParams(req, routes.session.path, { id: sessionMatch[1] ?? '' }) + if (method === 'GET') return getSession(bunReq) + if (method === 'DELETE') { + return checkKillGuard() ?? killSession(bunReq) + } + } + + const cleanupMatch = path.match(cleanupPattern) + if (cleanupMatch) { + const bunReq = withParams(req, routes.session.cleanup.path, { + id: cleanupMatch[1] ?? '', + }) + if (method === 'DELETE') { + return checkKillGuard() ?? cleanupSession(bunReq) + } + } + + const inputMatch = path.match(inputPattern) + if (inputMatch) { + const bunReq = withParams(req, routes.session.input.path, { + id: inputMatch[1] ?? '', + }) + if (method === 'POST') return sendInput(bunReq) + } + + const rawMatch = path.match(rawBufferPattern) + if (rawMatch) { + const bunReq = withParams(req, routes.session.buffer.raw.path, { + id: rawMatch[1] ?? '', + }) + if (method === 'GET') return getRawBuffer(bunReq) + } + + const plainMatch = path.match(plainBufferPattern) + if (plainMatch) { + const bunReq = withParams(req, routes.session.buffer.plain.path, { + id: plainMatch[1] ?? '', + }) + if (method === 'GET') return getPlainBuffer(bunReq) + } + + return new Response('Not Found', { status: 404 }) + } + public getWsUrl(): string { return `${this.server.url.origin.replace(/^http/, 'ws')}${routes.websocket.path}` } } + +function buildWebAuthFromEnv(): WebAuth { + return new WebAuth({ + username: process.env.PTY_WEB_USERNAME, + password: process.env.PTY_WEB_PASSWORD, + }) +} + +/** + * Construct a `BunRequest` from a plain `Request` by attaching the path + * parameters the route handler expects. Bun's typed `routes` feature used + * to do this automatically; the single-fetch dispatcher does it by hand. + */ +function withParams

( + req: Request, + _path: P, + params: Record +): Bun.BunRequest

{ + return Object.assign(req, { params, cookies: new Map() }) as unknown as Bun.BunRequest

+} diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts index aaa795b5..cbf9de2b 100644 --- a/src/web/shared/types.ts +++ b/src/web/shared/types.ts @@ -108,4 +108,11 @@ interface HealthResponse { websocket: { connections: number } memory?: { rss: number; heapUsed: number; heapTotal: number } responseTime?: number + /** + * Whether HTTP Basic Auth is enforced by the web server. The frontend reads + * this to decide whether to display the cross-origin warning banner. + */ + authEnabled: boolean + /** The username that the server expects (OS user when none configured). */ + authUsername: string } diff --git a/test/web-auth.test.ts b/test/web-auth.test.ts new file mode 100644 index 00000000..ba42956a --- /dev/null +++ b/test/web-auth.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'bun:test' +import { + isLoopbackOriginRequest, + SESSION_COOKIE_NAME, + WebAuth, + WEB_AUTH_REALM, +} from '../src/web/server/auth.ts' + +function basicHeader(username: string, password: string): string { + const encoded = Buffer.from(`${username}:${password}`, 'utf8').toString('base64') + return `Basic ${encoded}` +} + +function cookieHeader(token: string): string { + return `${SESSION_COOKIE_NAME}=${token}` +} + +describe('WebAuth', () => { + describe('when no password is configured', () => { + const auth = new WebAuth({ username: 'alice' }) + + it('reports disabled and keeps the configured username', () => { + expect(auth.isEnabled()).toBe(false) + expect(auth.getConfig()).toEqual({ enabled: false, username: 'alice' }) + }) + + it('lets every request pass through', () => { + const result = auth.check(new Request('http://localhost/api/sessions'), '/api/sessions') + expect(result.ok).toBe(true) + expect(result.response).toBeUndefined() + }) + + it('does not require Basic Auth even when /health is targeted', () => { + const result = auth.check(new Request('http://localhost/health'), '/health') + expect(result.ok).toBe(true) + }) + }) + + describe('when a password is configured', () => { + const auth = new WebAuth({ username: 'alice', password: 'hunter2' }) + + it('reports enabled and exposes the username', () => { + expect(auth.isEnabled()).toBe(true) + expect(auth.getConfig()).toEqual({ enabled: true, username: 'alice' }) + }) + + it('returns a 401 + WWW-Authenticate challenge for unauthenticated requests', () => { + const result = auth.check(new Request('http://localhost/api/sessions'), '/api/sessions') + expect(result.ok).toBe(false) + const response = result.response + expect(response).toBeDefined() + expect(response?.status).toBe(401) + expect(response?.headers.get('WWW-Authenticate')).toBe( + `Basic realm="${WEB_AUTH_REALM}", charset="UTF-8"` + ) + expect(response?.headers.get('Cache-Control')).toBe('no-store') + }) + + it('admits requests that carry the right credentials', () => { + const req = new Request('http://localhost/api/sessions', { + headers: { Authorization: basicHeader('alice', 'hunter2') }, + }) + const result = auth.check(req, '/api/sessions') + expect(result.ok).toBe(true) + // The same HMAC token is returned so the server can attach Set-Cookie. + expect(typeof result.sessionToken).toBe('string') + expect(result.sessionToken?.length).toBeGreaterThan(0) + }) + + it('admits requests carrying a valid session cookie', () => { + const initial = auth.check( + new Request('http://localhost/api/sessions', { + headers: { Authorization: basicHeader('alice', 'hunter2') }, + }), + '/api/sessions' + ) + const token = initial.sessionToken ?? '' + // No Authorization header — only the cookie. This is the path Safari + // fails on without a session token. + const result = auth.check( + new Request('http://localhost/api/sessions', { + headers: { Cookie: cookieHeader(token) }, + }), + '/api/sessions' + ) + expect(result.ok).toBe(true) + }) + + it('rejects requests with a forged or stale session cookie', () => { + const result = auth.check( + new Request('http://localhost/api/sessions', { + headers: { Cookie: cookieHeader('not-a-real-token') }, + }), + '/api/sessions' + ) + expect(result.ok).toBe(false) + }) + + it('cookieHeader produces a session cookie (no Max-Age)', () => { + const header = auth.cookieHeader('abc123') + expect(header).toContain(`${SESSION_COOKIE_NAME}=abc123`) + expect(header).toContain('HttpOnly') + expect(header).toContain('SameSite=Lax') + expect(header).toContain('Path=/') + expect(header).not.toContain('Max-Age') + expect(header).not.toContain('Expires') + }) + + it("a fresh WebAuth instance rejects the previous instance's token", () => { + const token = + auth.check( + new Request('http://localhost/api/sessions', { + headers: { Authorization: basicHeader('alice', 'hunter2') }, + }), + '/api/sessions' + ).sessionToken ?? '' + // Simulates server restart: secret rotates, old cookies no longer + // verify, the user is forced to re-authenticate. + const freshAuth = new WebAuth({ username: 'alice', password: 'hunter2' }) + const result = freshAuth.check( + new Request('http://localhost/api/sessions', { + headers: { Cookie: cookieHeader(token) }, + }), + '/api/sessions' + ) + expect(result.ok).toBe(false) + }) + + it('rejects requests with the wrong password', () => { + const req = new Request('http://localhost/api/sessions', { + headers: { Authorization: basicHeader('alice', 'wrong') }, + }) + const result = auth.check(req, '/api/sessions') + expect(result.ok).toBe(false) + }) + + it('rejects requests with the wrong username', () => { + const req = new Request('http://localhost/api/sessions', { + headers: { Authorization: basicHeader('mallory', 'hunter2') }, + }) + const result = auth.check(req, '/api/sessions') + expect(result.ok).toBe(false) + }) + + it('treats malformed Basic headers as unauthorized', () => { + const req = new Request('http://localhost/api/sessions', { + headers: { Authorization: 'Basic !!not-base64!!' }, + }) + const result = auth.check(req, '/api/sessions') + expect(result.ok).toBe(false) + }) + + it('treats Bearer headers as unauthorized', () => { + const req = new Request('http://localhost/api/sessions', { + headers: { Authorization: 'Bearer abcdef' }, + }) + const result = auth.check(req, '/api/sessions') + expect(result.ok).toBe(false) + }) + + it('admits /health requests without credentials', () => { + const result = auth.check(new Request('http://localhost/health'), '/health') + expect(result.ok).toBe(true) + }) + }) + + describe('when the user is only partially specified', () => { + it('falls back to the OS user when no username is configured', () => { + const auth = new WebAuth({ password: 'hunter2' }) + expect(auth.isEnabled()).toBe(true) + expect(auth.getConfig().username.length).toBeGreaterThan(0) + }) + + it('treats whitespace-only inputs as disabled / unset', () => { + const auth = new WebAuth({ username: ' ', password: ' ' }) + expect(auth.isEnabled()).toBe(false) + }) + }) +}) + +describe('isLoopbackOriginRequest', () => { + const cases: Array<{ origin: string | null; expected: boolean }> = [ + { origin: null, expected: true }, + { origin: 'http://localhost:8080', expected: true }, + { origin: 'http://127.0.0.1:8080', expected: true }, + { origin: 'http://[::1]:8080', expected: true }, + { origin: 'http://[::ffff:127.0.0.1]:8080', expected: true }, + { origin: 'http://192.168.1.10:8080', expected: false }, + { origin: 'http://example.com', expected: false }, + { origin: 'not-a-url', expected: false }, + ] + + for (const { origin, expected } of cases) { + it(`returns ${String(expected)} for origin=${String(origin)}`, () => { + const req = new Request('http://example.com/api/sessions', { + headers: origin ? { Origin: origin } : {}, + }) + expect(isLoopbackOriginRequest(req)).toBe(expected) + }) + } +}) diff --git a/test/web-server-auth.test.ts b/test/web-server-auth.test.ts new file mode 100644 index 00000000..32ebcff6 --- /dev/null +++ b/test/web-server-auth.test.ts @@ -0,0 +1,249 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' +import { PTYServer } from '../src/web/server/server.ts' +import { WebAuth } from '../src/web/server/auth.ts' + +function basicHeader(username: string, password: string): string { + const encoded = Buffer.from(`${username}:${password}`, 'utf8').toString('base64') + return `Basic ${encoded}` +} + +describe('Web Server HTTP Basic Auth', () => { + describe('with auth disabled', () => { + let server: PTYServer + beforeAll(async () => { + server = await PTYServer.createServer() + }) + afterAll(() => { + server[Symbol.dispose]() + }) + + it('exposes authEnabled=false on /health', async () => { + const res = await fetch(`${server.server.url}/health`) + expect(res.status).toBe(200) + const body = (await res.json()) as { authEnabled: boolean; authUsername: string } + expect(body.authEnabled).toBe(false) + expect(typeof body.authUsername).toBe('string') + }) + + it('does not gate the API', async () => { + const res = await fetch(`${server.server.url}/api/sessions`) + expect(res.status).toBe(200) + }) + }) + + describe('with auth enabled', () => { + let server: PTYServer + beforeAll(async () => { + const auth = new WebAuth({ username: 'svc', password: 'open-sesame' }) + server = await PTYServer.createServer({ auth }) + }) + afterAll(() => { + server[Symbol.dispose]() + }) + + it('reports authEnabled=true and the username on /health (still no creds required)', async () => { + const res = await fetch(`${server.server.url}/health`) + expect(res.status).toBe(200) + const body = (await res.json()) as { authEnabled: boolean; authUsername: string } + expect(body.authEnabled).toBe(true) + expect(body.authUsername).toBe('svc') + }) + + it('returns a 401 + WWW-Authenticate challenge for unauthenticated API requests', async () => { + const res = await fetch(`${server.server.url}/api/sessions`) + expect(res.status).toBe(401) + expect(res.headers.get('WWW-Authenticate')).toContain('Basic') + expect(res.headers.get('Cache-Control')).toBe('no-store') + }) + + it('admits requests that carry correct Basic credentials', async () => { + const res = await fetch(`${server.server.url}/api/sessions`, { + headers: { Authorization: basicHeader('svc', 'open-sesame') }, + }) + expect(res.status).toBe(200) + }) + + it('rejects requests with the wrong credentials', async () => { + const res = await fetch(`${server.server.url}/api/sessions`, { + headers: { Authorization: basicHeader('svc', 'wrong') }, + }) + expect(res.status).toBe(401) + }) + + it('gates destructive routes (DELETE) with the same auth check', async () => { + // Regression: kill/cleanup/clear routes must enforce auth too, otherwise + // anyone reaching the LAN address can kill sessions despite Basic Auth. + const noCreds = await fetch(`${server.server.url}/api/sessions/nope`, { + method: 'DELETE', + headers: { Origin: 'http://192.168.1.10:12345' }, + }) + expect(noCreds.status).toBe(401) + + const wrongCreds = await fetch(`${server.server.url}/api/sessions/nope`, { + method: 'DELETE', + headers: { + Authorization: basicHeader('svc', 'wrong'), + Origin: 'http://192.168.1.10:12345', + }, + }) + expect(wrongCreds.status).toBe(401) + + const rightCreds = await fetch(`${server.server.url}/api/sessions/nope`, { + method: 'DELETE', + headers: { + Authorization: basicHeader('svc', 'open-sesame'), + Origin: 'http://192.168.1.10:12345', + }, + }) + // 400 because the session doesn't exist; 200 would mean auth was bypassed. + expect(rightCreds.status).toBe(400) + }) + + it('issues a session cookie on successful Basic Auth', async () => { + const res = await fetch(`${server.server.url}/api/sessions`, { + headers: { Authorization: basicHeader('svc', 'open-sesame') }, + }) + expect(res.status).toBe(200) + const setCookie = res.headers.get('set-cookie') ?? '' + expect(setCookie).toMatch(/pty-session=[^;]+/) + expect(setCookie).toContain('HttpOnly') + expect(setCookie).toContain('SameSite=Lax') + expect(setCookie).toContain('Path=/') + expect(setCookie).not.toContain('Max-Age') + }) + + it('accepts the issued session cookie on subsequent requests without Basic Auth', async () => { + // First call: supply Basic Auth, capture the cookie. + const initial = await fetch(`${server.server.url}/api/sessions`, { + headers: { Authorization: basicHeader('svc', 'open-sesame') }, + }) + const cookie = initial.headers.get('set-cookie')?.split(';')[0] ?? '' + expect(cookie).toMatch(/pty-session=/) + + // Second call: only the cookie, no Authorization header. This is the + // exact flow Safari needs for `fetch()` and WebSocket upgrade. + const second = await fetch(`${server.server.url}/api/sessions`, { + headers: { Cookie: cookie }, + }) + expect(second.status).toBe(200) + }) + + it('rejects a forged session cookie', async () => { + const res = await fetch(`${server.server.url}/api/sessions`, { + headers: { Cookie: 'pty-session=totally-not-a-real-token' }, + }) + expect(res.status).toBe(401) + }) + + it('WebSocket upgrade authenticates via the session cookie', async () => { + const initial = await fetch(`${server.server.url}/api/sessions`, { + headers: { Authorization: basicHeader('svc', 'open-sesame') }, + }) + const cookie = initial.headers.get('set-cookie')?.split(';')[0] ?? '' + expect(cookie).toMatch(/pty-session=/) + + const upgraded = await fetch(`${server.server.url.toString()}/ws`, { + headers: { + Cookie: cookie, + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13', + }, + }).catch((error: unknown) => error) + expect(upgraded).toBeInstanceOf(Response) + expect((upgraded as Response).status).toBe(101) + }) + + it('gates the SPA shell with the same challenge', async () => { + const res = await fetch(`${server.server.url}/`) + expect(res.status).toBe(401) + expect(res.headers.get('WWW-Authenticate')).toContain('Basic') + }) + + it('refuses WebSocket upgrades without credentials', async () => { + // Use the http:// origin (not ws://) — Bun's fetch rejects ws://. Bun will + // reject the upgrade since it sees Upgrade/Connection headers but the auth + // check runs first and returns 401. + const upgraded = await fetch(`${server.server.url.toString()}/ws`, { + headers: { + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13', + }, + }).catch((error: unknown) => error) + // A non-upgrade response comes back as a regular Response. + expect(upgraded).toBeInstanceOf(Response) + expect((upgraded as Response).status).toBe(401) + }) + }) + + describe('with auth disabled and origin guard', () => { + let server: PTYServer + beforeAll(async () => { + // Sanity check: confirm we DID NOT pass an auth. PTYServer.createServer + // without options falls back to env vars — make sure those are unset so + // we exercise the "no password set" path explicitly. + const previous = { ...process.env } + delete process.env.PTY_WEB_PASSWORD + delete process.env.PTY_WEB_USERNAME + server = await PTYServer.createServer() + Object.assign(process.env, previous) + }) + afterAll(() => { + server[Symbol.dispose]() + }) + + it('allows kill from a loopback origin', async () => { + const res = await fetch(`${server.server.url}/api/sessions/nonexistent`, { + method: 'DELETE', + headers: { Origin: 'http://localhost:12345' }, + }) + // The session doesn't exist so we expect 400, not 403 — the guard is + // about origin, not session existence. + expect(res.status).toBe(400) + }) + + it('refuses kill from a non-loopback origin with a 403', async () => { + const res = await fetch(`${server.server.url}/api/sessions/nonexistent`, { + method: 'DELETE', + headers: { Origin: 'http://192.168.1.10:12345' }, + }) + expect(res.status).toBe(403) + const text = await res.text() + expect(text).toContain('PTY_WEB_PASSWORD') + }) + + it('refuses cleanup from a non-loopback origin', async () => { + const res = await fetch(`${server.server.url}/api/sessions/nonexistent/cleanup`, { + method: 'DELETE', + headers: { Origin: 'http://example.com' }, + }) + expect(res.status).toBe(403) + }) + + it('refuses clear-all from a non-loopback origin', async () => { + const res = await fetch(`${server.server.url}/api/sessions`, { + method: 'DELETE', + headers: { Origin: 'http://attacker.example' }, + }) + expect(res.status).toBe(403) + }) + + it('still allows kill from a missing origin (non-browser clients)', async () => { + const res = await fetch(`${server.server.url}/api/sessions/nonexistent`, { + method: 'DELETE', + }) + expect(res.status).toBe(400) + }) + + it('does not gate non-destructive routes by origin', async () => { + // Should return an empty array, not a 403 — the guard is kill-specific. + const res = await fetch(`${server.server.url}/api/sessions`, { + headers: { Origin: 'http://attacker.example' }, + }) + expect(res.status).toBe(200) + }) + }) +}) diff --git a/test/web-server.test.ts b/test/web-server.test.ts index 991bed31..09ebede4 100644 --- a/test/web-server.test.ts +++ b/test/web-server.test.ts @@ -273,8 +273,13 @@ describe('Web Server', () => { expect(bufferData.raw).toBe('line1\r\nline2\r\nline3\r\n') }) - it('should return index.html for non-existent endpoints', async () => { + it('should return 404 for non-existent endpoints', async () => { const response = await fetch(`${managedTestServer.server.server.url}/api/nonexistent`) + expect(response.status).toBe(404) + }, 200) + + it('should serve the SPA shell at /', async () => { + const response = await fetch(managedTestServer.server.server.url) expect(response.status).toBe(200) const text = await response.text() expect(text).toContain('

') From 9da2feea2a98b5bc4159560f1f1741eab86d8c0c Mon Sep 17 00:00:00 2001 From: "Zhang.H.N" Date: Thu, 23 Jul 2026 09:10:19 +0800 Subject: [PATCH 2/2] feat(web): read-only guard for non-loopback unauthenticated origins 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 --- .gitignore | 3 + src/web/client/components/app.tsx | 10 +- .../client/components/terminal-renderer.tsx | 58 +++++++- src/web/client/hooks/use-session-manager.ts | 20 ++- src/web/client/index.html | 17 +++ src/web/server/auth.ts | 18 ++- src/web/server/callback-manager.ts | 3 +- src/web/server/handlers/health.ts | 6 +- src/web/server/handlers/upgrade.ts | 10 -- src/web/server/handlers/websocket.ts | 59 ++++++-- src/web/server/server.ts | 49 ++++--- test/web-auth.test.ts | 27 ++++ test/web-permissions.test.ts | 13 +- test/web-server-auth.test.ts | 70 ++++++++++ test/web-ws-guard.test.ts | 127 ++++++++++++++++++ 15 files changed, 439 insertions(+), 51 deletions(-) delete mode 100644 src/web/server/handlers/upgrade.ts create mode 100644 test/web-ws-guard.test.ts diff --git a/.gitignore b/.gitignore index bc59ccda..1054d0d6 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,8 @@ devenv.local.yaml # direnv .direnv +# codegraph index +.codegraph/ + # pre-commit .pre-commit-config.yaml diff --git a/src/web/client/components/app.tsx b/src/web/client/components/app.tsx index 294084be..9bd3e5b7 100644 --- a/src/web/client/components/app.tsx +++ b/src/web/client/components/app.tsx @@ -81,12 +81,12 @@ export function App() { const [connected, setConnected] = useState(false) const [wsMessageCount, setWsMessageCount] = useState(0) const [sessionUpdateCount, setSessionUpdateCount] = useState(0) - const { authEnabled } = useAuthStatus() const nonLoopback = useNonLoopback() const { dismissed: authWarningDismissed, dismiss: dismissAuthWarning } = useDismissedAuthWarning() const showAuthWarning = nonLoopback && !authEnabled && !authWarningDismissed const killBlocked = nonLoopback && !authEnabled + const inputBlocked = killBlocked const { connected: wsConnected, @@ -161,6 +161,7 @@ export function App() { sendInput, wsConnected, killBlocked, + inputBlocked, onRawOutputUpdate: useCallback((rawOutput: string) => { setRawOutput(rawOutput) }, []), @@ -218,8 +219,15 @@ export function App() { onSendInput={handleSendInput} onInterrupt={handleKillSession} disabled={!activeSession || activeSession.status !== 'running'} + readOnly={inputBlocked} />
+ {inputBlocked && ( + + Read-only — input is disabled because HTTP Basic Auth is not configured and the UI + is being accessed from a non-loopback host. Selection, copy, and paste still work. + + )}
Debug: {rawOutput.length} chars, active: {activeSession?.id || 'none'}, WS raw_data:{' '} {wsMessageCount}, session_updates: {sessionUpdateCount} diff --git a/src/web/client/components/terminal-renderer.tsx b/src/web/client/components/terminal-renderer.tsx index e79cc085..5cb5bbb4 100644 --- a/src/web/client/components/terminal-renderer.tsx +++ b/src/web/client/components/terminal-renderer.tsx @@ -17,6 +17,14 @@ interface RawTerminalProps { onSendInput?: (data: string) => void onInterrupt?: () => void disabled?: boolean + /** + * When true the user can still see and select/copy the rendered output, + * but every keystroke is intercepted by `attachCustomKeyEventHandler` + * before xterm forwards it to the PTY. This is what the non-loopback + * read-only mode uses — the backend would reject the write anyway, but + * silently dropping keys here gives immediate UI feedback. + */ + readOnly?: boolean } export class RawTerminal extends React.Component { @@ -49,6 +57,12 @@ export class RawTerminal extends React.Component { this.xtermInstance.clear() this.xtermInstance.write(currentData) } + + // Re-apply read-only mode when the prop changes (e.g. user enabled auth + // and the UI flipped from read-only to writable after a /health probe). + if (prevProps.readOnly !== this.props.readOnly) { + this.applyReadOnly(this.props.readOnly === true) + } } override componentWillUnmount() { @@ -84,14 +98,46 @@ export class RawTerminal extends React.Component { window.xtermTerminal = term window.xtermSerializeAddon = this.serializeAddon - // Set up input handling + this.applyReadOnly(this.props.readOnly === true) this.setupInputHandling(term) } + /** + * Install (or uninstall) a key-event filter that blocks every "input + * intent" key while still letting modifier-only combinations and named + * navigation keys fall through to the browser so the user can keep + * selecting / copying / pasting. + * + * Returning `false` from `attachCustomKeyEventHandler` tells xterm NOT to + * forward the key as PTY data; the browser then handles it natively + * (selection, copy, paste, select-all, scroll, etc. all keep working). + */ + private applyReadOnly(readOnly: boolean) { + const term = this.xtermInstance + if (!term) return + if (!readOnly) { + term.attachCustomKeyEventHandler(() => true) + return + } + term.attachCustomKeyEventHandler((event) => { + // Any modifier combo (Ctrl / Cmd / Alt) belongs to the browser — copy, + // paste, select-all, find, reload. Always let the browser handle them. + if (event.ctrlKey || event.metaKey || event.altKey) return false + // Named navigation / editing keys the browser uses for selection — + // arrow keys, Home / End, PageUp / PageDown, Tab, Esc, F1-F12. + // Returning false here means xterm leaves them alone, so the browser's + // built-in text-selection shortcuts work as the user expects. + if (event.key.length !== 1) return false + // Single-character key with no modifiers = the user is trying to type + // something into the PTY. Swallow it. + return false + }) + } + private setupInputHandling(term: Terminal) { - const { onSendInput, onInterrupt, disabled } = this.props + const { onSendInput, onInterrupt, disabled, readOnly } = this.props - if (disabled) return + if (disabled || readOnly) return const handleData = (data: string) => { if (data === '\u0003') { @@ -108,7 +154,11 @@ export class RawTerminal extends React.Component { override render() { return ( -
+
) } } diff --git a/src/web/client/hooks/use-session-manager.ts b/src/web/client/hooks/use-session-manager.ts index b61137b3..ab338709 100644 --- a/src/web/client/hooks/use-session-manager.ts +++ b/src/web/client/hooks/use-session-manager.ts @@ -10,11 +10,16 @@ interface UseSessionManagerOptions { sendInput?: (sessionId: string, data: string) => void wsConnected?: boolean /** - * When true, kill operations are refused client-side. The server enforces - * the same rule on the API (returning 403), so disabling the handler here - * avoids a round-trip + an unfriendly error popup. + * When true, all PTY mutations (input, kill, cleanup) are refused client-side. + * The server enforces the same rule (403), so disabling the handlers here + * avoids round-trips + unfriendly error popups. */ killBlocked?: boolean + /** + * When true, input into the PTY is forbidden but the user can still select, + * copy, and paste. Mirrors the server-side read-only WS upgrade state. + */ + inputBlocked?: boolean onRawOutputUpdate?: (rawOutput: string) => void } @@ -25,6 +30,7 @@ export function useSessionManager({ sendInput, wsConnected, killBlocked = false, + inputBlocked = false, onRawOutputUpdate, }: UseSessionManagerOptions) { const handleSessionClick = useCallback( @@ -64,6 +70,12 @@ export function useSessionManager({ return } + if (inputBlocked) { + // The xterm layer should already be swallowing keys; this guard + // covers the HTTP fallback path used when the WS path is unavailable. + return + } + // Try WebSocket first if connected and available if (wsConnected && sendInput) { try { @@ -80,7 +92,7 @@ export function useSessionManager({ // eslint-disable-next-line no-empty } catch {} }, - [activeSession, wsConnected, sendInput] + [activeSession, wsConnected, sendInput, inputBlocked] ) const handleKillSession = useCallback(async () => { diff --git a/src/web/client/index.html b/src/web/client/index.html index e166c372..b1b3c514 100644 --- a/src/web/client/index.html +++ b/src/web/client/index.html @@ -238,6 +238,23 @@ font-size: 11px; color: #f0c674; } + .terminal-readonly-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + margin-top: 4px; + border: 1px solid #d29922; + background: rgba(210, 153, 34, 0.08); + color: #e3b341; + font-size: 12px; + line-height: 1.4; + border-radius: 4px; + } + .xterm-readonly .xterm-viewport, + .xterm-readonly .xterm-screen { + opacity: 0.85; + } .auth-warning-dismiss { background: transparent; border: none; diff --git a/src/web/server/auth.ts b/src/web/server/auth.ts index de899899..2df89f76 100644 --- a/src/web/server/auth.ts +++ b/src/web/server/auth.ts @@ -42,6 +42,22 @@ export function isLoopbackOriginRequest(req: Request): boolean { } } +/** + * Variant of {@link isLoopbackOriginRequest} that inspects the `Host` header + * instead of `Origin`. We need this for WebSocket upgrades because most + * non-browser WS clients (and Bun's own `WebSocket` class) do NOT send an + * `Origin` header on the upgrade — the only reliable signal of "did the + * client reach us via a loopback address" is then the `Host` they connected + * to (which mirrors how they typed the URL). + */ +export function isLoopbackHostRequest(req: Request): boolean { + const host = req.headers.get('Host') + if (!host) return true + // Host is `hostname[:port]` — peel the port before checking. + const hostname = host.replace(/:\d+$/, '') + return isLoopbackHostname(hostname) +} + export interface WebAuthOptions { /** Plain-text password used for HTTP Basic Auth. Empty/undefined disables auth. */ password?: string @@ -228,4 +244,4 @@ function parseSessionCookie(cookieHeader: string): string | null { if (value.length > 0) return value } return null -} \ No newline at end of file +} diff --git a/src/web/server/callback-manager.ts b/src/web/server/callback-manager.ts index e9a8040b..70e051ff 100644 --- a/src/web/server/callback-manager.ts +++ b/src/web/server/callback-manager.ts @@ -6,9 +6,10 @@ import { } from '../../plugin/pty/manager' import type { PTYSessionInfo } from '../../plugin/pty/types' import type { WSMessageServerSessionUpdate, WSMessageServerRawData } from '../shared/types' +import type { WebSocketConnectionState } from './handlers/websocket.ts' export class CallbackManager implements Disposable { - constructor(private server: Bun.Server) { + constructor(private server: Bun.Server) { this.server = server registerSessionUpdateCallback(this.sessionUpdateCallback) registerRawOutputCallback(this.rawOutputCallback) diff --git a/src/web/server/handlers/health.ts b/src/web/server/handlers/health.ts index 1cde7f64..362c3714 100644 --- a/src/web/server/handlers/health.ts +++ b/src/web/server/handlers/health.ts @@ -1,9 +1,13 @@ import { manager } from '../../../plugin/pty/manager.ts' import type { WebAuth } from '../auth.ts' +import type { WebSocketConnectionState } from './websocket.ts' import { JsonResponse } from './responses.ts' import type { HealthResponse } from '../../shared/types.ts' -export function handleHealth(server: Bun.Server, auth: WebAuth | null = null) { +export function handleHealth( + server: Bun.Server, + auth: WebAuth | null = null +) { const sessions = manager.list() const activeSessions = sessions.filter((s) => s.status === 'running').length const totalSessions = sessions.length diff --git a/src/web/server/handlers/upgrade.ts b/src/web/server/handlers/upgrade.ts deleted file mode 100644 index 5b1b8fb6..00000000 --- a/src/web/server/handlers/upgrade.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function handleUpgrade(server: Bun.Server, req: Request) { - if (!(req.headers.get('upgrade') === 'websocket')) { - return new Response('WebSocket endpoint - use WebSocket upgrade', { status: 426 }) - } - const success = server.upgrade(req) - if (success) { - return undefined // Upgrade succeeded, Bun sends 101 automatically - } - return new Response('WebSocket upgrade failed', { status: 400 }) -} diff --git a/src/web/server/handlers/websocket.ts b/src/web/server/handlers/websocket.ts index 1ad79567..6d37cd7c 100644 --- a/src/web/server/handlers/websocket.ts +++ b/src/web/server/handlers/websocket.ts @@ -17,15 +17,25 @@ import { type WSMessageServerUnsubscribedSession, } from '../../shared/types' +/** Per-connection state attached at WebSocket upgrade time. */ +export interface WebSocketConnectionState { + /** + * Whether this connection is allowed to push data into PTYs (via the + * `input` message) or create new sessions (via `spawn`). Determined at + * upgrade time from the auth gate + the request's Origin header. + */ + writable: boolean +} + class WebSocketHandler { - private sendSessionList(ws: ServerWebSocket): void { + private sendSessionList(ws: ServerWebSocket): void { const sessions = manager.list() const message: WSMessageServerSessionList = { type: 'session_list', sessions } ws.send(JSON.stringify(message)) } private handleSubscribe( - ws: ServerWebSocket, + ws: ServerWebSocket, message: WSMessageClientSubscribeSession ): void { const session = manager.get(message.sessionId) @@ -46,7 +56,7 @@ class WebSocketHandler { } private handleUnsubscribe( - ws: ServerWebSocket, + ws: ServerWebSocket, message: WSMessageClientUnsubscribeSession ): void { const topic = `session:${message.sessionId}` @@ -59,13 +69,16 @@ class WebSocketHandler { } private handleSessionListRequest( - ws: ServerWebSocket, + ws: ServerWebSocket, _message: WSMessageClientSessionList ): void { this.sendSessionList(ws) } - private handleUnknownMessage(ws: ServerWebSocket, message: WSMessageClient): void { + private handleUnknownMessage( + ws: ServerWebSocket, + message: WSMessageClient + ): void { const error: WSMessageServerError = { type: 'error', error: new CustomError(`Unknown message type ${message.type}`), @@ -74,7 +87,7 @@ class WebSocketHandler { } public handleWebSocketMessage( - ws: ServerWebSocket, + ws: ServerWebSocket, data: string | Buffer ): void { if (typeof data !== 'string') { @@ -102,10 +115,18 @@ class WebSocketHandler { break case 'spawn': + if (!isWritable(ws)) { + sendWriteError(ws) + break + } void this.handleSpawn(ws, message as WSMessageClientSpawnSession) break case 'input': + if (!isWritable(ws)) { + sendWriteError(ws) + break + } this.handleInput(message as WSMessageClientInput) break @@ -125,7 +146,10 @@ class WebSocketHandler { } } - private async handleSpawn(ws: ServerWebSocket, message: WSMessageClientSpawnSession) { + private async handleSpawn( + ws: ServerWebSocket, + message: WSMessageClientSpawnSession + ) { try { await checkCommandPermission(message.command, message.args ?? []) if (message.workdir) { @@ -149,7 +173,10 @@ class WebSocketHandler { manager.write(message.sessionId, message.data) } - private handleReadRaw(ws: ServerWebSocket, message: WSMessageClientReadRaw) { + private handleReadRaw( + ws: ServerWebSocket, + message: WSMessageClientReadRaw + ) { const rawData = manager.getRawBuffer(message.sessionId) if (!rawData) { const error: WSMessageServerError = { @@ -169,9 +196,23 @@ class WebSocketHandler { } export function handleWebSocketMessage( - ws: ServerWebSocket, + ws: ServerWebSocket, data: string | Buffer ): void { const handler = new WebSocketHandler() handler.handleWebSocketMessage(ws, data) } + +function isWritable(ws: ServerWebSocket): boolean { + return ws.data?.writable === true +} + +function sendWriteError(ws: ServerWebSocket): void { + const error: WSMessageServerError = { + type: 'error', + error: new CustomError( + 'Writing to PTYs is disabled from this origin. Set PTY_WEB_PASSWORD on the server (and reload) to enable input and session creation.' + ), + } + ws.send(JSON.stringify(error)) +} diff --git a/src/web/server/server.ts b/src/web/server/server.ts index 80ecfde7..c8eebbaa 100644 --- a/src/web/server/server.ts +++ b/src/web/server/server.ts @@ -1,6 +1,6 @@ import type { Server } from 'bun' import { routes } from '../shared/routes.ts' -import { isLoopbackOriginRequest, WebAuth } from './auth.ts' +import { isLoopbackHostRequest, isLoopbackOriginRequest, WebAuth } from './auth.ts' import { CallbackManager } from './callback-manager.ts' import { handleHealth } from './handlers/health.ts' import { @@ -16,7 +16,7 @@ import { } from './handlers/sessions.ts' import { buildStaticRoutes, type StaticAssets } from './handlers/static.ts' import { ErrorResponse } from './handlers/responses.ts' -import { handleWebSocketMessage } from './handlers/websocket.ts' +import { handleWebSocketMessage, type WebSocketConnectionState } from './handlers/websocket.ts' export interface PTYServerOptions { /** @@ -27,7 +27,7 @@ export interface PTYServerOptions { } export class PTYServer implements Disposable { - public readonly server: Server + public readonly server: Server private readonly staticAssets: StaticAssets private readonly stack = new DisposableStack() private readonly auth: WebAuth @@ -50,7 +50,7 @@ export class PTYServer implements Disposable { return new PTYServer(staticAssets, auth) } - private startWebServer(): Server { + private startWebServer(): Server { // Single `fetch` handler — matches opencode-mem's architecture. Using // Bun's typed `routes` map routes static assets around the auth gate // (their 200 response carries no `WWW-Authenticate`), which Safari's @@ -142,28 +142,43 @@ export class PTYServer implements Disposable { if (req.headers.get('upgrade') !== 'websocket') { return new Response('WebSocket endpoint - use WebSocket upgrade', { status: 426 }) } - const success = this.server.upgrade(req) + // Attach a `writable` flag so the message handler can reject + // `input` / `spawn` payloads when the upgrade originated from a + // non-loopback client and auth is disabled. We still upgrade (so the + // client can stream `session_list` + `raw_data` and watch the live + // terminal read-only) — we just refuse to act on write commands. + // + // WS upgrades rarely carry an `Origin` header (most non-browser WS + // clients, including Bun's own `WebSocket`, don't send one), so we + // fall back to inspecting `Host` — which mirrors how the client typed + // the URL and therefore correctly distinguishes "they connected via + // 127.0.0.1" from "they connected via 192.168.x.y". + const writable = this.auth.isEnabled() || isLoopbackHostRequest(req) + const success = this.server.upgrade(req, { + data: { writable }, + }) if (success) return undefined return new Response('WebSocket upgrade failed', { status: 400 }) } - // Destructive operations get an extra origin-based guard when auth is - // disabled, so an unauthenticated LAN visitor can't take down sessions. - const checkKillGuard = (): Response | null => { + // All destructive or write paths (create / kill / cleanup / clear / input) + // get an extra origin-based guard when auth is disabled, so an + // unauthenticated LAN visitor can't spawn processes or type into an + // interactive PTY. The frontend still gets to view output via the read-only + // GET / buffer / WS subscription paths. + const requireWritableOrigin = (): Response | null => { if (this.auth.isEnabled() || isLoopbackOriginRequest(req)) return null return new ErrorResponse( - 'Killing sessions from a non-loopback origin requires HTTP Basic Auth to be configured. ' + - 'Set the PTY_WEB_PASSWORD environment variable (and reload the web UI) to enable kill.', + 'Writing to PTYs from a non-loopback origin requires HTTP Basic Auth to be configured. ' + + 'Set the PTY_WEB_PASSWORD environment variable (and reload the web UI) to enable input, kill, and session creation.', 403 ) } if (path === routes.sessions.path) { if (method === 'GET') return getSessions() - if (method === 'POST') return createSession(req) - if (method === 'DELETE') { - return checkKillGuard() ?? clearSessions() - } + if (method === 'POST') return requireWritableOrigin() ?? createSession(req) + if (method === 'DELETE') return requireWritableOrigin() ?? clearSessions() } // Match `/api/sessions/:id` and the four sub-routes that hang off it. @@ -178,7 +193,7 @@ export class PTYServer implements Disposable { const bunReq = withParams(req, routes.session.path, { id: sessionMatch[1] ?? '' }) if (method === 'GET') return getSession(bunReq) if (method === 'DELETE') { - return checkKillGuard() ?? killSession(bunReq) + return requireWritableOrigin() ?? killSession(bunReq) } } @@ -188,7 +203,7 @@ export class PTYServer implements Disposable { id: cleanupMatch[1] ?? '', }) if (method === 'DELETE') { - return checkKillGuard() ?? cleanupSession(bunReq) + return requireWritableOrigin() ?? cleanupSession(bunReq) } } @@ -197,7 +212,7 @@ export class PTYServer implements Disposable { const bunReq = withParams(req, routes.session.input.path, { id: inputMatch[1] ?? '', }) - if (method === 'POST') return sendInput(bunReq) + if (method === 'POST') return requireWritableOrigin() ?? sendInput(bunReq) } const rawMatch = path.match(rawBufferPattern) diff --git a/test/web-auth.test.ts b/test/web-auth.test.ts index ba42956a..777d754a 100644 --- a/test/web-auth.test.ts +++ b/test/web-auth.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import { + isLoopbackHostRequest, isLoopbackOriginRequest, SESSION_COOKIE_NAME, WebAuth, @@ -199,3 +200,29 @@ describe('isLoopbackOriginRequest', () => { }) } }) + +describe('isLoopbackHostRequest', () => { + // The `Host` header (used for WebSocket upgrade checks since non-browser + // WS clients rarely send `Origin`) is `hostname[:port]`. + const cases: Array<{ host: string | null; expected: boolean }> = [ + { host: null, expected: true }, + { host: 'localhost:60134', expected: true }, + { host: 'localhost', expected: true }, + { host: '127.0.0.1:60134', expected: true }, + { host: '127.0.0.1', expected: true }, + { host: '[::1]:60134', expected: true }, + { host: '[::1]', expected: true }, + { host: '192.168.1.10:60134', expected: false }, + { host: '192.168.1.10', expected: false }, + { host: 'example.com:443', expected: false }, + ] + + for (const { host, expected } of cases) { + it(`returns ${String(expected)} for host=${String(host)}`, () => { + const req = new Request('http://example.com/api/sessions', { + headers: host ? { Host: host } : {}, + }) + expect(isLoopbackHostRequest(req)).toBe(expected) + }) + } +}) diff --git a/test/web-permissions.test.ts b/test/web-permissions.test.ts index 0a425c61..e7d7eef7 100644 --- a/test/web-permissions.test.ts +++ b/test/web-permissions.test.ts @@ -4,7 +4,10 @@ import { manager } from '../src/plugin/pty/manager.ts' import { initPermissions } from '../src/plugin/pty/permissions.ts' import type { PTYSessionInfo } from '../src/plugin/pty/types.ts' import { createSession } from '../src/web/server/handlers/sessions.ts' -import { handleWebSocketMessage } from '../src/web/server/handlers/websocket.ts' +import { + handleWebSocketMessage, + type WebSocketConnectionState, +} from '../src/web/server/handlers/websocket.ts' import type { WSMessageServerError } from '../src/web/shared/types.ts' type TestPermissionConfig = { @@ -43,7 +46,10 @@ function buildSpawnInfo(overrides: Partial = {}): PTYSessionInfo } } -function createFakeWebSocket(sentMessages: string[]): ServerWebSocket { +function createFakeWebSocket( + sentMessages: string[], + writable = true +): ServerWebSocket { return { send: (message: string) => { sentMessages.push(message) @@ -52,7 +58,8 @@ function createFakeWebSocket(sentMessages: string[]): ServerWebSocket subscribe: () => {}, unsubscribe: () => {}, subscriptions: new Set(), - } as unknown as ServerWebSocket + data: { writable }, + } as unknown as ServerWebSocket } describe('web permission enforcement', () => { diff --git a/test/web-server-auth.test.ts b/test/web-server-auth.test.ts index 32ebcff6..f11e25ec 100644 --- a/test/web-server-auth.test.ts +++ b/test/web-server-auth.test.ts @@ -245,5 +245,75 @@ describe('Web Server HTTP Basic Auth', () => { }) expect(res.status).toBe(200) }) + + it('refuses POST /api/sessions from a non-loopback origin (cannot create PTY)', async () => { + const res = await fetch(`${server.server.url}/api/sessions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'http://192.168.31.36:60134', + }, + body: JSON.stringify({ + command: 'bash', + description: 'should-be-blocked', + parentSessionId: 'guard-test', + }), + }) + expect(res.status).toBe(403) + const text = await res.text() + expect(text).toContain('PTY_WEB_PASSWORD') + }) + + it('refuses POST /api/sessions/:id/input from a non-loopback origin (cannot type into PTY)', async () => { + const res = await fetch(`${server.server.url}/api/sessions/anything/input`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'http://192.168.31.36:60134', + }, + body: JSON.stringify({ data: 'cat /etc/passwd\n' }), + }) + expect(res.status).toBe(403) + const text = await res.text() + expect(text).toContain('PTY_WEB_PASSWORD') + }) + + it('still allows POST /api/sessions from a loopback origin', async () => { + const res = await fetch(`${server.server.url}/api/sessions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: 'http://127.0.0.1:60134', + }, + body: JSON.stringify({ + command: 'echo', + args: ['loopback-ok'], + description: 'loopback should succeed', + parentSessionId: 'guard-test', + }), + }) + // 200 with the spawned session JSON. + expect(res.status).toBe(200) + }) + + it('still allows POST /api/sessions when auth is enabled (any origin)', async () => { + // Reuse the auth-enabled `server` from the earlier describe block? No — + // we are inside the auth-disabled describe. Verify the auth-enabled + // server separately; this case is covered by the earlier + // "admits requests that carry correct Basic credentials" tests via the + // GET endpoint. Here we just confirm the loopback-bypass logic itself + // with no Origin header (treated as loopback). + const res = await fetch(`${server.server.url}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: 'echo', + args: ['no-origin-ok'], + description: 'no origin treated as loopback', + parentSessionId: 'guard-test', + }), + }) + expect(res.status).toBe(200) + }) }) }) diff --git a/test/web-ws-guard.test.ts b/test/web-ws-guard.test.ts new file mode 100644 index 00000000..c980246a --- /dev/null +++ b/test/web-ws-guard.test.ts @@ -0,0 +1,127 @@ +import { beforeAll, describe, expect, it } from 'bun:test' +import type { ServerWebSocket } from 'bun' +import { manager } from '../src/plugin/pty/manager.ts' +import { + handleWebSocketMessage, + type WebSocketConnectionState, +} from '../src/web/server/handlers/websocket.ts' +import type { WSMessageServerError } from '../src/web/shared/types.ts' + +function fakeWs(writable: boolean): { + ws: ServerWebSocket + sent: string[] +} { + const sent: string[] = [] + const ws = { + send: (message: string) => { + sent.push(message) + return 0 + }, + subscribe: () => {}, + unsubscribe: () => {}, + subscriptions: new Set(), + data: { writable }, + } as unknown as ServerWebSocket + return { ws, sent } +} + +describe('WebSocket write guard (non-loopback + auth disabled)', () => { + let spawnedSessionId: string + + beforeAll(() => { + // Seed a session the read-only client could subscribe to. This makes + // sure the `input` rejection happens BEFORE we touch the manager. + const session = manager.spawn({ + command: 'bash', + args: ['-c', 'cat'], + description: 'guard-test', + parentSessionId: 'guard-test', + }) + spawnedSessionId = session.id + }) + + it('rejects `input` messages with an error frame and does NOT call manager.write', () => { + const { ws, sent } = fakeWs(false) + let writeCalls = 0 + const originalWrite = manager.write.bind(manager) + manager.write = ((id: string, data: string) => { + writeCalls++ + return originalWrite(id, data) + }) as typeof manager.write + + try { + handleWebSocketMessage( + ws, + JSON.stringify({ type: 'input', sessionId: spawnedSessionId, data: 'echo pwned\n' }) + ) + } finally { + manager.write = originalWrite + } + + expect(writeCalls).toBe(0) + expect(sent).toHaveLength(1) + const err = JSON.parse(sent[0] ?? '{}') as WSMessageServerError + expect(err.type).toBe('error') + expect(err.error.message).toContain('PTY_WEB_PASSWORD') + }) + + it('rejects `spawn` messages with an error frame and does NOT call manager.spawn', () => { + const { ws, sent } = fakeWs(false) + let spawnCalls = 0 + const originalSpawn = manager.spawn.bind(manager) + manager.spawn = ((opts) => { + spawnCalls++ + return originalSpawn(opts) + }) as typeof manager.spawn + + try { + handleWebSocketMessage( + ws, + JSON.stringify({ + type: 'spawn', + command: 'echo', + args: ['pwned'], + description: 'should-not-spawn', + parentSessionId: 'guard-test', + }) + ) + } finally { + manager.spawn = originalSpawn + } + + expect(spawnCalls).toBe(0) + expect(sent).toHaveLength(1) + const err = JSON.parse(sent[0] ?? '{}') as WSMessageServerError + expect(err.type).toBe('error') + }) + + it('still allows `subscribe` / `session_list` / `readRaw` on a read-only socket', () => { + const { ws, sent } = fakeWs(false) + + // `session_list` — should respond normally. + handleWebSocketMessage(ws, JSON.stringify({ type: 'session_list' })) + const first = JSON.parse(sent[0] ?? '{}') as { type: string } + expect(first.type).toBe('session_list') + + // `subscribe` — should respond with `subscribed`, no error. + handleWebSocketMessage(ws, JSON.stringify({ type: 'subscribe', sessionId: spawnedSessionId })) + const second = JSON.parse(sent[1] ?? '{}') as { type: string } + expect(second.type).toBe('subscribed') + + // `readRaw` — should respond with `readRawResponse`. + handleWebSocketMessage(ws, JSON.stringify({ type: 'readRaw', sessionId: spawnedSessionId })) + const third = JSON.parse(sent[2] ?? '{}') as { type: string } + expect(third.type).toBe('readRawResponse') + }) + + it('allows `input` and `spawn` when the connection is writable', () => { + const { ws, sent } = fakeWs(true) + handleWebSocketMessage( + ws, + JSON.stringify({ type: 'input', sessionId: spawnedSessionId, data: 'hello\n' }) + ) + // No error frame — just no-op (the seeded session hasn't received anything yet) + const messages = sent.map((s) => JSON.parse(s) as { type: string }) + expect(messages.every((m) => m.type !== 'error')).toBe(true) + }) +})