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:
parent
84e197b3dc
commit
44aaf150c2
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
52
src/proxy.ts
52
src/proxy.ts
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue