nyk commited on
Commit
a6b2e2f
·
unverified ·
1 Parent(s): cd054b5

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

Browse files

* 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

README.md CHANGED
@@ -130,7 +130,7 @@ bash scripts/security-audit.sh
130
 
131
  ### Known Limitations
132
 
133
- - **CSP still includes `unsafe-inline`** `unsafe-eval` has been removed, but inline styles remain for framework compatibility
134
 
135
  ### Security Considerations
136
 
 
130
 
131
  ### Known Limitations
132
 
133
+ - No major security limitations currently tracked here for CSP; policy now uses per-request nonces (no `unsafe-inline` / `unsafe-eval`).
134
 
135
  ### Security Considerations
136
 
docs/SECURITY-HARDENING.md CHANGED
@@ -105,7 +105,7 @@ Mission Control sets these headers automatically:
105
 
106
  | Header | Value |
107
  |--------|-------|
108
- | `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'unsafe-inline' 'nonce-...'` |
109
  | `X-Frame-Options` | `DENY` |
110
  | `X-Content-Type-Options` | `nosniff` |
111
  | `Referrer-Policy` | `strict-origin-when-cross-origin` |
 
105
 
106
  | Header | Value |
107
  |--------|-------|
108
+ | `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'nonce-<per-request>' 'strict-dynamic'; style-src 'self' 'nonce-<per-request>'` |
109
  | `X-Frame-Options` | `DENY` |
110
  | `X-Content-Type-Options` | `nosniff` |
111
  | `Referrer-Policy` | `strict-origin-when-cross-origin` |
