Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion mobile/scripts/bundle-terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -100,6 +100,9 @@ ${umdContent}

ws.onopen = () => {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'connected' }));
if (token) {
ws.send(JSON.stringify({ type: 'auth', token: token }));
}
Comment on lines +103 to +105
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [UZX-4PN] Authentication token sent over unencrypted WebSocket connection (high confidence)

The auth token is sent via WebSocket message without enforcing TLS/WSS. If users connect via ws:// instead of wss://, the token is transmitted in cleartext and can be intercepted by network attackers.

Suggested fix: While this file cannot directly enforce WSS, document that this auth method requires WSS in production. The enforcement should happen at connection time or in server configuration.

Suggested change
if (token) {
ws.send(JSON.stringify({ type: 'auth', token: token }));
}
// SECURITY: Token should only be sent over WSS (encrypted) in production

Identified by Warden via security-review · medium, high confidence

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) {
Expand Down
6 changes: 4 additions & 2 deletions mobile/src/screens/TerminalScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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;
`
Expand Down
2 changes: 1 addition & 1 deletion src/agent/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
20 changes: 12 additions & 8 deletions src/agent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
interface WebSocketData {
type: 'terminal';
workspaceName: string;
authenticated: boolean;
}

function createAgentServer(
Expand Down Expand Up @@ -89,6 +90,7 @@
isWorkspaceRunning,
isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
getPreferredShell,
getAuthToken: () => currentConfig.auth?.token,
});

const triggerAutoSync = () => {
Expand Down Expand Up @@ -143,32 +145,34 @@
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) {
return undefined;
}
return new Response('WebSocket upgrade failed', { status: 400 });

Check failure on line 168 in src/agent/run.ts

View workflow job for this annotation

GitHub Actions / warden: security-review

Authentication bypass allows unauthenticated terminal WebSocket connections

WebSocket upgrade occurs regardless of authentication status. The auth check result is passed as a flag but the connection is established before validation. This allows attackers to open terminal sessions (with shell access) without valid credentials. The checkAuth result should block the upgrade immediately, not defer it.
Comment on lines 154 to 168
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [4ER-FDL] WebSocket upgrade allowed without authentication (high confidence)

The terminal WebSocket endpoint allows upgrade before authentication. While deferred auth happens in the handler, this creates a window where unauthenticated WebSocket connections exist. The connection can be upgraded (line 161) even when authResult.ok is false, allowing unauthenticated clients to establish WebSocket connections that are only closed later in the handler.

Suggested fix: Reject WebSocket upgrade if authentication fails, or ensure auth check happens before upgrade

Suggested change
if (!authResult.ok) {
return unauthorizedResponse();
}
data: { type, workspaceName, authenticated: true },

Identified by Warden via security-review · high, high confidence

Comment on lines 148 to 168
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [K44-7DE] Authentication mismatch with PR description (high confidence)

PR description states tokens are passed via query param (?token=), but the actual implementation uses WebSocket message-based authentication (auth.ts shows only Authorization header support, terminal handler expects auth messages). This inconsistency suggests either incomplete implementation or misleading documentation. If query params were intended but not implemented, tokens could be logged in server access logs, proxy logs, and browser history.

Identified by Warden via security-review · medium, high confidence

}

const authResult = checkAuth(req, currentConfig);
if (!authResult.ok) {
return unauthorizedResponse();
}

if (pathname === '/health' && method === 'GET') {
const identity = getTailscaleIdentity(req);
const response: Record<string, unknown> = { status: 'ok', version: pkg.version };
Expand Down Expand Up @@ -198,9 +202,9 @@

websocket: {
open(ws: ServerWebSocket<WebSocketData>) {
const { type, workspaceName } = ws.data;
const { type, workspaceName, authenticated } = ws.data;
if (type === 'terminal') {
terminalHandler.handleOpen(ws, workspaceName);
terminalHandler.handleOpen(ws, workspaceName, authenticated);
}
},

Expand Down
4 changes: 4 additions & 0 deletions src/client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ export class ApiClient {
return `${wsUrl}/rpc/terminal/${encodeURIComponent(name)}`;
}

getAuthToken(): string | undefined {
return this.token;
}

getOpencodeUrl(name: string): string {
const wsUrl = this.baseUrl.replace(/^http/, 'ws');
return `${wsUrl}/rpc/opencode/${encodeURIComponent(name)}`;
Expand Down
7 changes: 6 additions & 1 deletion src/client/ws-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,7 +122,7 @@ export async function openTailscaleSSH(options: TailscaleSSHOptions): Promise<vo
}

export async function openWSShell(options: WSShellOptions): Promise<void> {
const { url, onConnect, onDisconnect, onError } = options;
const { url, token, onConnect, onDisconnect, onError } = options;

return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
Expand Down Expand Up @@ -155,6 +156,10 @@ export async function openWSShell(options: WSShellOptions): Promise<void> {
}
stdin.resume();

if (token) {
safeSend(JSON.stringify({ type: 'auth', token }));
}

sendResize();

if (onConnect) {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -495,6 +496,7 @@ program
const wsUrl = getTerminalWSUrl(agentHost, name);
await openWSShell({
url: wsUrl,
token: token || undefined,
onError: (err) => {
console.error(`\nConnection error: ${err.message}`);
},
Expand Down
31 changes: 29 additions & 2 deletions src/terminal/bun-handler.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,40 +12,48 @@ interface TerminalConnection {
session: AnyTerminalSession | null;
workspaceName: string;
started: boolean;
authenticated: boolean;
}

export interface TerminalHandlerOptions {
getContainerName: (workspaceName: string) => string;
isWorkspaceRunning: (workspaceName: string) => Promise<boolean>;
isHostAccessAllowed?: () => boolean;
getPreferredShell?: () => string | undefined;
getAuthToken?: () => string | undefined;
}

export class TerminalHandler {
private connections: Map<ServerWebSocket<unknown>, TerminalConnection> = new Map();
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<unknown>, workspaceName: string): void {
handleOpen(ws: ServerWebSocket<unknown>, workspaceName: string, authenticated = true): void {
const isHostMode = workspaceName === HOST_WORKSPACE_NAME;

if (isHostMode && !this.isHostAccessAllowed()) {
ws.close(4003, 'Host access is disabled');
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);
}
Expand All @@ -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 {
// invalid JSON, reject
}
ws.close(4001, 'Authentication failed');
this.connections.delete(ws);
return;
}

if (data.startsWith('{')) {
try {
const message = JSON.parse(data);
Expand Down
15 changes: 15 additions & 0 deletions src/terminal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
const msg = data as AuthMessage;
return (
typeof data === 'object' &&
data !== null &&
msg.type === 'auth' &&
typeof msg.token === 'string'
);
}
12 changes: 10 additions & 2 deletions test/helpers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,18 @@ 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<AppRouter>;
const link = new RPCLink({
url: `${baseUrl}/rpc`,
fetch: (url, init) => {
const reqInit = init as RequestInit;
const headers = new Headers(reqInit?.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(url, { ...reqInit, headers });
},
});
const client = createORPCClient<Client>(link);

Expand Down Expand Up @@ -363,7 +371,7 @@ export async function startTestAgent(options: TestAgentOptions = {}): Promise<Te
throw new Error(`Agent failed to start. Output:\n${agentOutput}`);
}

const api = createApiClient(baseUrl);
const api = createApiClient(baseUrl, options.config?.auth?.token);

// Track workspaces created by this agent instance for cleanup
const createdWorkspaces: string[] = [];
Expand Down
32 changes: 5 additions & 27 deletions test/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) => {
Expand All @@ -137,7 +115,7 @@ describe('Auth Middleware Integration', () => {
});
ws.on('open', () => {
ws.close();
resolve({ error: null, code: 200 });
resolve({ error: new Error('WebSocket should not have opened') });
});
});

Expand Down
Loading
Loading