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/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) @@ -16,6 +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, @@ -89,6 +160,8 @@ export function App() { subscribeWithRetry, sendInput, wsConnected, + killBlocked, + inputBlocked, onRawOutputUpdate: useCallback((rawOutput: string) => { setRawOutput(rawOutput) }, []), @@ -96,6 +169,24 @@ export function App() { return (

+ {showAuthWarning && ( + + + + Auth disabled. Set PTY_WEB_PASSWORD to secure kill/cleanup. + + + + )}
{activeSession.description ?? activeSession.title}
-
@@ -118,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 9411f9e7..ab338709 100644 --- a/src/web/client/hooks/use-session-manager.ts +++ b/src/web/client/hooks/use-session-manager.ts @@ -9,6 +9,17 @@ interface UseSessionManagerOptions { subscribeWithRetry: (sessionId: string) => void sendInput?: (sessionId: string, data: string) => void wsConnected?: boolean + /** + * 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 } @@ -18,6 +29,8 @@ export function useSessionManager({ subscribeWithRetry, sendInput, wsConnected, + killBlocked = false, + inputBlocked = false, onRawOutputUpdate, }: UseSessionManagerOptions) { const handleSessionClick = useCallback( @@ -57,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 { @@ -73,7 +92,7 @@ export function useSessionManager({ // eslint-disable-next-line no-empty } catch {} }, - [activeSession, wsConnected, sendInput] + [activeSession, wsConnected, sendInput, inputBlocked] ) const handleKillSession = useCallback(async () => { @@ -81,6 +100,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 +120,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..b1b3c514 100644 --- a/src/web/client/index.html +++ b/src/web/client/index.html @@ -194,6 +194,81 @@ .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; + } + .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; + 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..2df89f76 --- /dev/null +++ b/src/web/server/auth.ts @@ -0,0 +1,247 @@ +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 + } +} + +/** + * 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 + /** + * 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 +} 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 5646d370..362c3714 100644 --- a/src/web/server/handlers/health.ts +++ b/src/web/server/handlers/health.ts @@ -1,8 +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) { +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 +33,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/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 c8e94ed7..c8eebbaa 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 { isLoopbackHostRequest, 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 { handleWebSocketMessage } from './handlers/websocket.ts' +import { buildStaticRoutes, type StaticAssets } from './handlers/static.ts' +import { ErrorResponse } from './handlers/responses.ts' +import { handleWebSocketMessage, type WebSocketConnectionState } 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 + public readonly server: Server + 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 { + 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,188 @@ 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 }) + } + // 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 }) + } + + // 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( + '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 requireWritableOrigin() ?? createSession(req) + if (method === 'DELETE') return requireWritableOrigin() ?? 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 requireWritableOrigin() ?? killSession(bunReq) + } + } + + const cleanupMatch = path.match(cleanupPattern) + if (cleanupMatch) { + const bunReq = withParams(req, routes.session.cleanup.path, { + id: cleanupMatch[1] ?? '', + }) + if (method === 'DELETE') { + return requireWritableOrigin() ?? cleanupSession(bunReq) + } + } + + const inputMatch = path.match(inputPattern) + if (inputMatch) { + const bunReq = withParams(req, routes.session.input.path, { + id: inputMatch[1] ?? '', + }) + if (method === 'POST') return requireWritableOrigin() ?? 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..777d754a --- /dev/null +++ b/test/web-auth.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'bun:test' +import { + isLoopbackHostRequest, + 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) + }) + } +}) + +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 new file mode 100644 index 00000000..f11e25ec --- /dev/null +++ b/test/web-server-auth.test.ts @@ -0,0 +1,319 @@ +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) + }) + + 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-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('

') 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) + }) +})