next.config.js CHANGED
@@ -9,20 +9,8 @@ const nextConfig = {
9
  transpilePackages: ['react-markdown', 'remark-gfm'],
10
 
11
  // Security headers
 
12
  async headers() {
13
- const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
14
-
15
- const csp = [
16
- `default-src 'self'`,
17
- `script-src 'self' 'unsafe-inline' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
18
- `style-src 'self' 'unsafe-inline'`,
19
- `connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
20
- `img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
21
- `font-src 'self' data:`,
22
- `frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
23
- `worker-src 'self' blob:`,
24
- ].join('; ')
25
-
26
  return [
27
  {
28
  source: '/:path*',
@@ -30,7 +18,6 @@ const nextConfig = {
30
  { key: 'X-Frame-Options', value: 'DENY' },
31
  { key: 'X-Content-Type-Options', value: 'nosniff' },
32
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
33
- { key: 'Content-Security-Policy', value: csp },
34
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
35
  ...(process.env.MC_ENABLE_HSTS === '1' ? [
36
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
 
9
  transpilePackages: ['react-markdown', 'remark-gfm'],
10
 
11
  // Security headers
12
+ // Content-Security-Policy is set in src/proxy.ts with a per-request nonce.
13
  async headers() {
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  return [
15
  {
16
  source: '/:path*',
 
18
  { key: 'X-Frame-Options', value: 'DENY' },
19
  { key: 'X-Content-Type-Options', value: 'nosniff' },
20
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
 
21
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
22
  ...(process.env.MC_ENABLE_HSTS === '1' ? [
23
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }
src/app/api/agents/route.ts CHANGED
@@ -58,30 +58,57 @@ export async function GET(request: NextRequest) {
58
  config: enrichAgentConfigFromWorkspace(agent.config ? JSON.parse(agent.config) : {})
59
  }));
60
 
61
- // Get task counts for each agent (prepare once, reuse per agent)
62
- const taskCountStmt = db.prepare(`
63
- SELECT
64
- COUNT(*) as total,
65
- SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) as assigned,
66
- SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
67
- SUM(CASE WHEN status = 'quality_review' THEN 1 ELSE 0 END) as quality_review,
68
- SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done
69
- FROM tasks
70
- WHERE assigned_to = ? AND workspace_id = ?
71
- `);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  const agentsWithStats = agentsWithParsedData.map(agent => {
74
- const taskStats = taskCountStmt.get(agent.name, workspaceId) as any;
 
 
 
 
 
 
75
 
76
  return {
77
  ...agent,
78
  taskStats: {
79
- total: taskStats.total || 0,
80
- assigned: taskStats.assigned || 0,
81
- in_progress: taskStats.in_progress || 0,
82
- quality_review: taskStats.quality_review || 0,
83
- done: taskStats.done || 0,
84
- completed: taskStats.done || 0
85
  }
86
  };
87
  });
 
58
  config: enrichAgentConfigFromWorkspace(agent.config ? JSON.parse(agent.config) : {})
59
  }));
60
 
61
+ // Get task counts for all listed agents in one query (avoids N+1 queries)
62
+ const agentNames = agentsWithParsedData.map(agent => agent.name).filter(Boolean)
63
+ const taskStatsByAgent = new Map<string, { total: number; assigned: number; in_progress: number; quality_review: number; done: number }>()
64
+
65
+ if (agentNames.length > 0) {
66
+ const placeholders = agentNames.map(() => '?').join(', ')
67
+ const groupedTaskStats = db.prepare(`
68
+ SELECT
69
+ assigned_to,
70
+ COUNT(*) as total,
71
+ SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) as assigned,
72
+ SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
73
+ SUM(CASE WHEN status = 'quality_review' THEN 1 ELSE 0 END) as quality_review,
74
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done
75
+ FROM tasks
76
+ WHERE workspace_id = ? AND assigned_to IN (${placeholders})
77
+ GROUP BY assigned_to
78
+ `).all(workspaceId, ...agentNames) as Array<{
79
+ assigned_to: string
80
+ total: number | null
81
+ assigned: number | null
82
+ in_progress: number | null
83
+ quality_review: number | null
84
+ done: number | null
85
+ }>
86
+
87
+ for (const row of groupedTaskStats) {
88
+ taskStatsByAgent.set(row.assigned_to, {
89
+ total: row.total || 0,
90
+ assigned: row.assigned || 0,
91
+ in_progress: row.in_progress || 0,
92
+ quality_review: row.quality_review || 0,
93
+ done: row.done || 0,
94
+ })
95
+ }
96
+ }
97
 
98
  const agentsWithStats = agentsWithParsedData.map(agent => {
99
+ const taskStats = taskStatsByAgent.get(agent.name) || {
100
+ total: 0,
101
+ assigned: 0,
102
+ in_progress: 0,
103
+ quality_review: 0,
104
+ done: 0,
105
+ }
106
 
107
  return {
108
  ...agent,
109
  taskStats: {
110
+ ...taskStats,
111
+ completed: taskStats.done,
 
 
 
 
112
  }
113
  };
114
  });
src/components/layout/nav-rail.tsx CHANGED
@@ -1004,10 +1004,11 @@ function ContextSwitcher({ currentUser, isAdmin, isLocal, isConnected, tenants,
1004
  {!createMode ? (
1005
  <Button
1006
  variant="ghost"
1007
- onClick={() => { setCreateMode(true); setCreateError(null) }}
1008
- className="w-full flex items-center gap-2 px-2 py-1.5 h-auto rounded-md text-xs justify-start"
 
1009
  >
1010
- <div className="w-5 h-5 flex items-center justify-center text-muted-foreground/60">
1011
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="w-3.5 h-3.5">
1012
  <path d="M8 3v10M3 8h10" />
1013
  </svg>
 
1004
  {!createMode ? (
1005
  <Button
1006
  variant="ghost"
1007
+ disabled
1008
+ title="Temporarily disabled not functional yet"
1009
+ 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"
1010
  >
1011
+ <div className="w-5 h-5 flex items-center justify-center text-muted-foreground/40">
1012
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="w-3.5 h-3.5">
1013
  <path d="M8 3v10M3 8h10" />
1014
  </svg>
src/components/panels/skills-panel.tsx CHANGED
@@ -74,7 +74,7 @@ export function SkillsPanel() {
74
  const [createError, setCreateError] = useState<string | null>(null)
75
  const [isMounted, setIsMounted] = useState(false)
76
  const [activeTab, setActiveTab] = useState<PanelTab>('installed')
77
- const [registrySource, setRegistrySource] = useState<'clawhub' | 'skills-sh' | 'awesome-openclaw'>('clawhub')
78
  const [registryQuery, setRegistryQuery] = useState('')
79
  const [registryResults, setRegistryResults] = useState<RegistrySkill[]>([])
80
  const [registryLoading, setRegistryLoading] = useState(false)
 
74
  const [createError, setCreateError] = useState<string | null>(null)
75
  const [isMounted, setIsMounted] = useState(false)
76
  const [activeTab, setActiveTab] = useState<PanelTab>('installed')
77
+ const [registrySource, setRegistrySource] = useState<'clawhub' | 'skills-sh' | 'awesome-openclaw'>('awesome-openclaw')
78
  const [registryQuery, setRegistryQuery] = useState('')
79
  const [registryResults, setRegistryResults] = useState<RegistrySkill[]>([])
80
  const [registryLoading, setRegistryLoading] = useState(false)
src/components/panels/task-board-panel.tsx CHANGED
@@ -351,29 +351,30 @@ export function TaskBoardPanel() {
351
  const tasksList = tasksData.tasks || []
352
  const taskIds = tasksList.map((task: Task) => task.id)
353
 
354
- let newAegisMap: Record<number, boolean> = {}
 
 
 
 
355
  if (taskIds.length > 0) {
356
- try {
357
- const reviewResponse = await fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`)
358
- if (reviewResponse.ok) {
359
- const reviewData = await reviewResponse.json()
360
- const latest = reviewData.latest || {}
361
- newAegisMap = Object.fromEntries(
362
  Object.entries(latest).map(([id, row]: [string, any]) => [
363
  Number(id),
364
  row?.reviewer === 'aegis' && row?.status === 'approved'
365
  ])
366
  )
367
- }
368
- } catch {
369
- newAegisMap = {}
370
- }
 
 
 
371
  }
372
-
373
- storeSetTasks(tasksList)
374
- setAegisMap(newAegisMap)
375
- setAgents(agentsData.agents || [])
376
- setProjects(projectsData.projects || [])
377
  } catch (err) {
378
  setError(err instanceof Error ? err.message : 'An error occurred')
379
  } finally {
 
351
  const tasksList = tasksData.tasks || []
352
  const taskIds = tasksList.map((task: Task) => task.id)
353
 
354
+ // Render primary board data first; hydrate Aegis approvals in background.
355
+ storeSetTasks(tasksList)
356
+ setAgents(agentsData.agents || [])
357
+ setProjects(projectsData.projects || [])
358
+
359
  if (taskIds.length > 0) {
360
+ fetch(`/api/quality-review?taskIds=${taskIds.join(',')}`)
361
+ .then((reviewResponse) => reviewResponse.ok ? reviewResponse.json() : null)
362
+ .then((reviewData) => {
363
+ const latest = reviewData?.latest || {}
364
+ const newAegisMap: Record<number, boolean> = Object.fromEntries(
 
365
  Object.entries(latest).map(([id, row]: [string, any]) => [
366
  Number(id),
367
  row?.reviewer === 'aegis' && row?.status === 'approved'
368
  ])
369
  )
370
+ setAegisMap(newAegisMap)
371
+ })
372
+ .catch(() => {
373
+ setAegisMap({})
374
+ })
375
+ } else {
376
+ setAegisMap({})
377
  }
 
 
 
 
 
378
  } catch (err) {
379
  setError(err instanceof Error ? err.message : 'An error occurred')
380
  } finally {
src/lib/hermes-sessions.ts CHANGED
@@ -7,6 +7,7 @@
7
 
8
  import { existsSync, readFileSync } from 'node:fs'
9
  import { join } from 'node:path'
 
10
  import Database from 'better-sqlite3'
11
  import { config } from './config'
12
  import { logger } from './logger'
@@ -50,9 +51,31 @@ function getHermesPidPath(): string {
50
  return join(config.homeDir, '.hermes', 'gateway.pid')
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  export function isHermesInstalled(): boolean {
54
- // Check for state.db (created after first agent run) OR config.yaml (created by hermes setup)
55
- return existsSync(getHermesDbPath()) || existsSync(join(config.homeDir, '.hermes', 'config.yaml'))
56
  }
57
 
58
  export function isHermesGatewayRunning(): boolean {
 
7
 
8
  import { existsSync, readFileSync } from 'node:fs'
9
  import { join } from 'node:path'
10
+ import { spawnSync } from 'node:child_process'
11
  import Database from 'better-sqlite3'
12
  import { config } from './config'
13
  import { logger } from './logger'
 
51
  return join(config.homeDir, '.hermes', 'gateway.pid')
52
  }
53
 
54
+ let hermesBinaryCache: { checkedAt: number; installed: boolean } | null = null
55
+
56
+ function hasHermesCliBinary(): boolean {
57
+ const now = Date.now()
58
+ if (hermesBinaryCache && now - hermesBinaryCache.checkedAt < 30_000) {
59
+ return hermesBinaryCache.installed
60
+ }
61
+
62
+ const candidates = [process.env.HERMES_BIN, 'hermes-agent', 'hermes'].filter((v): v is string => Boolean(v && v.trim()))
63
+ const installed = candidates.some((bin) => {
64
+ try {
65
+ const res = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 1200 })
66
+ return res.status === 0
67
+ } catch {
68
+ return false
69
+ }
70
+ })
71
+
72
+ hermesBinaryCache = { checkedAt: now, installed }
73
+ return installed
74
+ }
75
+
76
  export function isHermesInstalled(): boolean {
77
+ // Strict detection: show Hermes UI only when Hermes CLI is actually installed on this system.
78
+ return hasHermesCliBinary()
79
  }
80
 
81
  export function isHermesGatewayRunning(): boolean {
src/lib/skill-registry.ts CHANGED
@@ -257,56 +257,89 @@ async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise
257
  }
258
 
259
  async function searchClawdHub(query: string): Promise<RegistrySearchResult> {
260
- try {
261
- const url = `${CLAWHUB_API}/skills/search?q=${encodeURIComponent(query)}`
262
- const res = await fetchWithTimeout(url)
263
- if (!res.ok) {
264
- logger.warn({ status: res.status }, 'ClawdHub search failed')
265
- return { skills: [], total: 0, source: 'clawhub' }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  }
267
- const data = await res.json() as any
268
- const skills: RegistrySkill[] = (data?.results || data?.skills || []).map((s: any) => ({
269
- slug: s.slug || s.id || s.name,
270
- name: s.name || s.slug,
271
- description: s.description || '',
272
- author: s.author || s.owner || 'unknown',
273
- version: s.version || s.latest_version || '0.0.0',
274
- source: 'clawhub' as const,
275
- installCount: s.installs || s.install_count,
276
- tags: s.tags,
277
- hash: s.hash || s.sha256,
278
- }))
279
- return { skills, total: data?.total || skills.length, source: 'clawhub' }
280
- } catch (err: any) {
281
- logger.warn({ err: err.message }, 'ClawdHub search error')
282
- return { skills: [], total: 0, source: 'clawhub' }
283
  }
 
 
284
  }
285
 
286
  async function searchSkillsSh(query: string): Promise<RegistrySearchResult> {
287
- try {
288
- const url = `${SKILLS_SH_API}/skills?q=${encodeURIComponent(query)}`
289
- const res = await fetchWithTimeout(url)
290
- if (!res.ok) {
291
- logger.warn({ status: res.status }, 'skills.sh search failed')
292
- return { skills: [], total: 0, source: 'skills-sh' }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  }
294
- const data = await res.json() as any
295
- const skills: RegistrySkill[] = (data?.skills || data?.results || []).map((s: any) => ({
296
- slug: s.slug || `${s.owner}/${s.name}` || s.id,
297
- name: s.name || s.slug,
298
- description: s.description || '',
299
- author: s.owner || s.author || 'unknown',
300
- version: s.version || 'latest',
301
- source: 'skills-sh' as const,
302
- installCount: s.installs || s.install_count,
303
- tags: s.tags,
304
- }))
305
- return { skills, total: data?.total || skills.length, source: 'skills-sh' }
306
- } catch (err: any) {
307
- logger.warn({ err: err.message }, 'skills.sh search error')
308
- return { skills: [], total: 0, source: 'skills-sh' }
309
  }
 
 
310
  }
311
 
312
  export async function searchRegistry(source: RegistrySource, query: string): Promise<RegistrySearchResult> {
 
257
  }
258
 
259
  async function searchClawdHub(query: string): Promise<RegistrySearchResult> {
260
+ // ClawdHub current API: /api/search?q=... (legacy /skills/search now 404s)
261
+ const urls = [
262
+ `${CLAWHUB_API}/search?q=${encodeURIComponent(query)}`,
263
+ `${CLAWHUB_API}/search?query=${encodeURIComponent(query)}`,
264
+ `${CLAWHUB_API}/skills/search?q=${encodeURIComponent(query)}`,
265
+ ]
266
+
267
+ for (const url of urls) {
268
+ try {
269
+ const res = await fetchWithTimeout(url)
270
+ if (!res.ok) {
271
+ logger.warn({ status: res.status, url }, 'ClawdHub search request failed')
272
+ continue
273
+ }
274
+
275
+ const data = await res.json() as any
276
+ const rows = data?.results || data?.skills || []
277
+ const skills: RegistrySkill[] = rows.map((s: any) => ({
278
+ slug: s.slug || s.id || s.name,
279
+ name: s.displayName || s.name || s.slug,
280
+ description: s.summary || s.description || '',
281
+ author: s.author || s.owner || 'unknown',
282
+ version: s.version || s.latest_version || 'latest',
283
+ source: 'clawhub' as const,
284
+ installCount: s.installs || s.install_count,
285
+ tags: s.tags,
286
+ hash: s.hash || s.sha256,
287
+ }))
288
+
289
+ if (skills.length > 0) {
290
+ return { skills, total: data?.total || skills.length, source: 'clawhub' }
291
+ }
292
+ } catch (err: any) {
293
+ logger.warn({ err: err.message, url }, 'ClawdHub search error')
294
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  }
296
+
297
+ return { skills: [], total: 0, source: 'clawhub' }
298
  }
299
 
300
  async function searchSkillsSh(query: string): Promise<RegistrySearchResult> {
301
+ // skills.sh current API: /api/search?q=... (legacy /skills endpoint now 404s)
302
+ const urls = [
303
+ `${SKILLS_SH_API}/search?q=${encodeURIComponent(query)}`,
304
+ `${SKILLS_SH_API}/search?query=${encodeURIComponent(query)}`,
305
+ `${SKILLS_SH_API}/skills?q=${encodeURIComponent(query)}`,
306
+ ]
307
+
308
+ for (const url of urls) {
309
+ try {
310
+ const res = await fetchWithTimeout(url)
311
+ if (!res.ok) {
312
+ logger.warn({ status: res.status, url }, 'skills.sh search request failed')
313
+ continue
314
+ }
315
+
316
+ const data = await res.json() as any
317
+ const rows = data?.skills || data?.results || []
318
+ const skills: RegistrySkill[] = rows.map((s: any) => {
319
+ const source = typeof s.source === 'string' ? s.source : 'unknown'
320
+ const slug = s.slug || s.id || (source && s.skillId ? `${source}/${s.skillId}` : s.name)
321
+ return {
322
+ slug,
323
+ name: s.name || s.skillId || s.slug || 'unnamed-skill',
324
+ description: s.description || s.summary || '',
325
+ author: s.owner || s.author || (source.includes('/') ? source.split('/')[0] : source),
326
+ version: s.version || 'latest',
327
+ source: 'skills-sh' as const,
328
+ installCount: s.installs || s.install_count,
329
+ tags: s.tags,
330
+ url: s.url,
331
+ }
332
+ })
333
+
334
+ if (skills.length > 0) {
335
+ return { skills, total: data?.total || data?.count || skills.length, source: 'skills-sh' }
336
+ }
337
+ } catch (err: any) {
338
+ logger.warn({ err: err.message, url }, 'skills.sh search error')
339
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  }
341
+
342
+ return { skills: [], total: 0, source: 'skills-sh' }
343
  }
344
 
345
  export async function searchRegistry(source: RegistrySource, query: string): Promise<RegistrySearchResult> {
src/proxy.ts CHANGED
@@ -82,25 +82,44 @@ function hostMatches(pattern: string, hostname: string): boolean {
82
  return h === p
83
  }
84
 
85
- function addSecurityHeaders(response: NextResponse, request: NextRequest): NextResponse {
86
- const requestId = crypto.randomUUID()
87
- response.headers.set('X-Request-Id', requestId)
88
- response.headers.set('X-Content-Type-Options', 'nosniff')
89
- response.headers.set('X-Frame-Options', 'DENY')
90
- response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
91
-
92
- const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
93
- const csp = [
94
  `default-src 'self'`,
95
- `script-src 'self' 'unsafe-inline' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
96
- `style-src 'self' 'unsafe-inline'`,
 
 
 
97
  `connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
98
  `img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
99
  `font-src 'self' data:`,
100
  `frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
101
  `worker-src 'self' blob:`,
102
  ].join('; ')
103
- response.headers.set('Content-Security-Policy', csp)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  return response
106
  }
@@ -164,7 +183,8 @@ export function proxy(request: NextRequest) {
164
 
165
  // Allow login page, auth API, and docs without session
166
  if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') {
167
- return addSecurityHeaders(NextResponse.next(), request)
 
168
  }
169
 
170
  // Check for session cookie
@@ -181,7 +201,8 @@ export function proxy(request: NextRequest) {
181
  const looksLikeAgentApiKey = /^mca_[a-f0-9]{48}$/i.test(apiKey)
182
 
183
  if (sessionToken || hasValidApiKey || looksLikeAgentApiKey) {
184
- return addSecurityHeaders(NextResponse.next(), request)
 
185
  }
186
 
187
  return addSecurityHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), request)
@@ -189,7 +210,8 @@ export function proxy(request: NextRequest) {
189
 
190
  // Page routes: redirect to login if no session
191
  if (sessionToken) {
192
- return addSecurityHeaders(NextResponse.next(), request)
 
193
  }
194
 
195
  // Redirect to login
 
82
  return h === p
83
  }
84
 
85
+ function buildCsp(nonce: string, googleEnabled: boolean): string {
86
+ return [
 
 
 
 
 
 
 
87
  `default-src 'self'`,
88
+ `base-uri 'self'`,
89
+ `object-src 'none'`,
90
+ `frame-ancestors 'none'`,
91
+ `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
92
+ `style-src 'self' 'nonce-${nonce}'`,
93
  `connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
94
  `img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
95
  `font-src 'self' data:`,
96
  `frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
97
  `worker-src 'self' blob:`,
98
  ].join('; ')
99
+ }
100
+
101
+ function nextResponseWithNonce(request: NextRequest): { response: NextResponse; nonce: string } {
102
+ const nonce = crypto.randomBytes(16).toString('base64')
103
+ const requestHeaders = new Headers(request.headers)
104
+ requestHeaders.set('x-nonce', nonce)
105
+ const response = NextResponse.next({
106
+ request: {
107
+ headers: requestHeaders,
108
+ },
109
+ })
110
+ return { response, nonce }
111
+ }
112
+
113
+ function addSecurityHeaders(response: NextResponse, _request: NextRequest, nonce?: string): NextResponse {
114
+ const requestId = crypto.randomUUID()
115
+ response.headers.set('X-Request-Id', requestId)
116
+ response.headers.set('X-Content-Type-Options', 'nosniff')
117
+ response.headers.set('X-Frame-Options', 'DENY')
118
+ response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
119
+
120
+ const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
121
+ const effectiveNonce = nonce || crypto.randomBytes(16).toString('base64')
122
+ response.headers.set('Content-Security-Policy', buildCsp(effectiveNonce, googleEnabled))
123
 
124
  return response
125
  }
 
183
 
184
  // Allow login page, auth API, and docs without session
185
  if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs') {
186
+ const { response, nonce } = nextResponseWithNonce(request)
187
+ return addSecurityHeaders(response, request, nonce)
188
  }
189
 
190
  // Check for session cookie
 
201
  const looksLikeAgentApiKey = /^mca_[a-f0-9]{48}$/i.test(apiKey)
202
 
203
  if (sessionToken || hasValidApiKey || looksLikeAgentApiKey) {
204
+ const { response, nonce } = nextResponseWithNonce(request)
205
+ return addSecurityHeaders(response, request, nonce)
206
  }
207
 
208
  return addSecurityHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), request)
 
210
 
211
  // Page routes: redirect to login if no session
212
  if (sessionToken) {
213
+ const { response, nonce } = nextResponseWithNonce(request)
214
+ return addSecurityHeaders(response, request, nonce)
215
  }
216
 
217
  // Redirect to login