security(csp): remove unsafe-inline with nonce-based CSP (#288)

* fix: route coordinator sends to live sessions and parse boxed doctor output

* feat: make coordinator routing user-configurable and deployment-agnostic

* feat: add coordinator target dropdown in settings

* feat(settings): preview live coordinator routing resolution

* security(csp): remove unsafe-inline via per-request nonce policy

* fix(ui): disable new organization CTA; improve skills registry and panel defaults

- disable non-functional New organization button in nav rail
- gate Hermes Memory visibility on actual Hermes CLI binary detection
- optimize agents task stats API by replacing N+1 with grouped query
- make task board render primary data first and hydrate quality-review async
- default skills panel registry source to awesome-openclaw
- add resilient registry search fallbacks for ClawdHub/skills.sh endpoint variants
This commit is contained in:
nyk 2026-03-11 23:15:20 +07:00 committed by GitHub
parent 84e197b3dc
commit 44aaf150c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 208 additions and 114 deletions

View File

@ -130,7 +130,7 @@ bash scripts/security-audit.sh
### Known Limitations
- **CSP still includes `unsafe-inline`**`unsafe-eval` has been removed, but inline styles remain for framework compatibility
- No major security limitations currently tracked here for CSP; policy now uses per-request nonces (no `unsafe-inline` / `unsafe-eval`).
### Security Considerations

View File

@ -105,7 +105,7 @@ Mission Control sets these headers automatically:
| Header | Value |
|--------|-------|
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'unsafe-inline' 'nonce-...'` |
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-<per-request>' 'strict-dynamic'; style-src 'self' 'nonce-<per-request>'` |
| `X-Frame-Options` | `DENY` |
| `X-Content-Type-Options` | `nosniff` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` |

View File

@ -9,20 +9,8 @@ const nextConfig = {
transpilePackages: ['react-markdown', 'remark-gfm'],
// Security headers
// Content-Security-Policy is set in src/proxy.ts with a per-request nonce.
async headers() {
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
const csp = [
`default-src 'self'`,
`script-src 'self' 'unsafe-inline' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
`style-src 'self' 'unsafe-inline'`,
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
`font-src 'self' data:`,
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
`worker-src 'self' blob:`,
].join('; ')
return [
{
source: '/:path*',
@ -30,7 +18,6 @@ const nextConfig = {
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Content-Security-Policy', value: csp },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
...(process.env.MC_ENABLE_HSTS === '1' ? [
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }

View File

@ -58,30 +58,57 @@ export async function GET(request: NextRequest) {
config: enrichAgentConfigFromWorkspace(agent.config ? JSON.parse(agent.config) : {})
}));
// Get task counts for each agent (prepare once, reuse per agent)
const taskCountStmt = db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) as assigned,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'quality_review' THEN 1 ELSE 0 END) as quality_review,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done
FROM tasks
WHERE assigned_to = ? AND workspace_id = ?
`);
// Get task counts for all listed agents in one query (avoids N+1 queries)
const agentNames = agentsWithParsedData.map(agent => agent.name).filter(Boolean)
const taskStatsByAgent = new Map<string, { total: number; assigned: number; in_progress: number; quality_review: number; done: number }>()
if (agentNames.length > 0) {
const placeholders = agentNames.map(() => '?').join(', ')
const groupedTaskStats = db.prepare(`
SELECT
assigned_to,
COUNT(*) as total,
SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) as assigned,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
SUM(CASE WHEN status = 'quality_review' THEN 1 ELSE 0 END) as quality_review,
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done
FROM tasks
WHERE workspace_id = ? AND assigned_to IN (${placeholders})
GROUP BY assigned_to
`).all(workspaceId, ...agentNames) as Array<{
assigned_to: string
total: number | null
assigned: number | null
in_progress: number | null
quality_review: number | null
done: number | null
}>
for (const row of groupedTaskStats) {
taskStatsByAgent.set(row.assigned_to, {
total: row.total || 0,
assigned: row.assigned || 0,
in_progress: row.in_progress || 0,
quality_review: row.quality_review || 0,
done: row.done || 0,
})
}
}
const agentsWithStats = agentsWithParsedData.map(agent => {
const taskStats = taskCountStmt.get(agent.name, workspaceId) as any;
const taskStats = taskStatsByAgent.get(agent.name) || {
total: 0,
assigned: 0,
in_progress: 0,
quality_review: 0,
done: 0,
}
return {
...agent,
taskStats: {
total: taskStats.total || 0,
assigned: taskStats.assigned || 0,
in_progress: taskStats.in_progress || 0,
quality_review: taskStats.quality_review || 0,
done: taskStats.done || 0,
completed: taskStats.done || 0
...taskStats,
completed: taskStats.done,
}
};
});

View File

@ -1004,10 +1004,11 @@ function ContextSwitcher({ currentUser, isAdmin, isLocal, isConnected, tenants,
{!createMode ? (
<Button
variant="ghost"
onClick={() => { setCreateMode(true); setCreateError(null) }}
className="w-full flex items-center gap-2 px-2 py-1.5 h-auto rounded-md text-xs justify-start"
disabled
title="Temporarily disabled — not functional yet"
className="w-full flex items-center gap-2 px-2 py-1.5 h-auto rounded-md text-xs justify-start text-muted-foreground/40 cursor-not-allowed"
>
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground/60">
<div className="w-5 h-5 flex items-center justify-center text-muted-foreground/40">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="w-3.5 h-3.5">
<path d="M8 3v10M3 8h10" />
</svg>

View File

@ -74,7 +74,7 @@ export function SkillsPanel() {
const [createError, setCreateError] = useState<string | null>(null)
const [isMounted, setIsMounted] = useState(false)
const [activeTab, setActiveTab] = useState<PanelTab>('installed')
const [registrySource, setRegistrySource] = useState<'clawhub' | 'skills-sh' | 'awesome-openclaw'>('clawhub')
const [registrySource, setRegistrySource] = useState<'clawhub' | 'skills-sh' | 'awesome-openclaw'>('awesome-openclaw')
const [registryQuery, setRegistryQuery] = useState('')
const [registryResults, setRegistryResults] = useState<RegistrySkill[]>([])
const [registryLoading, setRegistryLoading] = useState(false)

View File

@ -351,29 +351,30 @@ export function TaskBoardPanel() {
const tasksList = tasksData.tasks || []
const taskIds = tasksList.map((task: Task) => task.id)
let newAegisMap: Record<number, boolean> = {}
// Render primary board data first; hydrate Aegis approvals in background.
storeSetTasks(tasksList)
setAgents(agentsData.agents || [])
setProjects(projectsData.projects || [])
if (taskIds.length > 0) {
try {
const reviewResponse = await fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`)
if (reviewResponse.ok) {
const reviewData = await reviewResponse.json()
const latest = reviewData.latest || {}
newAegisMap = Object.fromEntries(
fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`)
.then((reviewResponse) => reviewResponse.ok ? reviewResponse.json() : null)
.then((reviewData) => {
const latest = reviewData?.latest || {}
const newAegisMap: Record<number, boolean> = Object.fromEntries(
Object.entries(latest).map(([id, row]: [string, any]) => [
Number(id),
row?.reviewer === 'aegis' && row?.status === 'approved'
])
)
}
} catch {
newAegisMap = {}
}
setAegisMap(newAegisMap)
})
.catch(() => {
setAegisMap({})
})
} else {
setAegisMap({})
}
storeSetTasks(tasksList)
setAegisMap(newAegisMap)
setAgents(agentsData.agents || [])
setProjects(projectsData.projects || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {

View File

@ -7,6 +7,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import Database from 'better-sqlite3'
import { config } from './config'
import { logger } from './logger'
@ -50,9 +51,31 @@ function getHermesPidPath(): string {
return join(config.homeDir, '.hermes', 'gateway.pid')
}
let hermesBinaryCache: { checkedAt: number; installed: boolean } | null = null
function hasHermesCliBinary(): boolean {
const now = Date.now()
if (hermesBinaryCache && now - hermesBinaryCache.checkedAt < 30_000) {
return hermesBinaryCache.installed
}
const candidates = [process.env.HERMES_BIN, 'hermes-agent', 'hermes'].filter((v): v is string => Boolean(v && v.trim()))
const installed = candidates.some((bin) => {
try {
const res = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 1200 })
return res.status === 0
} catch {
return false
}
})
hermesBinaryCache = { checkedAt: now, installed }
return installed
}
export function isHermesInstalled(): boolean {
// Check for state.db (created after first agent run) OR config.yaml (created by hermes setup)
return existsSync(getHermesDbPath()) || existsSync(join(config.homeDir, '.hermes', 'config.yaml'))
// Strict detection: show Hermes UI only when Hermes CLI is actually installed on this system.
return hasHermesCliBinary()
}
export function isHermesGatewayRunning(): boolean {

View File

@ -257,56 +257,89 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise
}
async function searchClawdHub(query: string): Promise<RegistrySearchResult> {
try {
const url = `${CLAWHUB_API}/skills/search?q=${encodeURIComponent(query)}`
const res = await fetchWithTimeout(url)
if (!res.ok) {
logger.warn({ status: res.status }, 'ClawdHub search failed')
return { skills: [], total: 0, source: 'clawhub' }
// ClawdHub current API: /api/search?q=... (legacy /skills/search now 404s)
const urls = [
`${CLAWHUB_API}/search?q=${encodeURIComponent(query)}`,
`${CLAWHUB_API}/search?query=${encodeURIComponent(query)}`,
`${CLAWHUB_API}/skills/search?q=${encodeURIComponent(query)}`,
]
for (const url of urls) {
try {
const res = await fetchWithTimeout(url)
if (!res.ok) {
logger.warn({ status: res.status, url }, 'ClawdHub search request failed')
continue
}
const data = await res.json() as any
const rows = data?.results || data?.skills || []
const skills: RegistrySkill[] = rows.map((s: any) => ({
slug: s.slug || s.id || s.name,
name: s.displayName || s.name || s.slug,
description: s.summary || s.description || '',
author: s.author || s.owner || 'unknown',
version: s.version || s.latest_version || 'latest',
source: 'clawhub' as const,
installCount: s.installs || s.install_count,
tags: s.tags,
hash: s.hash || s.sha256,
}))
if (skills.length > 0) {
return { skills, total: data?.total || skills.length, source: 'clawhub' }
}
} catch (err: any) {
logger.warn({ err: err.message, url }, 'ClawdHub search error')
}
const data = await res.json() as any
const skills: RegistrySkill[] = (data?.results || data?.skills || []).map((s: any) => ({
slug: s.slug || s.id || s.name,
name: s.name || s.slug,
description: s.description || '',
author: s.author || s.owner || 'unknown',
version: s.version || s.latest_version || '0.0.0',
source: 'clawhub' as const,
installCount: s.installs || s.install_count,
tags: s.tags,
hash: s.hash || s.sha256,
}))
return { skills, total: data?.total || skills.length, source: 'clawhub' }
} catch (err: any) {
logger.warn({ err: err.message }, 'ClawdHub search error')
return { skills: [], total: 0, source: 'clawhub' }
}
return { skills: [], total: 0, source: 'clawhub' }
}
async function searchSkillsSh(query: string): Promise<RegistrySearchResult> {
try {
const url = `${SKILLS_SH_API}/skills?q=${encodeURIComponent(query)}`
const res = await fetchWithTimeout(url)
if (!res.ok) {
logger.warn({ status: res.status }, 'skills.sh search failed')
return { skills: [], total: 0, source: 'skills-sh' }
// skills.sh current API: /api/search?q=... (legacy /skills endpoint now 404s)
const urls = [
`${SKILLS_SH_API}/search?q=${encodeURIComponent(query)}`,
`${SKILLS_SH_API}/search?query=${encodeURIComponent(query)}`,
`${SKILLS_SH_API}/skills?q=${encodeURIComponent(query)}`,
]
for (const url of urls) {
try {
const res = await fetchWithTimeout(url)
if (!res.ok) {
logger.warn({ status: res.status, url }, 'skills.sh search request failed')
continue
}
const data = await res.json() as any
const rows = data?.skills || data?.results || []
const skills: RegistrySkill[] = rows.map((s: any) => {
const source = typeof s.source === 'string' ? s.source : 'unknown'
const slug = s.slug || s.id || (source && s.skillId ? `${source}/${s.skillId}` : s.name)
return {
slug,
name: s.name || s.skillId || s.slug || 'unnamed-skill',
description: s.description || s.summary || '',
author: s.owner || s.author || (source.includes('/') ? source.split('/')[0] : source),
version: s.version || 'latest',
source: 'skills-sh' as const,
installCount: s.installs || s.install_count,
tags: s.tags,
url: s.url,
}
})
if (skills.length > 0) {
return { skills, total: data?.total || data?.count || skills.length, source: 'skills-sh' }
}
} catch (err: any) {
logger.warn({ err: err.message, url }, 'skills.sh search error')
}
const data = await res.json() as any
const skills: RegistrySkill[] = (data?.skills || data?.results || []).map((s: any) => ({
slug: s.slug || `${s.owner}/${s.name}` || s.id,
name: s.name || s.slug,
description: s.description || '',
author: s.owner || s.author || 'unknown',
version: s.version || 'latest',
source: 'skills-sh' as const,
installCount: s.installs || s.install_count,
tags: s.tags,
}))
return { skills, total: data?.total || skills.length, source: 'skills-sh' }
} catch (err: any) {
logger.warn({ err: err.message }, 'skills.sh search error')
return { skills: [], total: 0, source: 'skills-sh' }
}
return { skills: [], total: 0, source: 'skills-sh' }
}
export async function searchRegistry(source: RegistrySource, query: string): Promise<RegistrySearchResult> {

View File

@ -82,7 +82,35 @@ function hostMatches(pattern: string, hostname: string): boolean {
return h === p
}
function addSecurityHeaders(response: NextResponse, request: NextRequest): NextResponse {
function buildCsp(nonce: string, googleEnabled: boolean): string {
return [
`default-src 'self'`,
`base-uri 'self'`,
`object-src 'none'`,
`frame-ancestors 'none'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
`style-src 'self' 'nonce-${nonce}'`,
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
`font-src 'self' data:`,
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
`worker-src 'self' blob:`,
].join('; ')
}
function nextResponseWithNonce(request: NextRequest): { response: NextResponse; nonce: string } {
const nonce = crypto.randomBytes(16).toString('base64')
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
})
return { response, nonce }
}
function addSecurityHeaders(response: NextResponse, _request: NextRequest, nonce?: string): NextResponse {
const requestId = crypto.randomUUID()
response.headers.set('X-Request-Id', requestId)
response.headers.set('X-Content-Type-Options', 'nosniff')
@ -90,17 +118,8 @@ function addSecurityHeaders(response: NextResponse, request: NextRequest): NextR
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
const csp = [
`default-src 'self'`,
`script-src 'self' 'unsafe-inline' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
`style-src 'self' 'unsafe-inline'`,
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
`font-src 'self' data:`,
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
`worker-src 'self' blob:`,
].join('; ')
response.headers.set('Content-Security-Policy', csp)
const effectiveNonce = nonce || crypto.randomBytes(16).toString('base64')
response.headers.set('Content-Security-Policy', buildCsp(effectiveNonce, googleEnabled))
return response
}
@ -164,7 +183,8 @@ export function proxy(request: NextRequest) {
// Allow login page, auth API, and docs without session
if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') {
return addSecurityHeaders(NextResponse.next(), request)
const { response, nonce } = nextResponseWithNonce(request)
return addSecurityHeaders(response, request, nonce)
}
// Check for session cookie
@ -181,7 +201,8 @@ export function proxy(request: NextRequest) {
const looksLikeAgentApiKey = /^mca_[a-f0-9]{48}$/i.test(apiKey)
if (sessionToken || hasValidApiKey || looksLikeAgentApiKey) {
return addSecurityHeaders(NextResponse.next(), request)
const { response, nonce } = nextResponseWithNonce(request)
return addSecurityHeaders(response, request, nonce)
}
return addSecurityHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), request)
@ -189,7 +210,8 @@ export function proxy(request: NextRequest) {
// Page routes: redirect to login if no session
if (sessionToken) {
return addSecurityHeaders(NextResponse.next(), request)
const { response, nonce } = nextResponseWithNonce(request)
return addSecurityHeaders(response, request, nonce)
}
// Redirect to login