Skip to content

Commit 060a818

Browse files
Agent Relayclaude
andcommitted
fix(security): add workspace token auth to CLI auth endpoints
Fixes bd-critical-016: Workspace Daemon Auth - Unauthenticated Endpoints The workspace daemon's CLI auth endpoints were exposed without authentication. In cloud mode, attackers could potentially: - Submit malicious codes to active auth sessions - Enumerate active sessions - DoS the PTY processes - Hijack OAuth flows mid-completion Changes: - Add validateWorkspaceToken middleware to dashboard-server - Apply middleware to all /auth/cli/* endpoints - Skip auth in local mode (no WORKSPACE_TOKEN set) - Update cloud server onboarding.ts to send Authorization header - Add generateWorkspaceToken() helper matching provisioner logic - Store workspaceId in session for subsequent requests The workspace token is an HMAC-SHA256 hash of the workspace ID, signed with the session secret. This matches the token generation in the provisioner. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 2148459 commit 060a818

File tree

4 files changed

+92
-11
lines changed

4 files changed

+92
-11
lines changed

.trajectories/active/traj_7ludwvz45veh.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,18 @@
191191
"reasoning": "Hamburger menu visibility, logs button always visible on mobile, responsive padding throughout"
192192
},
193193
"significance": "high"
194+
},
195+
{
196+
"ts": 1767636873294,
197+
"type": "decision",
198+
"content": "Starting work on bd-critical-016: Workspace Daemon Auth security fix: Starting work on bd-critical-016: Workspace Daemon Auth security fix",
199+
"raw": {
200+
"question": "Starting work on bd-critical-016: Workspace Daemon Auth security fix",
201+
"chosen": "Starting work on bd-critical-016: Workspace Daemon Auth security fix",
202+
"alternatives": [],
203+
"reasoning": ""
204+
},
205+
"significance": "high"
194206
}
195207
]
196208
}

