From 2b28b8ebe20d0f1f8cac1707368783ec84325b26 Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:28:15 +0700 Subject: [PATCH] fix(security): enforce server-side actor identity (#224) * fix(security): enforce server-side actor identity * test: align message schema assertion with actor hardening * fix(security): enforce server actor identity in chat and broadcast * feat(auth): add scoped agent API keys with expiry and revocation --- src/app/api/agents/[id]/diagnostics/route.ts | 2 +- src/app/api/agents/[id]/keys/route.ts | 234 +++++++++++++++++++ src/app/api/agents/message/route.ts | 3 +- src/app/api/chat/messages/route.ts | 9 +- src/app/api/tasks/[id]/broadcast/route.ts | 2 +- src/app/api/tasks/[id]/comments/route.ts | 3 +- src/app/api/tasks/route.ts | 14 +- src/components/panels/agent-detail-tabs.tsx | 11 - src/lib/__tests__/validation.test.ts | 3 +- src/lib/auth.ts | 107 ++++++++- src/lib/config.ts | 6 +- src/lib/migrations.ts | 33 +++ src/lib/validation.ts | 3 - src/proxy.ts | 5 +- tests/actor-identity-hardening.spec.ts | 61 +++++ tests/agent-api-keys.spec.ts | 82 +++++++ tests/task-comments.spec.ts | 15 ++ tests/tasks-crud.spec.ts | 17 ++ 18 files changed, 573 insertions(+), 37 deletions(-) create mode 100644 src/app/api/agents/[id]/keys/route.ts create mode 100644 tests/actor-identity-hardening.spec.ts create mode 100644 tests/agent-api-keys.spec.ts diff --git a/src/app/api/agents/[id]/diagnostics/route.ts b/src/app/api/agents/[id]/diagnostics/route.ts index 4810843..f690f9b 100644 --- a/src/app/api/agents/[id]/diagnostics/route.ts +++ b/src/app/api/agents/[id]/diagnostics/route.ts @@ -85,7 +85,7 @@ export async function GET( } const { searchParams } = new URL(request.url); - const requesterAgentName = (request.headers.get('x-agent-name') || '').trim(); + const requesterAgentName = auth.user.agent_name?.trim() || ''; const privileged = searchParams.get('privileged') === '1'; const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name; diff --git a/src/app/api/agents/[id]/keys/route.ts b/src/app/api/agents/[id]/keys/route.ts new file mode 100644 index 0000000..d8b932f --- /dev/null +++ b/src/app/api/agents/[id]/keys/route.ts @@ -0,0 +1,234 @@ +import { createHash, randomBytes } from 'crypto' +import { NextRequest, NextResponse } from 'next/server' +import { requireRole } from '@/lib/auth' +import { getDatabase } from '@/lib/db' +import { logger } from '@/lib/logger' + +const ALLOWED_SCOPES = new Set([ + 'viewer', + 'operator', + 'admin', + 'agent:self', + 'agent:diagnostics', + 'agent:attribution', + 'agent:heartbeat', + 'agent:messages', +]) + +interface AgentRow { + id: number + name: string + workspace_id: number +} + +interface AgentKeyRow { + id: number + name: string + key_prefix: string + scopes: string + created_by: string | null + expires_at: number | null + revoked_at: number | null + last_used_at: number | null + created_at: number + updated_at: number +} + +function hashApiKey(rawKey: string): string { + return createHash('sha256').update(rawKey).digest('hex') +} + +function resolveAgent(db: ReturnType, idParam: string, workspaceId: number): AgentRow | null { + if (/^\d+$/.test(idParam)) { + return (db + .prepare(`SELECT id, name, workspace_id FROM agents WHERE id = ? AND workspace_id = ?`) + .get(Number(idParam), workspaceId) as AgentRow | undefined) || null + } + + return (db + .prepare(`SELECT id, name, workspace_id FROM agents WHERE name = ? AND workspace_id = ?`) + .get(idParam, workspaceId) as AgentRow | undefined) || null +} + +function parseScopes(rawScopes: unknown): string[] { + const fallback = ['viewer', 'agent:self'] + if (!Array.isArray(rawScopes)) return fallback + + const scopes = rawScopes + .map((scope) => String(scope).trim()) + .filter((scope) => scope.length > 0 && ALLOWED_SCOPES.has(scope)) + + if (scopes.length === 0) return fallback + return Array.from(new Set(scopes)) +} + +function parseExpiry(body: any): number | null { + if (body?.expires_at != null) { + const value = Number(body.expires_at) + if (!Number.isInteger(value) || value <= 0) throw new Error('expires_at must be a future unix timestamp') + return value + } + + if (body?.expires_in_days != null) { + const days = Number(body.expires_in_days) + if (!Number.isFinite(days) || days <= 0 || days > 3650) { + throw new Error('expires_in_days must be between 1 and 3650') + } + return Math.floor(Date.now() / 1000) + Math.floor(days * 24 * 60 * 60) + } + + return null +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = requireRole(request, 'admin') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const resolved = await params + const workspaceId = auth.user.workspace_id ?? 1 + const agent = resolveAgent(db, resolved.id, workspaceId) + if (!agent) return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) + + const rows = db + .prepare(` + SELECT id, name, key_prefix, scopes, created_by, expires_at, revoked_at, last_used_at, created_at, updated_at + FROM agent_api_keys + WHERE agent_id = ? AND workspace_id = ? + ORDER BY created_at DESC, id DESC + `) + .all(agent.id, workspaceId) as AgentKeyRow[] + + return NextResponse.json({ + agent: { id: agent.id, name: agent.name }, + keys: rows.map((row) => ({ + ...row, + scopes: (() => { + try { + const parsed = JSON.parse(row.scopes) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + })(), + })), + }) + } catch (error) { + logger.error({ err: error }, 'GET /api/agents/[id]/keys error') + return NextResponse.json({ error: 'Failed to list agent API keys' }, { status: 500 }) + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = requireRole(request, 'admin') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const resolved = await params + const workspaceId = auth.user.workspace_id ?? 1 + const agent = resolveAgent(db, resolved.id, workspaceId) + if (!agent) return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) + + const body = await request.json().catch(() => ({})) + const name = String(body?.name || 'default').trim().slice(0, 128) + if (!name) return NextResponse.json({ error: 'name is required' }, { status: 400 }) + + let expiresAt: number | null = null + try { + expiresAt = parseExpiry(body) + } catch (error) { + return NextResponse.json({ error: (error as Error).message }, { status: 400 }) + } + + const scopes = parseScopes(body?.scopes) + const now = Math.floor(Date.now() / 1000) + const rawKey = `mca_${randomBytes(24).toString('hex')}` + const keyHash = hashApiKey(rawKey) + const keyPrefix = rawKey.slice(0, 12) + + const result = db + .prepare(` + INSERT INTO agent_api_keys ( + agent_id, workspace_id, name, key_hash, key_prefix, scopes, expires_at, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + agent.id, + workspaceId, + name, + keyHash, + keyPrefix, + JSON.stringify(scopes), + expiresAt, + auth.user.username, + now, + now, + ) + + return NextResponse.json( + { + key: { + id: Number(result.lastInsertRowid), + name, + key_prefix: keyPrefix, + scopes, + expires_at: expiresAt, + created_at: now, + }, + api_key: rawKey, + }, + { status: 201 }, + ) + } catch (error) { + logger.error({ err: error }, 'POST /api/agents/[id]/keys error') + return NextResponse.json({ error: 'Failed to create agent API key' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = requireRole(request, 'admin') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const resolved = await params + const workspaceId = auth.user.workspace_id ?? 1 + const agent = resolveAgent(db, resolved.id, workspaceId) + if (!agent) return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) + + const body = await request.json().catch(() => ({})) + const keyId = Number(body?.key_id) + if (!Number.isInteger(keyId) || keyId <= 0) { + return NextResponse.json({ error: 'key_id must be a positive integer' }, { status: 400 }) + } + + const now = Math.floor(Date.now() / 1000) + const result = db + .prepare(` + UPDATE agent_api_keys + SET revoked_at = ?, updated_at = ? + WHERE id = ? AND agent_id = ? AND workspace_id = ? AND revoked_at IS NULL + `) + .run(now, now, keyId, agent.id, workspaceId) + + if (result.changes < 1) { + return NextResponse.json({ error: 'Active key not found for this agent' }, { status: 404 }) + } + + return NextResponse.json({ success: true, key_id: keyId, revoked_at: now }) + } catch (error) { + logger.error({ err: error }, 'DELETE /api/agents/[id]/keys error') + return NextResponse.json({ error: 'Failed to revoke agent API key' }, { status: 500 }) + } +} diff --git a/src/app/api/agents/message/route.ts b/src/app/api/agents/message/route.ts index 09a0179..dcbcc4e 100644 --- a/src/app/api/agents/message/route.ts +++ b/src/app/api/agents/message/route.ts @@ -16,7 +16,8 @@ export async function POST(request: NextRequest) { try { const result = await validateBody(request, createMessageSchema) if ('error' in result) return result.error - const { from, to, message } = result.data + const { to, message } = result.data + const from = auth.user.display_name || auth.user.username || 'system' const db = getDatabase() const workspaceId = auth.user.workspace_id ?? 1; diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index 545e918..0b28760 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -177,7 +177,8 @@ export async function GET(request: NextRequest) { /** * POST /api/chat/messages - Send a new message - * Body: { from, to, content, message_type, conversation_id, metadata } + * Body: { to, content, message_type, conversation_id, metadata } + * Sender identity is always resolved server-side from authenticated user. */ export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') @@ -188,16 +189,16 @@ export async function POST(request: NextRequest) { const workspaceId = auth.user.workspace_id ?? 1 const body = await request.json() - const from = (body.from || '').trim() + const from = auth.user.display_name || auth.user.username || 'system' const to = body.to ? (body.to as string).trim() : null const content = (body.content || '').trim() const message_type = body.message_type || 'text' const conversation_id = body.conversation_id || `conv_${Date.now()}` const metadata = body.metadata || null - if (!from || !content) { + if (!content) { return NextResponse.json( - { error: '"from" and "content" are required' }, + { error: '"content" is required' }, { status: 400 } ) } diff --git a/src/app/api/tasks/[id]/broadcast/route.ts b/src/app/api/tasks/[id]/broadcast/route.ts index 042f80a..21e8920 100644 --- a/src/app/api/tasks/[id]/broadcast/route.ts +++ b/src/app/api/tasks/[id]/broadcast/route.ts @@ -16,7 +16,7 @@ export async function POST( const taskId = parseInt(resolvedParams.id) const body = await request.json() const workspaceId = auth.user.workspace_id ?? 1; - const author = (body.author || 'system') as string + const author = auth.user.display_name || auth.user.username || 'system' const message = (body.message || '').trim() if (isNaN(taskId)) { diff --git a/src/app/api/tasks/[id]/comments/route.ts b/src/app/api/tasks/[id]/comments/route.ts index 72d19ff..83b1c00 100644 --- a/src/app/api/tasks/[id]/comments/route.ts +++ b/src/app/api/tasks/[id]/comments/route.ts @@ -109,7 +109,8 @@ export async function POST( const result = await validateBody(request, createCommentSchema); if ('error' in result) return result.error; - const { content, author = 'system', parent_id } = result.data; + const { content, parent_id } = result.data; + const author = auth.user.display_name || auth.user.username || 'system'; // Verify task exists const task = db diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 931d2de..0cc366c 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -161,6 +161,7 @@ export async function POST(request: NextRequest) { const body = validated.data; const user = auth.user + const actor = user.display_name || user.username || 'system' const { title, description, @@ -168,7 +169,6 @@ export async function POST(request: NextRequest) { priority = 'medium', project_id, assigned_to, - created_by = user?.username || 'system', due_date, estimated_hours, actual_hours, @@ -231,7 +231,7 @@ export async function POST(request: NextRequest) { resolvedProjectId, row.ticket_counter, assigned_to, - created_by, + actor, now, now, due_date, @@ -254,7 +254,7 @@ export async function POST(request: NextRequest) { const taskId = createTaskTx() // Log activity - db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, { + db_helpers.logActivity('task_created', 'task', taskId, actor, `Created task: ${title}`, { title, status: normalizedStatus, priority, @@ -262,18 +262,18 @@ export async function POST(request: NextRequest) { ...(outcome ? { outcome } : {}) }, workspaceId); - if (created_by) { - db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId) + if (actor) { + db_helpers.ensureTaskSubscription(taskId, actor, workspaceId) } for (const recipient of mentionResolution.recipients) { db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId); - if (recipient === created_by) continue; + if (recipient === actor) continue; db_helpers.createNotification( recipient, 'mention', 'You were mentioned in a task description', - `${created_by} mentioned you in task "${title}"`, + `${actor} mentioned you in task "${title}"`, 'task', taskId, workspaceId diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index 4f5ffd0..18d9f0f 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -89,7 +89,6 @@ export function OverviewTab({ loadingHeartbeat: boolean onPerformHeartbeat: () => Promise }) { - const [messageFrom, setMessageFrom] = useState('system') const [directMessage, setDirectMessage] = useState('') const [messageStatus, setMessageStatus] = useState(null) @@ -102,7 +101,6 @@ export function OverviewTab({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - from: messageFrom || 'system', to: agent.name, message: directMessage }) @@ -155,15 +153,6 @@ export function OverviewTab({
{messageStatus}
)}
-
- - setMessageFrom(e.target.value)} - className="w-full bg-surface-1 text-foreground rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50" - /> -