Spaces:
Runtime error
Runtime error
| import { NextRequest, NextResponse } from 'next/server' | |
| import { existsSync, readFileSync, writeFileSync, chmodSync, statSync } from 'node:fs' | |
| import { execFileSync } from 'node:child_process' | |
| import path from 'node:path' | |
| import crypto from 'node:crypto' | |
| import { requireRole } from '@/lib/auth' | |
| import { config } from '@/lib/config' | |
| import { getDatabase } from '@/lib/db' | |
| import { logger } from '@/lib/logger' | |
| import { FIX_SAFETY, runSecurityScan, type FixSafety } from '@/lib/security-scan' | |
| export interface FixResult { | |
| id: string | |
| name: string | |
| fixed: boolean | |
| detail: string | |
| fixSafety?: FixSafety | |
| } | |
| function shouldMutateRuntimeEnv() { | |
| return process.env.MISSION_CONTROL_TEST_MODE !== '1' | |
| } | |
| function normalizeHostname(raw: string): string { | |
| return raw.trim().replace(/^\[|\]$/g, '').split(':')[0].replace(/\.$/, '').toLowerCase() | |
| } | |
| function parseForwardedHost(forwarded: string | null): string[] { | |
| if (!forwarded) return [] | |
| const hosts: string[] = [] | |
| for (const part of forwarded.split(',')) { | |
| const match = /(?:^|;)\s*host="?([^";]+)"?/i.exec(part) | |
| if (match?.[1]) hosts.push(match[1]) | |
| } | |
| return hosts | |
| } | |
| function getRequestHostCandidates(request: NextRequest): string[] { | |
| const rawCandidates = [ | |
| ...(request.headers.get('x-forwarded-host') || '').split(','), | |
| ...(request.headers.get('x-original-host') || '').split(','), | |
| ...(request.headers.get('x-forwarded-server') || '').split(','), | |
| ...parseForwardedHost(request.headers.get('forwarded')), | |
| request.headers.get('host') || '', | |
| request.nextUrl.host || '', | |
| request.nextUrl.hostname || '', | |
| ] | |
| return [...new Set(rawCandidates.map(normalizeHostname).filter(Boolean))] | |
| } | |
| function getFailingChecks() { | |
| return Object.values(runSecurityScan().categories) | |
| .flatMap((category) => category.checks) | |
| .filter((check) => check.status !== 'pass') | |
| } | |
| export async function POST(request: NextRequest) { | |
| const auth = requireRole(request, 'admin') | |
| if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) | |
| // Optional: pass { ids: ["check_id"] } to fix only specific issues | |
| let targetIds: Set<string> | null = null | |
| try { | |
| const body = await request.json() | |
| if (Array.isArray(body?.ids) && body.ids.length > 0) { | |
| targetIds = new Set(body.ids as string[]) | |
| } | |
| } catch { /* no body = fix all */ } | |
| const shouldFix = (id: string) => !targetIds || targetIds.has(id) | |
| const results: FixResult[] = [] | |
| const envPaths = [ | |
| path.join(process.cwd(), '.env'), | |
| path.join(process.cwd(), '.env.local'), | |
| ] | |
| function readEnv(filePath: string): string { | |
| try { return readFileSync(filePath, 'utf-8') } catch { return '' } | |
| } | |
| function setEnvVar(key: string, value: string) { | |
| let targetPath = envPaths[0] | |
| for (const filePath of envPaths) { | |
| const content = readEnv(filePath) | |
| if (new RegExp(`^${key}=.*$`, 'm').test(content)) { | |
| targetPath = filePath | |
| break | |
| } | |
| } | |
| let content = readEnv(targetPath) | |
| const regex = new RegExp(`^${key}=.*$`, 'm') | |
| if (regex.test(content)) { | |
| content = content.replace(regex, `${key}=${value}`) | |
| } else { | |
| content = content.trimEnd() + `\n${key}=${value}\n` | |
| } | |
| writeFileSync(targetPath, content, 'utf-8') | |
| if (shouldMutateRuntimeEnv()) { | |
| process.env[key] = value | |
| } | |
| } | |
| function unsetEnvVar(key: string) { | |
| const regex = new RegExp(`^${key}=.*\n?`, 'm') | |
| for (const filePath of envPaths) { | |
| let content = readEnv(filePath) | |
| if (regex.test(content)) { | |
| content = content.replace(regex, '') | |
| writeFileSync(filePath, content, 'utf-8') | |
| } | |
| } | |
| if (shouldMutateRuntimeEnv()) { | |
| delete process.env[key] | |
| } | |
| } | |
| // 1. Fix .env file permissions | |
| const envPath = envPaths[0] | |
| if (shouldFix('env_permissions') && existsSync(envPath)) { | |
| try { | |
| const stat = statSync(envPath) | |
| const mode = (stat.mode & 0o777).toString(8) | |
| if (mode !== '600') { | |
| chmodSync(envPath, 0o600) | |
| results.push({ id: 'env_permissions', name: '.env file permissions', fixed: true, detail: `Changed from ${mode} to 600`, fixSafety: FIX_SAFETY['env_permissions'] }) | |
| } else { | |
| results.push({ id: 'env_permissions', name: '.env file permissions', fixed: true, detail: 'Already 600', fixSafety: FIX_SAFETY['env_permissions'] }) | |
| } | |
| } catch (e: any) { | |
| results.push({ id: 'env_permissions', name: '.env file permissions', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['env_permissions'] }) | |
| } | |
| } | |
| // 2. Fix MC_ALLOWED_HOSTS if not set | |
| const allowedHosts = (process.env.MC_ALLOWED_HOSTS || '').trim() | |
| const allowAny = process.env.MC_ALLOW_ANY_HOST | |
| if (shouldFix('allowed_hosts') && (!allowedHosts || allowAny === '1' || allowAny === 'true')) { | |
| try { | |
| if (allowAny) { | |
| unsetEnvVar('MC_ALLOW_ANY_HOST') | |
| } | |
| const preservedHosts = new Set([ | |
| 'localhost', | |
| '127.0.0.1', | |
| ...allowedHosts.split(',').map((host) => normalizeHostname(host)).filter(Boolean), | |
| ...getRequestHostCandidates(request), | |
| ]) | |
| const mergedHosts = Array.from(preservedHosts) | |
| setEnvVar('MC_ALLOWED_HOSTS', mergedHosts.join(',')) | |
| results.push({ id: 'allowed_hosts', name: 'Host allowlist', fixed: true, detail: `Set MC_ALLOWED_HOSTS=${mergedHosts.join(',')}`, fixSafety: FIX_SAFETY['allowed_hosts'] }) | |
| } catch (e: any) { | |
| results.push({ id: 'allowed_hosts', name: 'Host allowlist', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['allowed_hosts'] }) | |
| } | |
| } | |
| // 3. Fix MC_ENABLE_HSTS | |
| if (shouldFix('hsts_enabled') && process.env.MC_ENABLE_HSTS !== '1') { | |
| try { | |
| setEnvVar('MC_ENABLE_HSTS', '1') | |
| results.push({ id: 'hsts_enabled', name: 'HSTS enabled', fixed: true, detail: 'Set MC_ENABLE_HSTS=1', fixSafety: FIX_SAFETY['hsts_enabled'] }) | |
| } catch (e: any) { | |
| results.push({ id: 'hsts_enabled', name: 'HSTS', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['hsts_enabled'] }) | |
| } | |
| } | |
| // 4. Fix MC_COOKIE_SECURE | |
| const cookieSecure = process.env.MC_COOKIE_SECURE | |
| if (shouldFix('cookie_secure') && cookieSecure !== '1' && cookieSecure !== 'true') { | |
| try { | |
| setEnvVar('MC_COOKIE_SECURE', '1') | |
| results.push({ id: 'cookie_secure', name: 'Secure cookies', fixed: true, detail: 'Set MC_COOKIE_SECURE=1', fixSafety: FIX_SAFETY['cookie_secure'] }) | |
| } catch (e: any) { | |
| results.push({ id: 'cookie_secure', name: 'Secure cookies', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['cookie_secure'] }) | |
| } | |
| } | |
| // 4b. Re-enable runtime rate limiting | |
| const rateLimitDisabled = process.env.MC_DISABLE_RATE_LIMIT | |
| if (shouldFix('rate_limiting') && rateLimitDisabled) { | |
| try { | |
| unsetEnvVar('MC_DISABLE_RATE_LIMIT') | |
| results.push({ id: 'rate_limiting', name: 'Rate limiting active', fixed: true, detail: 'Removed MC_DISABLE_RATE_LIMIT', fixSafety: FIX_SAFETY['rate_limiting'] }) | |
| } catch (e: any) { | |
| results.push({ id: 'rate_limiting', name: 'Rate limiting active', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['rate_limiting'] }) | |
| } | |
| } | |
| // 5. Fix API_KEY if it's a known default | |
| const apiKey = process.env.API_KEY || '' | |
| if (shouldFix('api_key_set') && (!apiKey || apiKey === 'generate-a-random-key')) { | |
| try { | |
| const newKey = crypto.randomBytes(32).toString('hex') | |
| setEnvVar('API_KEY', newKey) | |
| results.push({ id: 'api_key_set', name: 'API key', fixed: true, detail: 'Generated new random API key', fixSafety: FIX_SAFETY['api_key_set'] }) | |
| } catch (e: any) { | |
| results.push({ id: 'api_key_set', name: 'API key', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['api_key_set'] }) | |
| } | |
| } | |
| // 6. Fix OpenClaw config | |
| const ocFixIds = ['config_permissions', 'gateway_auth', 'gateway_bind', 'elevated_disabled', 'dm_isolation', 'exec_restricted', 'control_ui_device_auth', 'control_ui_insecure_auth', 'fs_workspace_only', 'log_redaction'] | |
| const configPath = config.openclawConfigPath | |
| if (ocFixIds.some(id => shouldFix(id)) && configPath && existsSync(configPath)) { | |
| let ocConfig: any | |
| try { | |
| ocConfig = JSON.parse(readFileSync(configPath, 'utf-8')) | |
| } catch { ocConfig = null } | |
| if (ocConfig) { | |
| let configChanged = false | |
| // Fix config file permissions | |
| if (shouldFix('config_permissions')) try { | |
| const stat = statSync(configPath) | |
| const mode = (stat.mode & 0o777).toString(8) | |
| if (mode !== '600') { | |
| chmodSync(configPath, 0o600) | |
| results.push({ id: 'config_permissions', name: 'OpenClaw config permissions', fixed: true, detail: `Changed from ${mode} to 600`, fixSafety: FIX_SAFETY['config_permissions'] }) | |
| } | |
| } catch (e: any) { | |
| results.push({ id: 'config_permissions', name: 'OpenClaw config permissions', fixed: false, detail: e.message, fixSafety: FIX_SAFETY['config_permissions'] }) | |
| } | |
| // Fix gateway auth | |
| if (shouldFix('gateway_auth')) { | |
| if (!ocConfig.gateway) ocConfig.gateway = {} | |
| if (!ocConfig.gateway.auth) ocConfig.gateway.auth = {} | |
| if (ocConfig.gateway.auth.mode !== 'token') { | |
| ocConfig.gateway.auth.mode = 'token' | |
| if (!ocConfig.gateway.auth.token) { | |
| ocConfig.gateway.auth.token = crypto.randomBytes(32).toString('hex') | |
| } | |
| configChanged = true | |
| results.push({ id: 'gateway_auth', name: 'Gateway authentication', fixed: true, detail: 'Set auth.mode to "token" with generated token', fixSafety: FIX_SAFETY['gateway_auth'] }) | |
| } | |
| } | |
| // Fix gateway bind | |
| if (shouldFix('gateway_bind')) { | |
| if (!ocConfig.gateway) ocConfig.gateway = {} | |
| if (ocConfig.gateway.bind !== 'loopback' && ocConfig.gateway.bind !== '127.0.0.1') { | |
| ocConfig.gateway.bind = 'loopback' | |
| configChanged = true | |
| results.push({ id: 'gateway_bind', name: 'Gateway bind address', fixed: true, detail: 'Set bind to "loopback"', fixSafety: FIX_SAFETY['gateway_bind'] }) | |
| } | |
| } | |
| // Fix elevated mode | |
| if (shouldFix('elevated_disabled')) { | |
| if (!ocConfig.elevated) ocConfig.elevated = {} | |
| if (ocConfig.elevated.enabled === true) { | |
| ocConfig.elevated.enabled = false | |
| configChanged = true | |
| results.push({ id: 'elevated_disabled', name: 'Elevated mode', fixed: true, detail: 'Disabled elevated mode', fixSafety: FIX_SAFETY['elevated_disabled'] }) | |
| } | |
| } | |
| // Fix DM isolation | |
| if (shouldFix('dm_isolation')) { | |
| if (!ocConfig.session) ocConfig.session = {} | |
| if (ocConfig.session.dmScope !== 'per-channel-peer') { | |
| ocConfig.session.dmScope = 'per-channel-peer' | |
| configChanged = true | |
| results.push({ id: 'dm_isolation', name: 'DM session isolation', fixed: true, detail: 'Set dmScope to "per-channel-peer"', fixSafety: FIX_SAFETY['dm_isolation'] }) | |
| } | |
| } | |
| // Fix exec security | |
| if (shouldFix('exec_restricted')) { | |
| if (!ocConfig.tools) ocConfig.tools = {} | |
| if (!ocConfig.tools.exec) ocConfig.tools.exec = {} | |
| if (ocConfig.tools.exec.security !== 'allowlist' && ocConfig.tools.exec.security !== 'deny') { | |
| ocConfig.tools.exec.security = 'allowlist' | |
| configChanged = true | |
| results.push({ id: 'exec_restricted', name: 'Exec tool restriction', fixed: true, detail: 'Set exec security to "allowlist"', fixSafety: FIX_SAFETY['exec_restricted'] }) | |
| } | |
| } | |
| // Fix Control UI device auth | |
| if (shouldFix('control_ui_device_auth')) { | |
| if (ocConfig.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) { | |
| ocConfig.gateway.controlUi.dangerouslyDisableDeviceAuth = false | |
| configChanged = true | |
| results.push({ id: 'control_ui_device_auth', name: 'Control UI device auth', fixed: true, detail: 'Disabled dangerouslyDisableDeviceAuth', fixSafety: FIX_SAFETY['control_ui_device_auth'] }) | |
| } | |
| } | |
| // Fix Control UI insecure auth | |
| if (shouldFix('control_ui_insecure_auth')) { | |
| if (ocConfig.gateway?.controlUi?.allowInsecureAuth === true) { | |
| ocConfig.gateway.controlUi.allowInsecureAuth = false | |
| configChanged = true | |
| results.push({ id: 'control_ui_insecure_auth', name: 'Control UI secure auth', fixed: true, detail: 'Disabled allowInsecureAuth', fixSafety: FIX_SAFETY['control_ui_insecure_auth'] }) | |
| } | |
| } | |
| // Fix filesystem workspace isolation | |
| if (shouldFix('fs_workspace_only')) { | |
| if (!ocConfig.tools) ocConfig.tools = {} | |
| if (!ocConfig.tools.fs) ocConfig.tools.fs = {} | |
| if (ocConfig.tools.fs.workspaceOnly !== true) { | |
| ocConfig.tools.fs.workspaceOnly = true | |
| configChanged = true | |
| results.push({ id: 'fs_workspace_only', name: 'Filesystem workspace isolation', fixed: true, detail: 'Set tools.fs.workspaceOnly to true', fixSafety: FIX_SAFETY['fs_workspace_only'] }) | |
| } | |
| } | |
| // Fix log redaction | |
| if (shouldFix('log_redaction')) { | |
| if (!ocConfig.logging) ocConfig.logging = {} | |
| if (!ocConfig.logging.redactSensitive) { | |
| ocConfig.logging.redactSensitive = 'tools' | |
| configChanged = true | |
| results.push({ id: 'log_redaction', name: 'Log redaction', fixed: true, detail: 'Set logging.redactSensitive to "tools"', fixSafety: FIX_SAFETY['log_redaction'] }) | |
| } | |
| } | |
| if (configChanged) { | |
| try { | |
| writeFileSync(configPath, JSON.stringify(ocConfig, null, 2) + '\n', 'utf-8') | |
| } catch (e: any) { | |
| results.push({ id: 'config_write', name: 'Write OpenClaw config', fixed: false, detail: e.message }) | |
| } | |
| } | |
| } | |
| } | |
| // 7. Fix world-writable files (uses execFileSync with find — no user input) | |
| if (shouldFix('world_writable')) try { | |
| const cwd = process.cwd() | |
| const wwOutput = execFileSync('find', [cwd, '-maxdepth', '2', '-perm', '-o+w', '-not', '-type', 'l'], { | |
| encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], | |
| }).trim() | |
| if (wwOutput) { | |
| const files = wwOutput.split('\n').filter(Boolean).slice(0, 20) | |
| let fixedCount = 0 | |
| for (const f of files) { | |
| try { chmodSync(f, 0o755); fixedCount++ } catch { /* skip */ } | |
| } | |
| if (fixedCount > 0) { | |
| results.push({ id: 'world_writable', name: 'World-writable files', fixed: true, detail: `Fixed permissions on ${fixedCount} file(s)`, fixSafety: FIX_SAFETY['world_writable'] }) | |
| } | |
| } | |
| } catch { /* no world-writable files or find not available */ } | |
| // Audit log | |
| try { | |
| const db = getDatabase() | |
| db.prepare( | |
| 'INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)' | |
| ).run('security.auto_fix', auth.user.username, JSON.stringify({ fixes: results.filter(r => r.fixed).map(r => r.id) })) | |
| } catch { /* non-critical */ } | |
| const fixed = results.filter(r => r.fixed).length | |
| const failed = results.filter(r => !r.fixed).length | |
| const remainingChecks = getFailingChecks() | |
| const remainingAutoFixable = remainingChecks.filter((check) => check.id in FIX_SAFETY).length | |
| const remainingManual = remainingChecks.length - remainingAutoFixable | |
| logger.info({ fixed, failed, actor: auth.user.username }, 'Security auto-fix completed') | |
| return NextResponse.json({ | |
| attempted: results.length, | |
| fixed, | |
| failed, | |
| remaining: remainingChecks.length, | |
| remainingAutoFixable, | |
| remainingManual, | |
| results, | |
| note: remainingChecks.length > 0 | |
| ? 'Some issues require manual action or additional review. Environment-backed fixes may still require a server restart to fully apply.' | |
| : 'All currently detected auto-fixable issues have been resolved. Restart the server if you changed environment-backed settings.', | |
| }) | |
| } | |