.trajectories/index.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": 1,
3-
"lastUpdated": "2026-01-05T17:59:19.223Z",
3+
"lastUpdated": "2026-01-05T18:14:33.532Z",
44
"trajectories": {
55
"traj_ozd98si6a7ns": {
66
"title": "Fix thinking indicator showing on all messages",

src/cloud/api/onboarding.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as crypto from 'crypto';
1616
import { requireAuth } from './auth.js';
1717
import { db } from '../db/index.js';
1818
import { vault } from '../vault/index.js';
19+
import { getConfig } from '../config.js';
1920

2021
// Import for local use
2122
import {
@@ -51,6 +52,25 @@ export {
5152

5253
export const onboardingRouter = Router();
5354

55+
/**
56+
* Generate workspace token for authenticating requests to workspace daemon.
57+
* Must match the token generation in provisioner/index.ts
58+
*/
59+
function generateWorkspaceToken(workspaceId: string): string {
60+
const config = getConfig();
61+
return crypto
62+
.createHmac('sha256', config.sessionSecret)
63+
.update(`workspace:${workspaceId}`)
64+
.digest('hex');
65+
}
66+
67+
/**
68+
* Get Authorization header for workspace daemon requests
69+
*/
70+
function getWorkspaceAuthHeader(workspaceId: string): string {
71+
return `Bearer ${generateWorkspaceToken(workspaceId)}`;
72+
}
73+
5474
// Debug: log all requests to this router
5575
onboardingRouter.use((req, res, next) => {
5676
console.log(`[onboarding] ${req.method} ${req.path} - body:`, JSON.stringify(req.body));
@@ -80,6 +100,7 @@ interface CLIAuthSession {
80100
// Workspace delegation fields (set when auth runs in workspace daemon)
81101
workspaceUrl?: string;
82102
workspaceSessionId?: string;
103+
workspaceId?: string; // For generating auth token
83104
}
84105

85106
const activeSessions = new Map<string, CLIAuthSession>();
@@ -185,7 +206,10 @@ onboardingRouter.post('/cli/:provider/start', async (req: Request, res: Response
185206

186207
const authResponse = await fetch(targetUrl, {
187208
method: 'POST',
188-
headers: { 'Content-Type': 'application/json' },
209+
headers: {
210+
'Content-Type': 'application/json',
211+
'Authorization': getWorkspaceAuthHeader(workspace.id),
212+
},
189213
body: JSON.stringify({ useDeviceFlow }),
190214
});
191215

@@ -217,6 +241,7 @@ onboardingRouter.post('/cli/:provider/start', async (req: Request, res: Response
217241
// Store workspace info for status polling and auth code forwarding
218242
workspaceUrl,
219243
workspaceSessionId: workspaceSession.sessionId,
244+
workspaceId: workspace.id,
220245
};
221246

222247
activeSessions.set(sessionId, session);
@@ -253,10 +278,13 @@ onboardingRouter.get('/cli/:provider/status/:sessionId', async (req: Request, re
253278
}
254279

255280
// If we have workspace info, poll the workspace for status
256-
if (session.workspaceUrl && session.workspaceSessionId) {
281+
if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) {
257282
try {
258283
const statusResponse = await fetch(
259-
`${session.workspaceUrl}/auth/cli/${provider}/status/${session.workspaceSessionId}`
284+
`${session.workspaceUrl}/auth/cli/${provider}/status/${session.workspaceSessionId}`,
285+
{
286+
headers: { 'Authorization': getWorkspaceAuthHeader(session.workspaceId) },
287+
}
260288
);
261289
if (statusResponse.ok) {
262290
const workspaceStatus = await statusResponse.json() as {
@@ -311,7 +339,7 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request,
311339
let tokenExpiresAt = session.tokenExpiresAt;
312340

313341
// If using workspace delegation, forward complete request first
314-
if (session.workspaceUrl && session.workspaceSessionId) {
342+
if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) {
315343
// Forward authCode to workspace if provided (for Codex-style redirects)
316344
if (authCode) {
317345
const backendProviderId = provider === 'anthropic' ? 'anthropic' : provider;
@@ -320,7 +348,10 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request,
320348

321349
const completeResponse = await fetch(targetUrl, {
322350
method: 'POST',
323-
headers: { 'Content-Type': 'application/json' },
351+
headers: {
352+
'Content-Type': 'application/json',
353+
'Authorization': getWorkspaceAuthHeader(session.workspaceId),
354+
},
324355
body: JSON.stringify({ authCode }),
325356
});
326357

@@ -337,7 +368,10 @@ onboardingRouter.post('/cli/:provider/complete/:sessionId', async (req: Request,
337368
if (!accessToken) {
338369
try {
339370
const credsResponse = await fetch(
340-
`${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}`
371+
`${session.workspaceUrl}/auth/cli/${provider}/creds/${session.workspaceSessionId}`,
372+
{
373+
headers: { 'Authorization': getWorkspaceAuthHeader(session.workspaceId) },
374+
}
341375
);
342376
if (credsResponse.ok) {
343377
const creds = await credsResponse.json() as {
@@ -424,14 +458,17 @@ onboardingRouter.post('/cli/:provider/code/:sessionId', async (req: Request, res
424458
});
425459

426460
// Forward to workspace daemon
427-
if (session.workspaceUrl && session.workspaceSessionId) {
461+
if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) {
428462
try {
429463
const targetUrl = `${session.workspaceUrl}/auth/cli/${provider}/code/${session.workspaceSessionId}`;
430464
console.log('[onboarding] Forwarding auth code to workspace:', targetUrl);
431465

432466
const codeResponse = await fetch(targetUrl, {
433467
method: 'POST',
434-
headers: { 'Content-Type': 'application/json' },
468+
headers: {
469+
'Content-Type': 'application/json',
470+
'Authorization': getWorkspaceAuthHeader(session.workspaceId),
471+
},
435472
body: JSON.stringify({ code }),
436473
});
437474

@@ -485,11 +522,14 @@ onboardingRouter.post('/cli/:provider/cancel/:sessionId', async (req: Request, r
485522
const session = activeSessions.get(sessionId);
486523
if (session?.userId === userId) {
487524
// Cancel on workspace side if applicable
488-
if (session.workspaceUrl && session.workspaceSessionId) {
525+
if (session.workspaceUrl && session.workspaceSessionId && session.workspaceId) {
489526
try {
490527
await fetch(
491528
`${session.workspaceUrl}/auth/cli/${provider}/cancel/${session.workspaceSessionId}`,
492-
{ method: 'POST' }
529+
{
530+
method: 'POST',
531+
headers: { 'Authorization': getWorkspaceAuthHeader(session.workspaceId) },
532+
}
493533
);
494534
} catch {
495535
// Ignore cancel errors

src/dashboard-server/server.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,35 @@ export async function startDashboard(
21102110

21112111
// ===== CLI Auth API (for workspace-based provider authentication) =====
21122112

2113+
/**
2114+
* Middleware to validate workspace token for internal endpoints.
2115+
* In cloud mode, requests to /auth/cli/* must include a valid WORKSPACE_TOKEN
2116+
* to prevent unauthorized access to auth sessions.
2117+
*/
2118+
const validateWorkspaceToken: express.RequestHandler = (req, res, next) => {
2119+
// Skip auth validation in local mode (no WORKSPACE_TOKEN set)
2120+
const expectedToken = process.env.WORKSPACE_TOKEN;
2121+
if (!expectedToken) {
2122+
return next();
2123+
}
2124+
2125+
// Extract token from Authorization header
2126+
const authHeader = req.headers.authorization;
2127+
const token = authHeader?.startsWith('Bearer ')
2128+
? authHeader.substring(7)
2129+
: null;
2130+
2131+
if (!token || token !== expectedToken) {
2132+
console.warn('[dashboard] Unauthorized CLI auth request - invalid or missing workspace token');
2133+
return res.status(401).json({ error: 'Unauthorized - invalid workspace token' });
2134+
}
2135+
2136+
next();
2137+
};
2138+
2139+
// Apply workspace token validation to all CLI auth endpoints
2140+
app.use('/auth/cli', validateWorkspaceToken);
2141+
21132142
/**
21142143
* POST /auth/cli/:provider/start - Start CLI auth flow
21152144
* Body: { useDeviceFlow?: boolean }

0 commit comments

Comments
 (0)