From c7592acdc47eb85b8e4a061ce95aef3c08ffff5b Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:48:48 -0800 Subject: [PATCH 1/4] fix: pass auth token via query param for terminal WebSocket connections Browsers can't send custom Authorization headers on WebSocket upgrade requests, causing terminal connections from web/mobile UIs to fail auth. --- mobile/src/lib/api.ts | 6 +- src/agent/auth.ts | 9 +++ src/client/api.ts | 6 +- src/client/ws-shell.ts | 8 ++- src/index.ts | 3 +- test/helpers/agent.ts | 11 +++- test/integration/auth.test.ts | 20 ++++++ test/integration/terminal-auth.test.ts | 89 ++++++++++++++++++++++++++ web/src/lib/api.ts | 7 +- 9 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 test/integration/terminal-auth.test.ts diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index 413b7651..dd7f2a57 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -320,7 +320,11 @@ export interface SessionInfoWithWorkspace extends SessionInfo { export function getTerminalUrl(workspaceName: string): string { const wsUrl = baseUrl.replace(/^http/, 'ws'); - return `${wsUrl}/rpc/terminal/${encodeURIComponent(workspaceName)}`; + const url = new URL(`${wsUrl}/rpc/terminal/${encodeURIComponent(workspaceName)}`); + if (currentToken) { + url.searchParams.set('token', currentToken); + } + return url.toString(); } export function getTerminalHtml(): string { diff --git a/src/agent/auth.ts b/src/agent/auth.ts index 855817ec..1814a7cf 100644 --- a/src/agent/auth.ts +++ b/src/agent/auth.ts @@ -39,6 +39,15 @@ export function checkAuth(req: Request, config: AgentConfig): AuthResult { return { ok: true, identity: { type: 'tailscale', user: tsIdentity.email } }; } + const isWebSocketUpgrade = req.headers.get('Upgrade')?.toLowerCase() === 'websocket'; + const isTerminalWebSocket = url.pathname.startsWith('/rpc/terminal/'); + if (isWebSocketUpgrade && isTerminalWebSocket) { + const token = url.searchParams.get('token'); + if (token && secureCompare(token, config.auth.token)) { + return { ok: true, identity: { type: 'token' } }; + } + } + const authHeader = req.headers.get('Authorization'); if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); diff --git a/src/client/api.ts b/src/client/api.ts index 786b73cf..742c8dbf 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -184,7 +184,11 @@ export class ApiClient { getTerminalUrl(name: string): string { const wsUrl = this.baseUrl.replace(/^http/, 'ws'); - return `${wsUrl}/rpc/terminal/${encodeURIComponent(name)}`; + const url = new URL(`${wsUrl}/rpc/terminal/${encodeURIComponent(name)}`); + if (this.token) { + url.searchParams.set('token', this.token); + } + return url.toString(); } getOpencodeUrl(name: string): string { diff --git a/src/client/ws-shell.ts b/src/client/ws-shell.ts index 6313b53b..f09c7322 100644 --- a/src/client/ws-shell.ts +++ b/src/client/ws-shell.ts @@ -206,7 +206,7 @@ export async function openWSShell(options: WSShellOptions): Promise { }); } -export function getTerminalWSUrl(worker: string, workspaceName: string): string { +export function getTerminalWSUrl(worker: string, workspaceName: string, token?: string): string { let base = worker; if (!base.startsWith('http://') && !base.startsWith('https://')) { base = `http://${base}`; @@ -216,5 +216,9 @@ export function getTerminalWSUrl(worker: string, workspaceName: string): string if (!host.includes(':')) { host = `${host}:${DEFAULT_AGENT_PORT}`; } - return `${wsProtocol}${host}/rpc/terminal/${encodeURIComponent(workspaceName)}`; + const url = new URL(`${wsProtocol}${host}/rpc/terminal/${encodeURIComponent(workspaceName)}`); + if (token) { + url.searchParams.set('token', token); + } + return url.toString(); } diff --git a/src/index.ts b/src/index.ts index fc29c504..c049933e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -466,6 +466,7 @@ program try { const agentHost = await getAgentWithFallback(); const client = await createClient(); + const token = await getToken(); const workspace = await client.getWorkspace(name); if (workspace.status !== 'running') { @@ -492,7 +493,7 @@ program }, }); } else { - const wsUrl = getTerminalWSUrl(agentHost, name); + const wsUrl = getTerminalWSUrl(agentHost, name, token || undefined); await openWSShell({ url: wsUrl, onError: (err) => { diff --git a/test/helpers/agent.ts b/test/helpers/agent.ts index 90e7fe94..d471c8e6 100644 --- a/test/helpers/agent.ts +++ b/test/helpers/agent.ts @@ -116,10 +116,17 @@ export async function waitForHealthy(baseUrl: string, timeout = 10000): Promise< return false; } -export function createApiClient(baseUrl: string): ApiClient { +export function createApiClient(baseUrl: string, token?: string): ApiClient { type Client = RouterClient; const link = new RPCLink({ url: `${baseUrl}/rpc`, + fetch: (url, init) => { + const headers = new Headers((init as RequestInit)?.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + return fetch(url, { ...(init as RequestInit), headers }); + }, }); const client = createORPCClient(link); @@ -363,7 +370,7 @@ export async function startTestAgent(options: TestAgentOptions = {}): Promise { expect(result.code).toBe(404); }); + + it('accepts WebSocket upgrade with token in query string (browser-compatible)', async () => { + const wsUrl = `${agent.baseUrl.replace('http', 'ws')}/rpc/terminal/nonexistent-workspace?token=${TEST_TOKEN}`; + + const result = await new Promise<{ error: Error | null; code?: number }>((resolve) => { + const ws = new WebSocket(wsUrl); + ws.on('error', (err) => { + resolve({ error: err }); + }); + ws.on('unexpected-response', (_, res) => { + resolve({ error: null, code: res.statusCode }); + }); + ws.on('open', () => { + ws.close(); + resolve({ error: null, code: 200 }); + }); + }); + + expect(result.code).toBe(404); + }); }); }); diff --git a/test/integration/terminal-auth.test.ts b/test/integration/terminal-auth.test.ts new file mode 100644 index 00000000..3268f486 --- /dev/null +++ b/test/integration/terminal-auth.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import WebSocket from 'ws'; +import { startTestAgent, type TestAgent } from '../helpers/agent'; + +const TEST_TOKEN = 'test-auth-token-12345'; + +function waitForOpen(ws: WebSocket, timeout = 5000): Promise { + return new Promise((resolve, reject) => { + if (ws.readyState === WebSocket.OPEN) { + resolve(); + return; + } + + const timer = setTimeout(() => reject(new Error('Timeout waiting for connection')), timeout); + ws.once('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function collectMessages(ws: WebSocket, durationMs: number): Promise { + return new Promise((resolve) => { + let output = ''; + const handler = (data: Buffer | string) => { + output += data.toString(); + }; + ws.on('message', handler); + setTimeout(() => { + ws.off('message', handler); + resolve(output); + }, durationMs); + }); +} + +describe('Terminal WebSocket - Query Token Auth', () => { + let agent: TestAgent; + let workspaceName: string; + let workspaceCreated = false; + + beforeAll(async () => { + agent = await startTestAgent({ + config: { + auth: { token: TEST_TOKEN }, + }, + }); + workspaceName = agent.generateWorkspaceName(); + const result = await agent.api.createWorkspace({ name: workspaceName }); + if (result.status === 201) { + workspaceCreated = true; + } + }, 120000); + + afterAll(async () => { + if (workspaceCreated) { + try { + await agent.api.deleteWorkspace(workspaceName); + } catch { + // ignore + } + } + await agent.cleanup(); + }); + + it('authenticates WebSocket via token query param and can execute a command', async () => { + if (!workspaceCreated) { + return; + } + + const wsUrl = `ws://127.0.0.1:${agent.port}/rpc/terminal/${workspaceName}?token=${TEST_TOKEN}`; + const ws = new WebSocket(wsUrl); + + await waitForOpen(ws, 15000); + ws.send(JSON.stringify({ type: 'resize', cols: 80, rows: 24 })); + await new Promise((r) => setTimeout(r, 300)); + + const outputPromise = collectMessages(ws, 2500); + ws.send('echo "QUERY_TOKEN_AUTH_OK"\n'); + + const output = await outputPromise; + expect(output).toContain('QUERY_TOKEN_AUTH_OK'); + + ws.close(); + }, 30000); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d397e38b..60058db4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -304,5 +304,10 @@ export const api = { export function getTerminalUrl(name: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - return `${protocol}//${window.location.host}/rpc/terminal/${encodeURIComponent(name)}`; + const url = new URL(`${protocol}//${window.location.host}/rpc/terminal/${encodeURIComponent(name)}`); + const token = getStoredToken(); + if (token) { + url.searchParams.set('token', token); + } + return url.toString(); } From 1bfd1e00efed11b1162366846304f8e44c515d64 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:31:34 +0000 Subject: [PATCH 2/4] simplify terminal-auth test: fail fast on setup, rely on cleanup() --- test/integration/terminal-auth.test.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/test/integration/terminal-auth.test.ts b/test/integration/terminal-auth.test.ts index 3268f486..1c7a0fb5 100644 --- a/test/integration/terminal-auth.test.ts +++ b/test/integration/terminal-auth.test.ts @@ -40,8 +40,6 @@ function collectMessages(ws: WebSocket, durationMs: number): Promise { describe('Terminal WebSocket - Query Token Auth', () => { let agent: TestAgent; let workspaceName: string; - let workspaceCreated = false; - beforeAll(async () => { agent = await startTestAgent({ config: { @@ -50,27 +48,14 @@ describe('Terminal WebSocket - Query Token Auth', () => { }); workspaceName = agent.generateWorkspaceName(); const result = await agent.api.createWorkspace({ name: workspaceName }); - if (result.status === 201) { - workspaceCreated = true; - } + expect(result.status).toBe(201); }, 120000); afterAll(async () => { - if (workspaceCreated) { - try { - await agent.api.deleteWorkspace(workspaceName); - } catch { - // ignore - } - } await agent.cleanup(); }); it('authenticates WebSocket via token query param and can execute a command', async () => { - if (!workspaceCreated) { - return; - } - const wsUrl = `ws://127.0.0.1:${agent.port}/rpc/terminal/${workspaceName}?token=${TEST_TOKEN}`; const ws = new WebSocket(wsUrl); From fab289c09b95be09bd6c76201e72f2fc7d7339ae Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:50:29 +0000 Subject: [PATCH 3/4] fix: send terminal auth token via first WebSocket message instead of URL query param Tokens in URLs leak via server logs, proxy logs, browser history, and Referer headers. Move to first-message auth: the server upgrades the WebSocket unconditionally, then expects { type: 'auth', token } as the first message before allowing any terminal interaction. Connections that fail auth are closed with code 4001. Bearer header and Tailscale auth still work at the HTTP level for upgrade requests that already carry credentials. --- mobile/scripts/bundle-terminal.ts | 5 ++- mobile/src/lib/api.ts | 6 +-- mobile/src/screens/TerminalScreen.tsx | 6 ++- src/agent/auth.ts | 11 +----- src/agent/run.ts | 20 ++++++---- src/client/api.ts | 10 ++--- src/client/ws-shell.ts | 15 ++++---- src/index.ts | 3 +- src/terminal/bun-handler.ts | 31 ++++++++++++++- src/terminal/types.ts | 15 ++++++++ test/integration/auth.test.ts | 52 +++----------------------- test/integration/terminal-auth.test.ts | 25 ++++++++++--- web/src/components/Terminal.tsx | 6 ++- web/src/lib/api.ts | 7 +--- 14 files changed, 112 insertions(+), 100 deletions(-) diff --git a/mobile/scripts/bundle-terminal.ts b/mobile/scripts/bundle-terminal.ts index faff7d69..e6b97518 100644 --- a/mobile/scripts/bundle-terminal.ts +++ b/mobile/scripts/bundle-terminal.ts @@ -45,7 +45,7 @@ ${umdContent} let ws = null; let fitAddon = null; - async function connect(wsUrl, initialCommand) { + async function connect(wsUrl, initialCommand, token) { const ghostty = await Ghostty.load(); term = new Terminal({ @@ -100,6 +100,9 @@ ${umdContent} ws.onopen = () => { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'connected' })); + if (token) { + ws.send(JSON.stringify({ type: 'auth', token: token })); + } const dims = term.getDimensions ? term.getDimensions() : { cols: term.cols, rows: term.rows }; ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows })); if (initialCommand) { diff --git a/mobile/src/lib/api.ts b/mobile/src/lib/api.ts index dd7f2a57..413b7651 100644 --- a/mobile/src/lib/api.ts +++ b/mobile/src/lib/api.ts @@ -320,11 +320,7 @@ export interface SessionInfoWithWorkspace extends SessionInfo { export function getTerminalUrl(workspaceName: string): string { const wsUrl = baseUrl.replace(/^http/, 'ws'); - const url = new URL(`${wsUrl}/rpc/terminal/${encodeURIComponent(workspaceName)}`); - if (currentToken) { - url.searchParams.set('token', currentToken); - } - return url.toString(); + return `${wsUrl}/rpc/terminal/${encodeURIComponent(workspaceName)}`; } export function getTerminalHtml(): string { diff --git a/mobile/src/screens/TerminalScreen.tsx b/mobile/src/screens/TerminalScreen.tsx index c8ad2621..23559d69 100644 --- a/mobile/src/screens/TerminalScreen.tsx +++ b/mobile/src/screens/TerminalScreen.tsx @@ -12,7 +12,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { WebView } from 'react-native-webview' import { useQuery } from '@tanstack/react-query' -import { api, getTerminalHtml, getTerminalUrl, HOST_WORKSPACE_NAME } from '../lib/api' +import { api, getTerminalHtml, getTerminalUrl, getToken, HOST_WORKSPACE_NAME } from '../lib/api' import { ExtraKeysBar } from '../components/ExtraKeysBar' import { useTheme } from '../contexts/ThemeContext' @@ -103,11 +103,13 @@ export function TerminalScreen({ route, navigation }: any) { } const wsUrl = getTerminalUrl(name) + const token = getToken() const escapedCommand = initialCommand ? initialCommand.replace(/\\/g, '\\\\').replace(/'/g, "\\'") : '' + const escapedToken = token ? token.replace(/\\/g, '\\\\').replace(/'/g, "\\'") : '' const injectedJS = ` if (window.initTerminal) { - window.initTerminal('${wsUrl}', '${escapedCommand}'); + window.initTerminal('${wsUrl}', '${escapedCommand}', '${escapedToken}'); } true; ` diff --git a/src/agent/auth.ts b/src/agent/auth.ts index 1814a7cf..a0fec0b9 100644 --- a/src/agent/auth.ts +++ b/src/agent/auth.ts @@ -2,7 +2,7 @@ import { timingSafeEqual } from 'crypto'; import type { AgentConfig } from '../shared/types'; import { getTailscaleIdentity } from '../tailscale'; -function secureCompare(a: string, b: string): boolean { +export function secureCompare(a: string, b: string): boolean { if (a.length !== b.length) return false; return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } @@ -39,15 +39,6 @@ export function checkAuth(req: Request, config: AgentConfig): AuthResult { return { ok: true, identity: { type: 'tailscale', user: tsIdentity.email } }; } - const isWebSocketUpgrade = req.headers.get('Upgrade')?.toLowerCase() === 'websocket'; - const isTerminalWebSocket = url.pathname.startsWith('/rpc/terminal/'); - if (isWebSocketUpgrade && isTerminalWebSocket) { - const token = url.searchParams.get('token'); - if (token && secureCompare(token, config.auth.token)) { - return { ok: true, identity: { type: 'token' } }; - } - } - const authHeader = req.headers.get('Authorization'); if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice(7); diff --git a/src/agent/run.ts b/src/agent/run.ts index 6a215e45..f287278a 100644 --- a/src/agent/run.ts +++ b/src/agent/run.ts @@ -39,6 +39,7 @@ interface TailscaleInfo { interface WebSocketData { type: 'terminal'; workspaceName: string; + authenticated: boolean; } function createAgentServer( @@ -89,6 +90,7 @@ function createAgentServer( isWorkspaceRunning, isHostAccessAllowed: () => currentConfig.allowHostAccess === true, getPreferredShell, + getAuthToken: () => currentConfig.auth?.token, }); const triggerAutoSync = () => { @@ -143,24 +145,21 @@ function createAgentServer( return staticResponse; } - const authResult = checkAuth(req, currentConfig); - if (!authResult.ok) { - return unauthorizedResponse(); - } - const terminalMatch = pathname.match(/^\/rpc\/terminal\/([^/]+)$/); if (terminalMatch) { const type: WebSocketData['type'] = 'terminal'; const workspaceName = decodeURIComponent(terminalMatch[1]); + const authResult = checkAuth(req, currentConfig); + const running = await isWorkspaceRunning(workspaceName); if (!running) { return new Response('Not Found', { status: 404 }); } const upgraded = server.upgrade(req, { - data: { type, workspaceName }, + data: { type, workspaceName, authenticated: authResult.ok }, }); if (upgraded) { @@ -169,6 +168,11 @@ function createAgentServer( return new Response('WebSocket upgrade failed', { status: 400 }); } + const authResult = checkAuth(req, currentConfig); + if (!authResult.ok) { + return unauthorizedResponse(); + } + if (pathname === '/health' && method === 'GET') { const identity = getTailscaleIdentity(req); const response: Record = { status: 'ok', version: pkg.version }; @@ -198,9 +202,9 @@ function createAgentServer( websocket: { open(ws: ServerWebSocket) { - const { type, workspaceName } = ws.data; + const { type, workspaceName, authenticated } = ws.data; if (type === 'terminal') { - terminalHandler.handleOpen(ws, workspaceName); + terminalHandler.handleOpen(ws, workspaceName, authenticated); } }, diff --git a/src/client/api.ts b/src/client/api.ts index 742c8dbf..5be5a209 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -184,11 +184,11 @@ export class ApiClient { getTerminalUrl(name: string): string { const wsUrl = this.baseUrl.replace(/^http/, 'ws'); - const url = new URL(`${wsUrl}/rpc/terminal/${encodeURIComponent(name)}`); - if (this.token) { - url.searchParams.set('token', this.token); - } - return url.toString(); + return `${wsUrl}/rpc/terminal/${encodeURIComponent(name)}`; + } + + getAuthToken(): string | undefined { + return this.token; } getOpencodeUrl(name: string): string { diff --git a/src/client/ws-shell.ts b/src/client/ws-shell.ts index f09c7322..1b119e1c 100644 --- a/src/client/ws-shell.ts +++ b/src/client/ws-shell.ts @@ -5,6 +5,7 @@ import { DEFAULT_AGENT_PORT } from '../shared/constants'; export interface WSShellOptions { url: string; + token?: string; onConnect?: () => void; onDisconnect?: (code: number) => void; onError?: (error: Error) => void; @@ -121,7 +122,7 @@ export async function openTailscaleSSH(options: TailscaleSSHOptions): Promise { - const { url, onConnect, onDisconnect, onError } = options; + const { url, token, onConnect, onDisconnect, onError } = options; return new Promise((resolve, reject) => { const ws = new WebSocket(url); @@ -155,6 +156,10 @@ export async function openWSShell(options: WSShellOptions): Promise { } stdin.resume(); + if (token) { + safeSend(JSON.stringify({ type: 'auth', token })); + } + sendResize(); if (onConnect) { @@ -206,7 +211,7 @@ export async function openWSShell(options: WSShellOptions): Promise { }); } -export function getTerminalWSUrl(worker: string, workspaceName: string, token?: string): string { +export function getTerminalWSUrl(worker: string, workspaceName: string): string { let base = worker; if (!base.startsWith('http://') && !base.startsWith('https://')) { base = `http://${base}`; @@ -216,9 +221,5 @@ export function getTerminalWSUrl(worker: string, workspaceName: string, token?: if (!host.includes(':')) { host = `${host}:${DEFAULT_AGENT_PORT}`; } - const url = new URL(`${wsProtocol}${host}/rpc/terminal/${encodeURIComponent(workspaceName)}`); - if (token) { - url.searchParams.set('token', token); - } - return url.toString(); + return `${wsProtocol}${host}/rpc/terminal/${encodeURIComponent(workspaceName)}`; } diff --git a/src/index.ts b/src/index.ts index c049933e..454d509d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -493,9 +493,10 @@ program }, }); } else { - const wsUrl = getTerminalWSUrl(agentHost, name, token || undefined); + const wsUrl = getTerminalWSUrl(agentHost, name); await openWSShell({ url: wsUrl, + token: token || undefined, onError: (err) => { console.error(`\nConnection error: ${err.message}`); }, diff --git a/src/terminal/bun-handler.ts b/src/terminal/bun-handler.ts index 9eaa26e3..bec08a97 100644 --- a/src/terminal/bun-handler.ts +++ b/src/terminal/bun-handler.ts @@ -1,7 +1,8 @@ import type { ServerWebSocket } from 'bun'; import { createTerminalSession, TerminalSession } from './handler'; import { createHostTerminalSession, HostTerminalSession } from './host-handler'; -import { isControlMessage } from './types'; +import { isControlMessage, isAuthMessage } from './types'; +import { secureCompare } from '../agent/auth'; import { HOST_WORKSPACE_NAME } from '../shared/client-types'; type AnyTerminalSession = TerminalSession | HostTerminalSession; @@ -11,6 +12,7 @@ interface TerminalConnection { session: AnyTerminalSession | null; workspaceName: string; started: boolean; + authenticated: boolean; } export interface TerminalHandlerOptions { @@ -18,6 +20,7 @@ export interface TerminalHandlerOptions { isWorkspaceRunning: (workspaceName: string) => Promise; isHostAccessAllowed?: () => boolean; getPreferredShell?: () => string | undefined; + getAuthToken?: () => string | undefined; } export class TerminalHandler { @@ -25,14 +28,16 @@ export class TerminalHandler { private getContainerName: (workspaceName: string) => string; private isHostAccessAllowed: () => boolean; private getPreferredShell: () => string | undefined; + private getAuthToken: () => string | undefined; constructor(options: TerminalHandlerOptions) { this.getContainerName = options.getContainerName; this.isHostAccessAllowed = options.isHostAccessAllowed || (() => false); this.getPreferredShell = options.getPreferredShell || (() => undefined); + this.getAuthToken = options.getAuthToken || (() => undefined); } - handleOpen(ws: ServerWebSocket, workspaceName: string): void { + handleOpen(ws: ServerWebSocket, workspaceName: string, authenticated = true): void { const isHostMode = workspaceName === HOST_WORKSPACE_NAME; if (isHostMode && !this.isHostAccessAllowed()) { @@ -40,11 +45,15 @@ export class TerminalHandler { return; } + const authToken = this.getAuthToken(); + const isAuthenticated = authenticated || !authToken; + const connection: TerminalConnection = { ws, session: null, workspaceName, started: false, + authenticated: isAuthenticated, }; this.connections.set(ws, connection); } @@ -53,6 +62,24 @@ export class TerminalHandler { const connection = this.connections.get(ws); if (!connection) return; + if (!connection.authenticated) { + try { + const message = JSON.parse(data); + if (isAuthMessage(message)) { + const authToken = this.getAuthToken(); + if (authToken && secureCompare(message.token, authToken)) { + connection.authenticated = true; + return; + } + } + } catch { + // Not valid JSON + } + ws.close(4001, 'Authentication failed'); + this.connections.delete(ws); + return; + } + if (data.startsWith('{')) { try { const message = JSON.parse(data); diff --git a/src/terminal/types.ts b/src/terminal/types.ts index debb2ed9..98de12fb 100644 --- a/src/terminal/types.ts +++ b/src/terminal/types.ts @@ -26,3 +26,18 @@ export function isControlMessage(data: unknown): data is ControlMessage { typeof (data as ControlMessage).rows === 'number' ); } + +export interface AuthMessage { + type: 'auth'; + token: string; +} + +export function isAuthMessage(data: unknown): data is AuthMessage { + return ( + typeof data === 'object' && + data !== null && + 'type' in data && + (data as AuthMessage).type === 'auth' && + typeof (data as AuthMessage).token === 'string' + ); +} diff --git a/test/integration/auth.test.ts b/test/integration/auth.test.ts index 807eb92b..dcb28ace 100644 --- a/test/integration/auth.test.ts +++ b/test/integration/auth.test.ts @@ -80,8 +80,8 @@ describe('Auth Middleware Integration', () => { }); describe('WebSocket Endpoints', () => { - it('rejects WebSocket upgrade without auth', async () => { - const wsUrl = `${agent.baseUrl.replace('http', 'ws')}/rpc/terminal/test-workspace`; + it('returns 404 for non-existent workspace regardless of auth', async () => { + const wsUrl = `${agent.baseUrl.replace('http', 'ws')}/rpc/terminal/nonexistent-workspace`; const result = await new Promise<{ error: Error | null; code?: number }>((resolve) => { const ws = new WebSocket(wsUrl); @@ -97,32 +97,10 @@ describe('Auth Middleware Integration', () => { }); }); - expect(result.code).toBe(401); - }); - - it('rejects WebSocket upgrade with wrong token', async () => { - const wsUrl = `${agent.baseUrl.replace('http', 'ws')}/rpc/terminal/test-workspace`; - - const result = await new Promise<{ error: Error | null; code?: number }>((resolve) => { - const ws = new WebSocket(wsUrl, { - headers: { Authorization: 'Bearer wrong-token' }, - }); - ws.on('error', (err) => { - resolve({ error: err }); - }); - ws.on('unexpected-response', (_, res) => { - resolve({ error: null, code: res.statusCode }); - }); - ws.on('open', () => { - ws.close(); - resolve({ error: new Error('WebSocket should not have opened') }); - }); - }); - - expect(result.code).toBe(401); + expect(result.code).toBe(404); }); - it('accepts WebSocket upgrade with valid token (returns 404 for non-existent workspace)', async () => { + it('returns 404 for non-existent workspace with valid Bearer token', async () => { const wsUrl = `${agent.baseUrl.replace('http', 'ws')}/rpc/terminal/nonexistent-workspace`; const result = await new Promise<{ error: Error | null; code?: number }>((resolve) => { @@ -137,27 +115,7 @@ describe('Auth Middleware Integration', () => { }); ws.on('open', () => { ws.close(); - resolve({ error: null, code: 200 }); - }); - }); - - expect(result.code).toBe(404); - }); - - it('accepts WebSocket upgrade with token in query string (browser-compatible)', async () => { - const wsUrl = `${agent.baseUrl.replace('http', 'ws')}/rpc/terminal/nonexistent-workspace?token=${TEST_TOKEN}`; - - const result = await new Promise<{ error: Error | null; code?: number }>((resolve) => { - const ws = new WebSocket(wsUrl); - ws.on('error', (err) => { - resolve({ error: err }); - }); - ws.on('unexpected-response', (_, res) => { - resolve({ error: null, code: res.statusCode }); - }); - ws.on('open', () => { - ws.close(); - resolve({ error: null, code: 200 }); + resolve({ error: new Error('WebSocket should not have opened') }); }); }); diff --git a/test/integration/terminal-auth.test.ts b/test/integration/terminal-auth.test.ts index 1c7a0fb5..49f73efe 100644 --- a/test/integration/terminal-auth.test.ts +++ b/test/integration/terminal-auth.test.ts @@ -37,7 +37,7 @@ function collectMessages(ws: WebSocket, durationMs: number): Promise { }); } -describe('Terminal WebSocket - Query Token Auth', () => { +describe('Terminal WebSocket - First Message Auth', () => { let agent: TestAgent; let workspaceName: string; beforeAll(async () => { @@ -55,20 +55,35 @@ describe('Terminal WebSocket - Query Token Auth', () => { await agent.cleanup(); }); - it('authenticates WebSocket via token query param and can execute a command', async () => { - const wsUrl = `ws://127.0.0.1:${agent.port}/rpc/terminal/${workspaceName}?token=${TEST_TOKEN}`; + it('authenticates WebSocket via first auth message and can execute a command', async () => { + const wsUrl = `ws://127.0.0.1:${agent.port}/rpc/terminal/${workspaceName}`; const ws = new WebSocket(wsUrl); await waitForOpen(ws, 15000); + ws.send(JSON.stringify({ type: 'auth', token: TEST_TOKEN })); ws.send(JSON.stringify({ type: 'resize', cols: 80, rows: 24 })); await new Promise((r) => setTimeout(r, 300)); const outputPromise = collectMessages(ws, 2500); - ws.send('echo "QUERY_TOKEN_AUTH_OK"\n'); + ws.send('echo "FIRST_MSG_AUTH_OK"\n'); const output = await outputPromise; - expect(output).toContain('QUERY_TOKEN_AUTH_OK'); + expect(output).toContain('FIRST_MSG_AUTH_OK'); ws.close(); }, 30000); + + it('closes connection with 4001 when sending resize without auth', async () => { + const wsUrl = `ws://127.0.0.1:${agent.port}/rpc/terminal/${workspaceName}`; + const ws = new WebSocket(wsUrl); + + await waitForOpen(ws, 15000); + ws.send(JSON.stringify({ type: 'resize', cols: 80, rows: 24 })); + + const closeCode = await new Promise((resolve) => { + ws.on('close', (code) => resolve(code)); + }); + + expect(closeCode).toBe(4001); + }, 15000); }); diff --git a/web/src/components/Terminal.tsx b/web/src/components/Terminal.tsx index 5c26972a..0888bf94 100644 --- a/web/src/components/Terminal.tsx +++ b/web/src/components/Terminal.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web' -import { getTerminalUrl } from '@/lib/api' +import { getTerminalUrl, getToken } from '@/lib/api' interface TerminalProps { workspaceName: string @@ -137,6 +137,10 @@ function TerminalInstance({ workspaceName, initialCommand, runId }: TerminalProp ws.onopen = () => { if (cancelled.current) return setIsConnected(true) + const token = getToken() + if (token) { + ws.send(JSON.stringify({ type: 'auth', token })) + } const { cols, rows } = cached.terminal ws.send(JSON.stringify({ type: 'resize', cols, rows })) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 60058db4..d397e38b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -304,10 +304,5 @@ export const api = { export function getTerminalUrl(name: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = new URL(`${protocol}//${window.location.host}/rpc/terminal/${encodeURIComponent(name)}`); - const token = getStoredToken(); - if (token) { - url.searchParams.set('token', token); - } - return url.toString(); + return `${protocol}//${window.location.host}/rpc/terminal/${encodeURIComponent(name)}`; } From 0af6d2cecefea55221ae9a818d748ace85aec034 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:57:42 +0000 Subject: [PATCH 4/4] refactor: minor simplifications from code review --- src/terminal/bun-handler.ts | 2 +- src/terminal/types.ts | 6 +++--- test/helpers/agent.ts | 5 +++-- test/integration/terminal-auth.test.ts | 5 ----- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/terminal/bun-handler.ts b/src/terminal/bun-handler.ts index bec08a97..3fd30351 100644 --- a/src/terminal/bun-handler.ts +++ b/src/terminal/bun-handler.ts @@ -73,7 +73,7 @@ export class TerminalHandler { } } } catch { - // Not valid JSON + // invalid JSON, reject } ws.close(4001, 'Authentication failed'); this.connections.delete(ws); diff --git a/src/terminal/types.ts b/src/terminal/types.ts index 98de12fb..6ad13f97 100644 --- a/src/terminal/types.ts +++ b/src/terminal/types.ts @@ -33,11 +33,11 @@ export interface AuthMessage { } export function isAuthMessage(data: unknown): data is AuthMessage { + const msg = data as AuthMessage; return ( typeof data === 'object' && data !== null && - 'type' in data && - (data as AuthMessage).type === 'auth' && - typeof (data as AuthMessage).token === 'string' + msg.type === 'auth' && + typeof msg.token === 'string' ); } diff --git a/test/helpers/agent.ts b/test/helpers/agent.ts index d471c8e6..e2352c90 100644 --- a/test/helpers/agent.ts +++ b/test/helpers/agent.ts @@ -121,11 +121,12 @@ export function createApiClient(baseUrl: string, token?: string): ApiClient { const link = new RPCLink({ url: `${baseUrl}/rpc`, fetch: (url, init) => { - const headers = new Headers((init as RequestInit)?.headers); + const reqInit = init as RequestInit; + const headers = new Headers(reqInit?.headers); if (token) { headers.set('Authorization', `Bearer ${token}`); } - return fetch(url, { ...(init as RequestInit), headers }); + return fetch(url, { ...reqInit, headers }); }, }); const client = createORPCClient(link); diff --git a/test/integration/terminal-auth.test.ts b/test/integration/terminal-auth.test.ts index 49f73efe..2b7ea2df 100644 --- a/test/integration/terminal-auth.test.ts +++ b/test/integration/terminal-auth.test.ts @@ -6,11 +6,6 @@ const TEST_TOKEN = 'test-auth-token-12345'; function waitForOpen(ws: WebSocket, timeout = 5000): Promise { return new Promise((resolve, reject) => { - if (ws.readyState === WebSocket.OPEN) { - resolve(); - return; - } - const timer = setTimeout(() => reject(new Error('Timeout waiting for connection')), timeout); ws.once('open', () => { clearTimeout(timer);