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
This commit is contained in:
nyk 2026-03-06 01:28:15 +07:00 committed by GitHub
parent 6e3d8d97cc
commit 2b28b8ebe2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 573 additions and 37 deletions

View File

@ -85,7 +85,7 @@ export async function GET(
} }
const { searchParams } = new URL(request.url); 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 privileged = searchParams.get('privileged') === '1';
const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name; const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name;

View File

@ -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<typeof getDatabase>, 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 })
}
}

View File

@ -16,7 +16,8 @@ export async function POST(request: NextRequest) {
try { try {
const result = await validateBody(request, createMessageSchema) const result = await validateBody(request, createMessageSchema)
if ('error' in result) return result.error 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 db = getDatabase()
const workspaceId = auth.user.workspace_id ?? 1; const workspaceId = auth.user.workspace_id ?? 1;

View File

@ -177,7 +177,8 @@ export async function GET(request: NextRequest) {
/** /**
* POST /api/chat/messages - Send a new message * 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) { export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator') const auth = requireRole(request, 'operator')
@ -188,16 +189,16 @@ export async function POST(request: NextRequest) {
const workspaceId = auth.user.workspace_id ?? 1 const workspaceId = auth.user.workspace_id ?? 1
const body = await request.json() 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 to = body.to ? (body.to as string).trim() : null
const content = (body.content || '').trim() const content = (body.content || '').trim()
const message_type = body.message_type || 'text' const message_type = body.message_type || 'text'
const conversation_id = body.conversation_id || `conv_${Date.now()}` const conversation_id = body.conversation_id || `conv_${Date.now()}`
const metadata = body.metadata || null const metadata = body.metadata || null
if (!from || !content) { if (!content) {
return NextResponse.json( return NextResponse.json(
{ error: '"from" and "content" are required' }, { error: '"content" is required' },
{ status: 400 } { status: 400 }
) )
} }

View File

@ -16,7 +16,7 @@ export async function POST(
const taskId = parseInt(resolvedParams.id) const taskId = parseInt(resolvedParams.id)
const body = await request.json() const body = await request.json()
const workspaceId = auth.user.workspace_id ?? 1; 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() const message = (body.message || '').trim()
if (isNaN(taskId)) { if (isNaN(taskId)) {

View File

@ -109,7 +109,8 @@ export async function POST(
const result = await validateBody(request, createCommentSchema); const result = await validateBody(request, createCommentSchema);
if ('error' in result) return result.error; 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 // Verify task exists
const task = db const task = db

View File

@ -161,6 +161,7 @@ export async function POST(request: NextRequest) {
const body = validated.data; const body = validated.data;
const user = auth.user const user = auth.user
const actor = user.display_name || user.username || 'system'
const { const {
title, title,
description, description,
@ -168,7 +169,6 @@ export async function POST(request: NextRequest) {
priority = 'medium', priority = 'medium',
project_id, project_id,
assigned_to, assigned_to,
created_by = user?.username || 'system',
due_date, due_date,
estimated_hours, estimated_hours,
actual_hours, actual_hours,
@ -231,7 +231,7 @@ export async function POST(request: NextRequest) {
resolvedProjectId, resolvedProjectId,
row.ticket_counter, row.ticket_counter,
assigned_to, assigned_to,
created_by, actor,
now, now,
now, now,
due_date, due_date,
@ -254,7 +254,7 @@ export async function POST(request: NextRequest) {
const taskId = createTaskTx() const taskId = createTaskTx()
// Log activity // 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, title,
status: normalizedStatus, status: normalizedStatus,
priority, priority,
@ -262,18 +262,18 @@ export async function POST(request: NextRequest) {
...(outcome ? { outcome } : {}) ...(outcome ? { outcome } : {})
}, workspaceId); }, workspaceId);
if (created_by) { if (actor) {
db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId) db_helpers.ensureTaskSubscription(taskId, actor, workspaceId)
} }
for (const recipient of mentionResolution.recipients) { for (const recipient of mentionResolution.recipients) {
db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId); db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId);
if (recipient === created_by) continue; if (recipient === actor) continue;
db_helpers.createNotification( db_helpers.createNotification(
recipient, recipient,
'mention', 'mention',
'You were mentioned in a task description', 'You were mentioned in a task description',
`${created_by} mentioned you in task "${title}"`, `${actor} mentioned you in task "${title}"`,
'task', 'task',
taskId, taskId,
workspaceId workspaceId

View File

@ -89,7 +89,6 @@ export function OverviewTab({
loadingHeartbeat: boolean loadingHeartbeat: boolean
onPerformHeartbeat: () => Promise<void> onPerformHeartbeat: () => Promise<void>
}) { }) {
const [messageFrom, setMessageFrom] = useState('system')
const [directMessage, setDirectMessage] = useState('') const [directMessage, setDirectMessage] = useState('')
const [messageStatus, setMessageStatus] = useState<string | null>(null) const [messageStatus, setMessageStatus] = useState<string | null>(null)
@ -102,7 +101,6 @@ export function OverviewTab({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
from: messageFrom || 'system',
to: agent.name, to: agent.name,
message: directMessage message: directMessage
}) })
@ -155,15 +153,6 @@ export function OverviewTab({
<div className="text-xs text-foreground/80 mb-2">{messageStatus}</div> <div className="text-xs text-foreground/80 mb-2">{messageStatus}</div>
)} )}
<form onSubmit={handleSendMessage} className="space-y-2"> <form onSubmit={handleSendMessage} className="space-y-2">
<div>
<label className="block text-xs text-muted-foreground mb-1">From</label>
<input
type="text"
value={messageFrom}
onChange={(e) => 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"
/>
</div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">Message</label> <label className="block text-xs text-muted-foreground mb-1">Message</label>
<textarea <textarea

View File

@ -242,7 +242,8 @@ describe('createMessageSchema', () => {
}) })
expect(result.success).toBe(true) expect(result.success).toBe(true)
if (result.success) { if (result.success) {
expect(result.data.from).toBe('system') expect(result.data.to).toBe('bob')
expect(result.data.message).toBe('Hello')
} }
}) })

View File

@ -1,4 +1,4 @@
import { randomBytes, timingSafeEqual } from 'crypto' import { createHash, randomBytes, timingSafeEqual } from 'crypto'
import { getDatabase } from './db' import { getDatabase } from './db'
import { hashPassword, verifyPassword } from './password' import { hashPassword, verifyPassword } from './password'
@ -33,6 +33,12 @@ export interface User {
last_login_at: number | null last_login_at: number | null
/** Agent name when request is made on behalf of a specific agent (via X-Agent-Name header) */ /** Agent name when request is made on behalf of a specific agent (via X-Agent-Name header) */
agent_name?: string | null agent_name?: string | null
/** Auth principal kind used for this request */
principal_type?: 'user' | 'system_api_key' | 'agent_api_key'
/** Agent id when authenticated via dedicated agent API key */
agent_id?: number | null
/** Scopes resolved for API key principals */
auth_scopes?: string[] | null
} }
export interface UserSession { export interface UserSession {
@ -78,6 +84,18 @@ interface UserQueryRow {
password_hash: string password_hash: string
} }
interface AgentApiKeyRow {
id: number
agent_id: number
workspace_id: number
name: string
scopes: string
expires_at: number | null
revoked_at: number | null
key_hash: string
agent_name: string
}
// Session management // Session management
const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds const SESSION_DURATION = 7 * 24 * 60 * 60 // 7 days in seconds
@ -278,13 +296,19 @@ export function getUserFromRequest(request: Request): User | null {
const sessionToken = parseCookie(cookieHeader, 'mc-session') const sessionToken = parseCookie(cookieHeader, 'mc-session')
if (sessionToken) { if (sessionToken) {
const user = validateSession(sessionToken) const user = validateSession(sessionToken)
if (user) return { ...user, agent_name: agentName } if (user) return { ...user, agent_name: agentName, principal_type: 'user', auth_scopes: null, agent_id: null }
} }
// Check API key - return synthetic user
const configuredApiKey = (process.env.API_KEY || '').trim()
const apiKey = extractApiKeyFromHeaders(request.headers) const apiKey = extractApiKeyFromHeaders(request.headers)
if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) { if (!apiKey) return null
// Check dedicated agent API key first.
const agentPrincipal = validateAgentApiKey(apiKey)
if (agentPrincipal) return agentPrincipal
// Check system API key - return synthetic user.
const configuredApiKey = (process.env.API_KEY || '').trim()
if (configuredApiKey && safeCompare(apiKey, configuredApiKey)) {
return { return {
id: 0, id: 0,
username: 'api', username: 'api',
@ -295,12 +319,85 @@ export function getUserFromRequest(request: Request): User | null {
updated_at: 0, updated_at: 0,
last_login_at: null, last_login_at: null,
agent_name: agentName, agent_name: agentName,
principal_type: 'system_api_key',
auth_scopes: ['admin'],
agent_id: null,
} }
} }
return null return null
} }
function hashApiKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex')
}
function toRoleFromScopes(scopes: string[]): User['role'] {
if (scopes.includes('admin')) return 'admin'
if (scopes.includes('operator')) return 'operator'
return 'viewer'
}
function parseScopes(raw: string): string[] {
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return ['viewer']
const scopes = parsed.map((value) => String(value).trim()).filter(Boolean)
return scopes.length > 0 ? scopes : ['viewer']
} catch {
return ['viewer']
}
}
function validateAgentApiKey(rawApiKey: string): User | null {
try {
const db = getDatabase()
const now = Math.floor(Date.now() / 1000)
const keyHash = hashApiKey(rawApiKey)
const row = db.prepare(`
SELECT k.id, k.agent_id, k.workspace_id, k.name, k.scopes, k.expires_at, k.revoked_at, k.key_hash, a.name as agent_name
FROM agent_api_keys k
JOIN agents a ON a.id = k.agent_id
WHERE k.key_hash = ?
AND k.revoked_at IS NULL
AND (k.expires_at IS NULL OR k.expires_at > ?)
AND a.workspace_id = k.workspace_id
LIMIT 1
`).get(keyHash, now) as AgentApiKeyRow | undefined
if (!row) return null
if (!safeCompare(keyHash, row.key_hash)) return null
const scopes = parseScopes(row.scopes)
const role = toRoleFromScopes(scopes)
// Authentication should not fail if best-effort key usage bookkeeping hits a transient lock.
try {
db.prepare(`UPDATE agent_api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`).run(now, now, row.id)
} catch {
// no-op
}
return {
id: row.agent_id,
username: row.agent_name,
display_name: row.agent_name,
role,
workspace_id: row.workspace_id,
created_at: 0,
updated_at: 0,
last_login_at: null,
agent_name: row.agent_name,
principal_type: 'agent_api_key',
auth_scopes: scopes,
agent_id: row.agent_id,
}
} catch {
return null
}
}
function extractApiKeyFromHeaders(headers: Headers): string | null { function extractApiKeyFromHeaders(headers: Headers): string | null {
const direct = (headers.get('x-api-key') || '').trim() const direct = (headers.get('x-api-key') || '').trim()
if (direct) return direct if (direct) return direct

View File

@ -2,7 +2,11 @@ import fs from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
const defaultDataDir = path.join(process.cwd(), '.data') const runtimeCwd = process.cwd()
const normalizedCwd = runtimeCwd.endsWith(path.join('.next', 'standalone'))
? path.resolve(runtimeCwd, '..', '..')
: runtimeCwd
const defaultDataDir = path.join(normalizedCwd, '.data')
const defaultOpenClawStateDir = path.join(os.homedir(), '.openclaw') const defaultOpenClawStateDir = path.join(os.homedir(), '.openclaw')
const explicitOpenClawConfigPath = const explicitOpenClawConfigPath =
process.env.OPENCLAW_CONFIG_PATH || process.env.OPENCLAW_CONFIG_PATH ||

View File

@ -797,6 +797,39 @@ const migrations: Migration[] = [
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_completed_at ON tasks(completed_at)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_completed_at ON tasks(completed_at)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_outcome ON tasks(workspace_id, outcome, completed_at)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_outcome ON tasks(workspace_id, outcome, completed_at)`)
} }
},
{
id: '027_agent_api_keys',
up: (db) => {
const hasAgents = db
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = 'agents'`)
.get() as { ok?: number } | undefined
if (!hasAgents?.ok) return
db.exec(`
CREATE TABLE IF NOT EXISTS agent_api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
workspace_id INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
key_prefix TEXT NOT NULL,
scopes TEXT NOT NULL DEFAULT '["viewer"]',
expires_at INTEGER,
revoked_at INTEGER,
created_by TEXT,
last_used_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_agent_id ON agent_api_keys(agent_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_workspace_id ON agent_api_keys(workspace_id)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_expires_at ON agent_api_keys(expires_at)`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_api_keys_revoked_at ON agent_api_keys(revoked_at)`)
}
} }
] ]

View File

@ -33,7 +33,6 @@ export const createTaskSchema = z.object({
priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'), priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
project_id: z.number().int().positive().optional(), project_id: z.number().int().positive().optional(),
assigned_to: z.string().max(100).optional(), assigned_to: z.string().max(100).optional(),
created_by: z.string().max(100).optional(),
due_date: z.number().optional(), due_date: z.number().optional(),
estimated_hours: z.number().min(0).optional(), estimated_hours: z.number().min(0).optional(),
actual_hours: z.number().min(0).optional(), actual_hours: z.number().min(0).optional(),
@ -124,14 +123,12 @@ export const createWorkflowSchema = z.object({
export const createCommentSchema = z.object({ export const createCommentSchema = z.object({
task_id: z.number().optional(), task_id: z.number().optional(),
content: z.string().min(1, 'Comment content is required'), content: z.string().min(1, 'Comment content is required'),
author: z.string().optional(),
parent_id: z.number().optional(), parent_id: z.number().optional(),
}) })
export const createMessageSchema = z.object({ export const createMessageSchema = z.object({
to: z.string().min(1, 'Recipient is required'), to: z.string().min(1, 'Recipient is required'),
message: z.string().min(1, 'Message is required'), message: z.string().min(1, 'Message is required'),
from: z.string().optional().default('system'),
}) })
export const updateSettingsSchema = z.object({ export const updateSettingsSchema = z.object({

View File

@ -117,7 +117,10 @@ export function proxy(request: NextRequest) {
const configuredApiKey = (process.env.API_KEY || '').trim() const configuredApiKey = (process.env.API_KEY || '').trim()
const apiKey = extractApiKeyFromRequest(request) const apiKey = extractApiKeyFromRequest(request)
const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey))
if (sessionToken || hasValidApiKey) { // Dedicated agent API keys are validated in route auth against DB.
// Proxy only permits them to pass through to avoid coupling middleware to DB access.
const hasAgentApiKeyCandidate = apiKey.startsWith('mca_')
if (sessionToken || hasValidApiKey || hasAgentApiKeyCandidate) {
return applySecurityHeaders(NextResponse.next()) return applySecurityHeaders(NextResponse.next())
} }

View File

@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test'
import { API_KEY_HEADER, createTestAgent, createTestTask, deleteTestAgent, deleteTestTask } from './helpers'
test.describe('Actor Identity Hardening', () => {
const taskCleanup: number[] = []
const agentCleanup: number[] = []
test.afterEach(async ({ request }) => {
while (taskCleanup.length > 0) {
const id = taskCleanup.pop()!
await deleteTestTask(request, id)
}
while (agentCleanup.length > 0) {
const id = agentCleanup.pop()!
await deleteTestAgent(request, id)
}
})
test('POST /api/chat/messages ignores client-supplied from and uses authenticated actor', async ({ request }) => {
const res = await request.post('/api/chat/messages', {
headers: API_KEY_HEADER,
data: {
from: 'spoofed-user',
content: 'identity hardening check',
conversation_id: `identity-check-${Date.now()}`,
},
})
expect(res.status()).toBe(201)
const body = await res.json()
expect(body.message.from_agent).toBe('API Access')
expect(body.message.from_agent).not.toBe('spoofed-user')
})
test('POST /api/tasks/[id]/broadcast ignores client-supplied author', async ({ request }) => {
const { id: taskId } = await createTestTask(request)
taskCleanup.push(taskId)
const { id: agentId, name: agentName } = await createTestAgent(request)
agentCleanup.push(agentId)
const commentRes = await request.post(`/api/tasks/${taskId}/comments`, {
headers: API_KEY_HEADER,
data: { content: `Mentioning @${agentName} for subscription` },
})
expect(commentRes.status()).toBe(201)
const broadcastRes = await request.post(`/api/tasks/${taskId}/broadcast`, {
headers: API_KEY_HEADER,
data: {
author: agentName,
message: 'hardening broadcast test',
},
})
expect(broadcastRes.status()).toBe(200)
const body = await broadcastRes.json()
expect(body.sent + body.skipped).toBe(1)
expect(body.skipped).toBe(1)
})
})

View File

@ -0,0 +1,82 @@
import { expect, test } from '@playwright/test'
import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers'
test.describe('Agent API keys', () => {
const cleanup: number[] = []
test.afterEach(async ({ request }) => {
for (const id of cleanup.splice(0)) {
await deleteTestAgent(request, id).catch(() => {})
}
})
test('supports scoped agent auth without x-agent-name and allows revoke', async ({ request }) => {
const primary = await createTestAgent(request)
const other = await createTestAgent(request)
cleanup.push(primary.id, other.id)
const createKeyRes = await request.post(`/api/agents/${primary.id}/keys`, {
headers: API_KEY_HEADER,
data: {
name: 'diag-key',
scopes: ['viewer', 'agent:self', 'agent:diagnostics'],
expires_in_days: 1,
},
})
expect(createKeyRes.status()).toBe(201)
const createKeyBody = await createKeyRes.json()
expect(createKeyBody.api_key).toMatch(/^mca_/)
const agentKeyHeader = { 'x-api-key': createKeyBody.api_key as string }
const selfRes = await request.get(`/api/agents/${primary.id}/diagnostics?section=summary`, {
headers: agentKeyHeader,
})
expect(selfRes.status()).toBe(200)
const crossRes = await request.get(`/api/agents/${other.id}/diagnostics?section=summary`, {
headers: agentKeyHeader,
})
expect(crossRes.status()).toBe(403)
const listRes = await request.get(`/api/agents/${primary.id}/keys`, { headers: API_KEY_HEADER })
expect(listRes.status()).toBe(200)
const listBody = await listRes.json()
const storedKey = listBody.keys.find((entry: any) => entry.id === createKeyBody.key.id)
expect(storedKey).toBeDefined()
expect(storedKey.key_prefix).toBe(createKeyBody.key.key_prefix)
const revokeRes = await request.delete(`/api/agents/${primary.id}/keys`, {
headers: API_KEY_HEADER,
data: { key_id: createKeyBody.key.id },
})
expect(revokeRes.status()).toBe(200)
const afterRevoke = await request.get(`/api/agents/${primary.id}/diagnostics?section=summary`, {
headers: agentKeyHeader,
})
expect(afterRevoke.status()).toBe(401)
})
test('rejects expired agent keys', async ({ request }) => {
const primary = await createTestAgent(request)
cleanup.push(primary.id)
const createKeyRes = await request.post(`/api/agents/${primary.id}/keys`, {
headers: API_KEY_HEADER,
data: {
name: 'expired-key',
scopes: ['viewer', 'agent:self'],
expires_at: Math.floor(Date.now() / 1000) - 5,
},
})
expect(createKeyRes.status()).toBe(201)
const { api_key } = await createKeyRes.json()
const expiredRes = await request.get(`/api/agents/${primary.id}/attribution?section=identity`, {
headers: { 'x-api-key': api_key },
})
expect(expiredRes.status()).toBe(401)
})
})

View File

@ -69,6 +69,21 @@ test.describe('Task Comments', () => {
expect(replyBody.comment.parent_id).toBe(parentId) expect(replyBody.comment.parent_id).toBe(parentId)
}) })
test('POST ignores client-supplied author and uses authenticated actor', async ({ request }) => {
const { id } = await createTestTask(request)
cleanup.push(id)
const res = await request.post(`/api/tasks/${id}/comments`, {
headers: API_KEY_HEADER,
data: { content: 'Author spoof check', author: 'spoofed-author' },
})
expect(res.status()).toBe(201)
const body = await res.json()
expect(body.comment.author).not.toBe('spoofed-author')
expect(body.comment.author).toBe('API Access')
})
// ── GET /api/tasks/[id]/comments ───────────── // ── GET /api/tasks/[id]/comments ─────────────
test('GET returns comments array for task', async ({ request }) => { test('GET returns comments array for task', async ({ request }) => {

View File

@ -44,6 +44,23 @@ test.describe('Tasks CRUD', () => {
expect(body.task.metadata).toEqual({ source: 'e2e' }) expect(body.task.metadata).toEqual({ source: 'e2e' })
}) })
test('POST ignores client-supplied created_by and uses authenticated actor', async ({ request }) => {
const title = `e2e-task-actor-${Date.now()}`
const res = await request.post('/api/tasks', {
headers: API_KEY_HEADER,
data: {
title,
created_by: 'spoofed-agent',
},
})
expect(res.status()).toBe(201)
const body = await res.json()
const id = Number(body.task.id)
cleanup.push(id)
expect(body.task.created_by).not.toBe('spoofed-agent')
expect(body.task.created_by).toBe('API Access')
})
test('POST rejects empty title', async ({ request }) => { test('POST rejects empty title', async ({ request }) => {
const res = await request.post('/api/tasks', { const res = await request.post('/api/tasks', {
headers: API_KEY_HEADER, headers: API_KEY_HEADER,